mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-06 16:04:23 +00:00
Rename root folders
This commit is contained in:
66
app/src/components/auth/LoginPage.jsx
Normal file
66
app/src/components/auth/LoginPage.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Paper,
|
||||
Title,
|
||||
Container,
|
||||
Button,
|
||||
Text,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const LoginPage = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title ta="center">Welcome to NovaMD</Title>
|
||||
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||
Please sign in to continue
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Button type="submit" loading={loading}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
54
app/src/components/editor/ContentView.jsx
Normal file
54
app/src/components/editor/ContentView.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Text, Center } from '@mantine/core';
|
||||
import Editor from './Editor';
|
||||
import MarkdownPreview from './MarkdownPreview';
|
||||
import { getFileUrl } from '../../services/api';
|
||||
import { isImageFile } from '../../utils/fileHelpers';
|
||||
|
||||
const ContentView = ({
|
||||
activeTab,
|
||||
selectedFile,
|
||||
content,
|
||||
handleContentChange,
|
||||
handleSave,
|
||||
handleFileSelect,
|
||||
}) => {
|
||||
if (!selectedFile) {
|
||||
return (
|
||||
<Center style={{ height: '100%' }}>
|
||||
<Text size="xl" weight={500}>
|
||||
No file selected.
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (isImageFile(selectedFile)) {
|
||||
return (
|
||||
<Center className="image-preview">
|
||||
<img
|
||||
src={getFileUrl(selectedFile)}
|
||||
alt={selectedFile}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return activeTab === 'source' ? (
|
||||
<Editor
|
||||
content={content}
|
||||
handleContentChange={handleContentChange}
|
||||
handleSave={handleSave}
|
||||
selectedFile={selectedFile}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownPreview content={content} handleFileSelect={handleFileSelect} />
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentView;
|
||||
90
app/src/components/editor/Editor.jsx
Normal file
90
app/src/components/editor/Editor.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { basicSetup } from 'codemirror';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
|
||||
const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
||||
const { colorScheme } = useWorkspace();
|
||||
const editorRef = useRef();
|
||||
const viewRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
const handleEditorSave = (view) => {
|
||||
handleSave(selectedFile, view.state.doc.toString());
|
||||
return true;
|
||||
};
|
||||
|
||||
const theme = EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '14px',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: colorScheme === 'dark' ? '#1e1e1e' : '#f5f5f5',
|
||||
color: colorScheme === 'dark' ? '#858585' : '#999',
|
||||
border: 'none',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: colorScheme === 'dark' ? '#2c313a' : '#e8e8e8',
|
||||
},
|
||||
});
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: content,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
markdown(),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of(defaultKeymap),
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Ctrl-s',
|
||||
run: handleEditorSave,
|
||||
preventDefault: true,
|
||||
},
|
||||
]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
handleContentChange(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
theme,
|
||||
colorScheme === 'dark' ? oneDark : [],
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
state,
|
||||
parent: editorRef.current,
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
};
|
||||
}, [colorScheme, handleContentChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {
|
||||
viewRef.current.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: viewRef.current.state.doc.length,
|
||||
insert: content,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
return <div ref={editorRef} className="editor-container" />;
|
||||
};
|
||||
|
||||
export default Editor;
|
||||
111
app/src/components/editor/MarkdownPreview.jsx
Normal file
111
app/src/components/editor/MarkdownPreview.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { unified } from 'unified';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import rehypeMathjax from 'rehype-mathjax';
|
||||
import rehypeReact from 'rehype-react';
|
||||
import rehypePrism from 'rehype-prism';
|
||||
import * as prod from 'react/jsx-runtime';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { remarkWikiLinks } from '../../utils/remarkWikiLinks';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
|
||||
const MarkdownPreview = ({ content, handleFileSelect }) => {
|
||||
const [processedContent, setProcessedContent] = useState(null);
|
||||
const baseUrl = window.API_BASE_URL;
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const handleLinkClick = (e, href) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (href.startsWith(`${baseUrl}/internal/`)) {
|
||||
// For existing files, extract the path and directly select it
|
||||
const [filePath] = decodeURIComponent(
|
||||
href.replace(`${baseUrl}/internal/`, '')
|
||||
).split('#');
|
||||
handleFileSelect(filePath);
|
||||
} else if (href.startsWith(`${baseUrl}/notfound/`)) {
|
||||
// For non-existent files, show a notification
|
||||
const fileName = decodeURIComponent(
|
||||
href.replace(`${baseUrl}/notfound/`, '')
|
||||
);
|
||||
notifications.show({
|
||||
title: 'File Not Found',
|
||||
message: `The file "${fileName}" does not exist.`,
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const processor = useMemo(
|
||||
() =>
|
||||
unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkWikiLinks, currentWorkspace?.name)
|
||||
.use(remarkMath)
|
||||
.use(remarkRehype)
|
||||
.use(rehypeMathjax)
|
||||
.use(rehypePrism)
|
||||
.use(rehypeReact, {
|
||||
production: true,
|
||||
jsx: prod.jsx,
|
||||
jsxs: prod.jsxs,
|
||||
Fragment: prod.Fragment,
|
||||
components: {
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
onError={(event) => {
|
||||
console.error('Failed to load image:', event.target.src);
|
||||
event.target.alt = 'Failed to load image';
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
a: ({ href, children, ...props }) => (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => handleLinkClick(e, href)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children, className, ...props }) => {
|
||||
const language = className
|
||||
? className.replace('language-', '')
|
||||
: null;
|
||||
return (
|
||||
<pre className={className}>
|
||||
<code {...props}>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
[baseUrl, handleFileSelect, currentWorkspace?.name]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const processContent = async () => {
|
||||
if (!currentWorkspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processor.process(content);
|
||||
setProcessedContent(result.result);
|
||||
} catch (error) {
|
||||
console.error('Error processing markdown:', error);
|
||||
}
|
||||
};
|
||||
|
||||
processContent();
|
||||
}, [content, processor, currentWorkspace]);
|
||||
|
||||
return <div className="markdown-preview">{processedContent}</div>;
|
||||
};
|
||||
|
||||
export default MarkdownPreview;
|
||||
85
app/src/components/files/FileActions.jsx
Normal file
85
app/src/components/files/FileActions.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { ActionIcon, Tooltip, Group } from '@mantine/core';
|
||||
import {
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconGitPullRequest,
|
||||
IconGitCommit,
|
||||
} from '@tabler/icons-react';
|
||||
import { useModalContext } from '../../contexts/ModalContext';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
|
||||
const FileActions = ({ handlePullChanges, selectedFile }) => {
|
||||
const { settings } = useWorkspace();
|
||||
const {
|
||||
setNewFileModalVisible,
|
||||
setDeleteFileModalVisible,
|
||||
setCommitMessageModalVisible,
|
||||
} = useModalContext();
|
||||
|
||||
const handleCreateFile = () => setNewFileModalVisible(true);
|
||||
const handleDeleteFile = () => setDeleteFileModalVisible(true);
|
||||
const handleCommitAndPush = () => setCommitMessageModalVisible(true);
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Tooltip label="Create new file">
|
||||
<ActionIcon variant="default" size="md" onClick={handleCreateFile}>
|
||||
<IconPlus size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label={selectedFile ? 'Delete current file' : 'No file selected'}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="md"
|
||||
onClick={handleDeleteFile}
|
||||
disabled={!selectedFile}
|
||||
color="red"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label={
|
||||
settings.gitEnabled
|
||||
? 'Pull changes from remote'
|
||||
: 'Git is not enabled'
|
||||
}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="md"
|
||||
onClick={handlePullChanges}
|
||||
disabled={!settings.gitEnabled}
|
||||
>
|
||||
<IconGitPullRequest size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label={
|
||||
!settings.gitEnabled
|
||||
? 'Git is not enabled'
|
||||
: settings.gitAutoCommit
|
||||
? 'Auto-commit is enabled'
|
||||
: 'Commit and push changes'
|
||||
}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="md"
|
||||
onClick={handleCommitAndPush}
|
||||
disabled={!settings.gitEnabled || settings.gitAutoCommit}
|
||||
>
|
||||
<IconGitCommit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileActions;
|
||||
110
app/src/components/files/FileTree.jsx
Normal file
110
app/src/components/files/FileTree.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useRef, useState, useLayoutEffect } from 'react';
|
||||
import { Tree } from 'react-arborist';
|
||||
import { IconFile, IconFolder, IconFolderOpen } from '@tabler/icons-react';
|
||||
import { Tooltip } from '@mantine/core';
|
||||
import useResizeObserver from '@react-hook/resize-observer';
|
||||
|
||||
const useSize = (target) => {
|
||||
const [size, setSize] = useState();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSize(target.current.getBoundingClientRect());
|
||||
}, [target]);
|
||||
|
||||
useResizeObserver(target, (entry) => setSize(entry.contentRect));
|
||||
return size;
|
||||
};
|
||||
|
||||
const FileIcon = ({ node }) => {
|
||||
if (node.isLeaf) {
|
||||
return <IconFile size={16} />;
|
||||
}
|
||||
return node.isOpen ? (
|
||||
<IconFolderOpen size={16} color="var(--mantine-color-yellow-filled)" />
|
||||
) : (
|
||||
<IconFolder size={16} color="var(--mantine-color-yellow-filled)" />
|
||||
);
|
||||
};
|
||||
|
||||
const Node = ({ node, style, dragHandle }) => {
|
||||
return (
|
||||
<Tooltip label={node.data.name} openDelay={500}>
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={{
|
||||
...style,
|
||||
paddingLeft: `${node.level * 20}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (node.isInternal) {
|
||||
node.toggle();
|
||||
} else {
|
||||
node.tree.props.onNodeClick(node);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FileIcon node={node} />
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
fontSize: '14px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{node.data.name}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const FileTree = ({ files, handleFileSelect, showHiddenFiles }) => {
|
||||
const target = useRef(null);
|
||||
const size = useSize(target);
|
||||
|
||||
files = files.filter((file) => {
|
||||
if (file.name.startsWith('.') && !showHiddenFiles) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={target}
|
||||
style={{ height: 'calc(100vh - 140px)', marginTop: '20px' }}
|
||||
>
|
||||
{size && (
|
||||
<Tree
|
||||
data={files}
|
||||
openByDefault={false}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
indent={24}
|
||||
rowHeight={28}
|
||||
onActivate={(node) => {
|
||||
if (!node.isInternal) {
|
||||
handleFileSelect(node.data.path);
|
||||
}
|
||||
}}
|
||||
onNodeClick={(node) => {
|
||||
if (!node.isInternal) {
|
||||
handleFileSelect(node.data.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Node}
|
||||
</Tree>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileTree;
|
||||
22
app/src/components/layout/Header.jsx
Normal file
22
app/src/components/layout/Header.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Group, Text } from '@mantine/core';
|
||||
import UserMenu from '../navigation/UserMenu';
|
||||
import WorkspaceSwitcher from '../navigation/WorkspaceSwitcher';
|
||||
import WorkspaceSettings from '../settings/workspace/WorkspaceSettings';
|
||||
|
||||
const Header = () => {
|
||||
return (
|
||||
<Group justify="space-between" h={60} px="md">
|
||||
<Text fw={700} size="lg">
|
||||
NovaMD
|
||||
</Text>
|
||||
<Group>
|
||||
<WorkspaceSwitcher />
|
||||
<UserMenu />
|
||||
</Group>
|
||||
<WorkspaceSettings />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
59
app/src/components/layout/Layout.jsx
Normal file
59
app/src/components/layout/Layout.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { AppShell, Container, Loader, Center } from '@mantine/core';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
import MainContent from './MainContent';
|
||||
import { useFileNavigation } from '../../hooks/useFileNavigation';
|
||||
import { useFileList } from '../../hooks/useFileList';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
|
||||
const Layout = () => {
|
||||
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
|
||||
const { selectedFile, handleFileSelect } = useFileNavigation();
|
||||
const { files, loadFileList } = useFileList();
|
||||
|
||||
if (workspaceLoading) {
|
||||
return (
|
||||
<Center style={{ height: '100vh' }}>
|
||||
<Loader size="xl" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentWorkspace) {
|
||||
return <div>No workspace found. Please create a workspace.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell header={{ height: 60 }} padding="md">
|
||||
<AppShell.Header>
|
||||
<Header />
|
||||
</AppShell.Header>
|
||||
<AppShell.Main>
|
||||
<Container
|
||||
size="xl"
|
||||
p={0}
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: 'calc(100vh - 60px - 2rem)', // Subtracting header height and vertical padding
|
||||
overflow: 'hidden', // Prevent scrolling in the container
|
||||
}}
|
||||
>
|
||||
<Sidebar
|
||||
selectedFile={selectedFile}
|
||||
handleFileSelect={handleFileSelect}
|
||||
files={files}
|
||||
loadFileList={loadFileList}
|
||||
/>
|
||||
<MainContent
|
||||
selectedFile={selectedFile}
|
||||
handleFileSelect={handleFileSelect}
|
||||
loadFileList={loadFileList}
|
||||
/>
|
||||
</Container>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
124
app/src/components/layout/MainContent.jsx
Normal file
124
app/src/components/layout/MainContent.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Tabs, Breadcrumbs, Group, Box, Text, Flex } from '@mantine/core';
|
||||
import { IconCode, IconEye, IconPointFilled } from '@tabler/icons-react';
|
||||
|
||||
import ContentView from '../editor/ContentView';
|
||||
import CreateFileModal from '../modals/file/CreateFileModal';
|
||||
import DeleteFileModal from '../modals/file/DeleteFileModal';
|
||||
import CommitMessageModal from '../modals/git/CommitMessageModal';
|
||||
|
||||
import { useFileContent } from '../../hooks/useFileContent';
|
||||
import { useFileOperations } from '../../hooks/useFileOperations';
|
||||
import { useGitOperations } from '../../hooks/useGitOperations';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
|
||||
const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
|
||||
const [activeTab, setActiveTab] = useState('source');
|
||||
const { settings } = useWorkspace();
|
||||
const {
|
||||
content,
|
||||
hasUnsavedChanges,
|
||||
setHasUnsavedChanges,
|
||||
handleContentChange,
|
||||
} = useFileContent(selectedFile);
|
||||
const { handleSave, handleCreate, handleDelete } = useFileOperations();
|
||||
const { handleCommitAndPush } = useGitOperations(settings.gitEnabled);
|
||||
|
||||
const handleTabChange = useCallback((value) => {
|
||||
setActiveTab(value);
|
||||
}, []);
|
||||
|
||||
const handleSaveFile = useCallback(
|
||||
async (filePath, content) => {
|
||||
let success = await handleSave(filePath, content);
|
||||
if (success) {
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
return success;
|
||||
},
|
||||
[handleSave, setHasUnsavedChanges]
|
||||
);
|
||||
|
||||
const handleCreateFile = useCallback(
|
||||
async (fileName) => {
|
||||
const success = await handleCreate(fileName);
|
||||
if (success) {
|
||||
loadFileList();
|
||||
handleFileSelect(fileName);
|
||||
}
|
||||
},
|
||||
[handleCreate, handleFileSelect, loadFileList]
|
||||
);
|
||||
|
||||
const handleDeleteFile = useCallback(
|
||||
async (filePath) => {
|
||||
const success = await handleDelete(filePath);
|
||||
if (success) {
|
||||
loadFileList();
|
||||
handleFileSelect(null);
|
||||
}
|
||||
},
|
||||
[handleDelete, handleFileSelect, loadFileList]
|
||||
);
|
||||
|
||||
const renderBreadcrumbs = useMemo(() => {
|
||||
if (!selectedFile) return null;
|
||||
const pathParts = selectedFile.split('/');
|
||||
const items = pathParts.map((part, index) => (
|
||||
<Text key={index} size="sm">
|
||||
{part}
|
||||
</Text>
|
||||
));
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Breadcrumbs separator="/">{items}</Breadcrumbs>
|
||||
{hasUnsavedChanges && (
|
||||
<IconPointFilled
|
||||
size={16}
|
||||
style={{ color: 'var(--mantine-color-yellow-filled)' }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}, [selectedFile, hasUnsavedChanges]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Flex justify="space-between" align="center" p="md">
|
||||
{renderBreadcrumbs}
|
||||
<Tabs value={activeTab} onChange={handleTabChange}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="source" leftSection={<IconCode size="0.8rem" />} />
|
||||
<Tabs.Tab value="preview" leftSection={<IconEye size="0.8rem" />} />
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</Flex>
|
||||
<Box style={{ flex: 1, overflow: 'auto' }}>
|
||||
<ContentView
|
||||
activeTab={activeTab}
|
||||
selectedFile={selectedFile}
|
||||
content={content}
|
||||
handleContentChange={handleContentChange}
|
||||
handleSave={handleSaveFile}
|
||||
handleFileSelect={handleFileSelect}
|
||||
/>
|
||||
</Box>
|
||||
<CreateFileModal onCreateFile={handleCreateFile} />
|
||||
<DeleteFileModal
|
||||
onDeleteFile={handleDeleteFile}
|
||||
selectedFile={selectedFile}
|
||||
/>
|
||||
<CommitMessageModal onCommitAndPush={handleCommitAndPush} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainContent;
|
||||
37
app/src/components/layout/Sidebar.jsx
Normal file
37
app/src/components/layout/Sidebar.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import FileActions from '../files/FileActions';
|
||||
import FileTree from '../files/FileTree';
|
||||
import { useGitOperations } from '../../hooks/useGitOperations';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
|
||||
const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
|
||||
const { settings } = useWorkspace();
|
||||
const { handlePull } = useGitOperations(settings.gitEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
loadFileList();
|
||||
}, [loadFileList]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
width: '25%',
|
||||
minWidth: '200px',
|
||||
maxWidth: '300px',
|
||||
borderRight: '1px solid var(--app-shell-border-color)',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<FileActions handlePullChanges={handlePull} selectedFile={selectedFile} />
|
||||
<FileTree
|
||||
files={files}
|
||||
handleFileSelect={handleFileSelect}
|
||||
showHiddenFiles={settings.showHiddenFiles}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
55
app/src/components/modals/account/DeleteAccountModal.jsx
Normal file
55
app/src/components/modals/account/DeleteAccountModal.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
PasswordInput,
|
||||
Group,
|
||||
Button,
|
||||
} from '@mantine/core';
|
||||
|
||||
const DeleteAccountModal = ({ opened, onClose, onConfirm }) => {
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Delete Account"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack>
|
||||
<Text c="red" fw={500}>
|
||||
Warning: This action cannot be undone
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
Please enter your password to confirm account deletion.
|
||||
</Text>
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
placeholder="Enter your current password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
onConfirm(password);
|
||||
setPassword('');
|
||||
}}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteAccountModal;
|
||||
51
app/src/components/modals/account/EmailPasswordModal.jsx
Normal file
51
app/src/components/modals/account/EmailPasswordModal.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
PasswordInput,
|
||||
} from '@mantine/core';
|
||||
|
||||
const EmailPasswordModal = ({ opened, onClose, onConfirm, email }) => {
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Confirm Password"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm">
|
||||
Please enter your password to confirm changing your email to: {email}
|
||||
</Text>
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
placeholder="Enter your current password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onConfirm(password);
|
||||
setPassword('');
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailPasswordModal;
|
||||
48
app/src/components/modals/file/CreateFileModal.jsx
Normal file
48
app/src/components/modals/file/CreateFileModal.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
|
||||
import { useModalContext } from '../../../contexts/ModalContext';
|
||||
|
||||
const CreateFileModal = ({ onCreateFile }) => {
|
||||
const [fileName, setFileName] = useState('');
|
||||
const { newFileModalVisible, setNewFileModalVisible } = useModalContext();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (fileName) {
|
||||
await onCreateFile(fileName);
|
||||
setFileName('');
|
||||
setNewFileModalVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={newFileModalVisible}
|
||||
onClose={() => setNewFileModalVisible(false)}
|
||||
title="Create New File"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Box maw={400} mx="auto">
|
||||
<TextInput
|
||||
label="File Name"
|
||||
placeholder="Enter file name"
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.currentTarget.value)}
|
||||
mb="md"
|
||||
w="100%"
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setNewFileModalVisible(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>Create</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateFileModal;
|
||||
37
app/src/components/modals/file/DeleteFileModal.jsx
Normal file
37
app/src/components/modals/file/DeleteFileModal.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Modal, Text, Button, Group } from '@mantine/core';
|
||||
import { useModalContext } from '../../../contexts/ModalContext';
|
||||
|
||||
const DeleteFileModal = ({ onDeleteFile, selectedFile }) => {
|
||||
const { deleteFileModalVisible, setDeleteFileModalVisible } =
|
||||
useModalContext();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onDeleteFile(selectedFile);
|
||||
setDeleteFileModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={deleteFileModalVisible}
|
||||
onClose={() => setDeleteFileModalVisible(false)}
|
||||
title="Delete File"
|
||||
centered
|
||||
>
|
||||
<Text>Are you sure you want to delete "{selectedFile}"?</Text>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setDeleteFileModalVisible(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={handleConfirm}>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteFileModal;
|
||||
49
app/src/components/modals/git/CommitMessageModal.jsx
Normal file
49
app/src/components/modals/git/CommitMessageModal.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
|
||||
import { useModalContext } from '../../../contexts/ModalContext';
|
||||
|
||||
const CommitMessageModal = ({ onCommitAndPush }) => {
|
||||
const [message, setMessage] = useState('');
|
||||
const { commitMessageModalVisible, setCommitMessageModalVisible } =
|
||||
useModalContext();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (message) {
|
||||
await onCommitAndPush(message);
|
||||
setMessage('');
|
||||
setCommitMessageModalVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={commitMessageModalVisible}
|
||||
onClose={() => setCommitMessageModalVisible(false)}
|
||||
title="Enter Commit Message"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Box maw={400} mx="auto">
|
||||
<TextInput
|
||||
label="Commit Message"
|
||||
placeholder="Enter commit message"
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.currentTarget.value)}
|
||||
mb="md"
|
||||
w="100%"
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setCommitMessageModalVisible(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>Commit</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommitMessageModal;
|
||||
76
app/src/components/modals/user/CreateUserModal.jsx
Normal file
76
app/src/components/modals/user/CreateUserModal.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Button,
|
||||
Group,
|
||||
} from '@mantine/core';
|
||||
|
||||
const CreateUserModal = ({ opened, onClose, onCreateUser, loading }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [role, setRole] = useState('viewer');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const result = await onCreateUser({ email, password, displayName, role });
|
||||
if (result.success) {
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
setDisplayName('');
|
||||
setRole('viewer');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Create New User" centered>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
<TextInput
|
||||
label="Display Name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.currentTarget.value)}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
<Select
|
||||
label="Role"
|
||||
required
|
||||
value={role}
|
||||
onChange={setRole}
|
||||
data={[
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'editor', label: 'Editor' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
]}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={loading}>
|
||||
Create User
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateUserModal;
|
||||
29
app/src/components/modals/user/DeleteUserModal.jsx
Normal file
29
app/src/components/modals/user/DeleteUserModal.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||
|
||||
const DeleteUserModal = ({ opened, onClose, onConfirm, user, loading }) => (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Delete User"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack>
|
||||
<Text>
|
||||
Are you sure you want to delete user "{user?.email}"? This action cannot
|
||||
be undone and all associated data will be permanently deleted.
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={onConfirm} loading={loading}>
|
||||
Delete User
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default DeleteUserModal;
|
||||
105
app/src/components/modals/user/EditUserModal.jsx
Normal file
105
app/src/components/modals/user/EditUserModal.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
TextInput,
|
||||
Select,
|
||||
Button,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
|
||||
const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
displayName: '',
|
||||
role: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
email: user.email,
|
||||
displayName: user.displayName || '',
|
||||
role: user.role,
|
||||
password: '',
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const updateData = {
|
||||
...formData,
|
||||
...(formData.password ? { password: formData.password } : {}),
|
||||
};
|
||||
|
||||
const result = await onEditUser(user.id, updateData);
|
||||
if (result.success) {
|
||||
setFormData({
|
||||
email: '',
|
||||
displayName: '',
|
||||
role: '',
|
||||
password: '',
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Edit User" centered>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.currentTarget.value })
|
||||
}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
<TextInput
|
||||
label="Display Name"
|
||||
value={formData.displayName}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, displayName: e.currentTarget.value })
|
||||
}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
<Select
|
||||
label="Role"
|
||||
required
|
||||
value={formData.role}
|
||||
onChange={(value) => setFormData({ ...formData, role: value })}
|
||||
data={[
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'editor', label: 'Editor' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
]}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="New Password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.currentTarget.value })
|
||||
}
|
||||
placeholder="Enter new password (leave empty to keep current)"
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
Leave password empty to keep the current password
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={loading}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditUserModal;
|
||||
82
app/src/components/modals/workspace/CreateWorkspaceModal.jsx
Normal file
82
app/src/components/modals/workspace/CreateWorkspaceModal.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
|
||||
import { useModalContext } from '../../../contexts/ModalContext';
|
||||
import { createWorkspace } from '../../../services/api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { createWorkspaceModalVisible, setCreateWorkspaceModalVisible } =
|
||||
useModalContext();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Workspace name is required',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const workspace = await createWorkspace(name);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Workspace created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
setName('');
|
||||
setCreateWorkspaceModalVisible(false);
|
||||
if (onWorkspaceCreated) {
|
||||
onWorkspaceCreated(workspace);
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to create workspace',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={createWorkspaceModalVisible}
|
||||
onClose={() => setCreateWorkspaceModalVisible(false)}
|
||||
title="Create New Workspace"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Box maw={400} mx="auto">
|
||||
<TextInput
|
||||
label="Workspace Name"
|
||||
placeholder="Enter workspace name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
mb="md"
|
||||
w="100%"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setCreateWorkspaceModalVisible(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={loading}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateWorkspaceModal;
|
||||
35
app/src/components/modals/workspace/DeleteWorkspaceModal.jsx
Normal file
35
app/src/components/modals/workspace/DeleteWorkspaceModal.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||
|
||||
const DeleteWorkspaceModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
workspaceName,
|
||||
}) => (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Delete Workspace"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack>
|
||||
<Text>
|
||||
Are you sure you want to delete workspace "{workspaceName}"? This action
|
||||
cannot be undone and all files in this workspace will be permanently
|
||||
deleted.
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={onConfirm}>
|
||||
Delete Workspace
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default DeleteWorkspaceModal;
|
||||
155
app/src/components/navigation/UserMenu.jsx
Normal file
155
app/src/components/navigation/UserMenu.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Popover,
|
||||
Stack,
|
||||
UnstyledButton,
|
||||
Group,
|
||||
Text,
|
||||
Divider,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconLogout,
|
||||
IconSettings,
|
||||
} from '@tabler/icons-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import AccountSettings from '../settings/account/AccountSettings';
|
||||
import AdminDashboard from '../settings/admin/AdminDashboard';
|
||||
|
||||
const UserMenu = () => {
|
||||
const [accountSettingsOpened, setAccountSettingsOpened] = useState(false);
|
||||
const [adminDashboardOpened, setAdminDashboardOpened] = useState(false);
|
||||
const [opened, setOpened] = useState(false);
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
width={200}
|
||||
position="bottom-end"
|
||||
withArrow
|
||||
shadow="md"
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Avatar
|
||||
radius="xl"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
>
|
||||
<IconUser size={24} />
|
||||
</Avatar>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="sm">
|
||||
{/* User Info Section */}
|
||||
<Group gap="sm">
|
||||
<Avatar radius="xl" size="md">
|
||||
<IconUser size={24} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{user.displayName || user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Menu Items */}
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
setAccountSettingsOpened(true);
|
||||
setOpened(false);
|
||||
}}
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={(theme) => ({
|
||||
borderRadius: theme.radius.sm,
|
||||
'&:hover': {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[5]
|
||||
: theme.colors.gray[0],
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Group>
|
||||
<IconSettings size={16} />
|
||||
<Text size="sm">Account Settings</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
setAdminDashboardOpened(true);
|
||||
setOpened(false);
|
||||
}}
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={(theme) => ({
|
||||
borderRadius: theme.radius.sm,
|
||||
'&:hover': {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[5]
|
||||
: theme.colors.gray[0],
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Group>
|
||||
<IconUsers size={16} />
|
||||
<Text size="sm">Admin Dashboard</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
|
||||
<UnstyledButton
|
||||
onClick={handleLogout}
|
||||
px="sm"
|
||||
py="xs"
|
||||
color="red"
|
||||
style={(theme) => ({
|
||||
borderRadius: theme.radius.sm,
|
||||
'&:hover': {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[5]
|
||||
: theme.colors.gray[0],
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Group>
|
||||
<IconLogout size={16} color="red" />
|
||||
<Text size="sm" c="red">
|
||||
Logout
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<AccountSettings
|
||||
opened={accountSettingsOpened}
|
||||
onClose={() => setAccountSettingsOpened(false)}
|
||||
/>
|
||||
|
||||
<AdminDashboard
|
||||
opened={adminDashboardOpened}
|
||||
onClose={() => setAdminDashboardOpened(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserMenu;
|
||||
197
app/src/components/navigation/WorkspaceSwitcher.jsx
Normal file
197
app/src/components/navigation/WorkspaceSwitcher.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Popover,
|
||||
Stack,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
Loader,
|
||||
Center,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
import { useModalContext } from '../../contexts/ModalContext';
|
||||
import { listWorkspaces } from '../../services/api';
|
||||
import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal';
|
||||
|
||||
const WorkspaceSwitcher = () => {
|
||||
const { currentWorkspace, switchWorkspace } = useWorkspace();
|
||||
const { setSettingsModalVisible, setCreateWorkspaceModalVisible } =
|
||||
useModalContext();
|
||||
const [workspaces, setWorkspaces] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [popoverOpened, setPopoverOpened] = useState(false);
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const loadWorkspaces = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await listWorkspaces();
|
||||
setWorkspaces(list);
|
||||
} catch (error) {
|
||||
console.error('Failed to load workspaces:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
setPopoverOpened(false);
|
||||
setCreateWorkspaceModalVisible(true);
|
||||
};
|
||||
|
||||
const handleWorkspaceCreated = async (newWorkspace) => {
|
||||
await loadWorkspaces();
|
||||
switchWorkspace(newWorkspace.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
width={300}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
opened={popoverOpened}
|
||||
onChange={setPopoverOpened}
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
setPopoverOpened((o) => !o);
|
||||
if (!popoverOpened) {
|
||||
loadWorkspaces();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconFolders size={20} />
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{currentWorkspace?.name || 'No workspace'}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown p="xs">
|
||||
<Group justify="space-between" mb="md" px="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Workspaces
|
||||
</Text>
|
||||
<Tooltip label="Create New Workspace">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="md"
|
||||
onClick={handleCreateWorkspace}
|
||||
>
|
||||
<IconFolderPlus size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={400} offsetScrollbars>
|
||||
<Stack gap="xs">
|
||||
{loading ? (
|
||||
<Center p="md">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
workspaces.map((workspace) => {
|
||||
const isSelected = workspace.name === currentWorkspace?.name;
|
||||
return (
|
||||
<Paper
|
||||
key={workspace.name}
|
||||
p="xs"
|
||||
withBorder
|
||||
style={{
|
||||
backgroundColor: isSelected
|
||||
? theme.colors.blue[
|
||||
theme.colorScheme === 'dark' ? 8 : 1
|
||||
]
|
||||
: undefined,
|
||||
borderColor: isSelected
|
||||
? theme.colors.blue[
|
||||
theme.colorScheme === 'dark' ? 7 : 5
|
||||
]
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<UnstyledButton
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => {
|
||||
switchWorkspace(workspace.name);
|
||||
setPopoverOpened(false);
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
truncate
|
||||
c={
|
||||
isSelected
|
||||
? theme.colors.blue[
|
||||
theme.colorScheme === 'dark' ? 0 : 9
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c={
|
||||
isSelected
|
||||
? theme.colorScheme === 'dark'
|
||||
? theme.colors.blue[2]
|
||||
: theme.colors.blue[7]
|
||||
: 'dimmed'
|
||||
}
|
||||
>
|
||||
{new Date(
|
||||
workspace.createdAt
|
||||
).toLocaleDateString()}
|
||||
</Text>
|
||||
</Box>
|
||||
</UnstyledButton>
|
||||
{isSelected && (
|
||||
<Tooltip label="Workspace Settings">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
color={
|
||||
theme.colorScheme === 'dark'
|
||||
? 'blue.2'
|
||||
: 'blue.7'
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSettingsModalVisible(true);
|
||||
setPopoverOpened(false);
|
||||
}}
|
||||
>
|
||||
<IconSettings size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<CreateWorkspaceModal onWorkspaceCreated={handleWorkspaceCreated} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceSwitcher;
|
||||
10
app/src/components/settings/AccordionControl.jsx
Normal file
10
app/src/components/settings/AccordionControl.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Accordion, Title } from '@mantine/core';
|
||||
|
||||
const AccordionControl = ({ children }) => (
|
||||
<Accordion.Control>
|
||||
<Title order={4}>{children}</Title>
|
||||
</Accordion.Control>
|
||||
);
|
||||
|
||||
export default AccordionControl;
|
||||
248
app/src/components/settings/account/AccountSettings.jsx
Normal file
248
app/src/components/settings/account/AccountSettings.jsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useReducer, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Title,
|
||||
Stack,
|
||||
Accordion,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { useProfileSettings } from '../../../hooks/useProfileSettings';
|
||||
import EmailPasswordModal from '../../modals/account/EmailPasswordModal';
|
||||
import SecuritySettings from './SecuritySettings';
|
||||
import ProfileSettings from './ProfileSettings';
|
||||
import DangerZoneSettings from './DangerZoneSettings';
|
||||
import AccordionControl from '../AccordionControl';
|
||||
|
||||
// Reducer for managing settings state
|
||||
const initialState = {
|
||||
localSettings: {},
|
||||
initialSettings: {},
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
|
||||
function settingsReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'INIT_SETTINGS':
|
||||
return {
|
||||
...state,
|
||||
localSettings: action.payload,
|
||||
initialSettings: action.payload,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
case 'UPDATE_LOCAL_SETTINGS':
|
||||
const newLocalSettings = { ...state.localSettings, ...action.payload };
|
||||
const hasChanges =
|
||||
JSON.stringify(newLocalSettings) !==
|
||||
JSON.stringify(state.initialSettings);
|
||||
return {
|
||||
...state,
|
||||
localSettings: newLocalSettings,
|
||||
hasUnsavedChanges: hasChanges,
|
||||
};
|
||||
case 'MARK_SAVED':
|
||||
return {
|
||||
...state,
|
||||
initialSettings: state.localSettings,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const AccountSettings = ({ opened, onClose }) => {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const { loading, updateProfile } = useProfileSettings();
|
||||
const [state, dispatch] = useReducer(settingsReducer, initialState);
|
||||
const isInitialMount = useRef(true);
|
||||
const [emailModalOpened, setEmailModalOpened] = useState(false);
|
||||
|
||||
// Initialize settings on mount
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current && user) {
|
||||
isInitialMount.current = false;
|
||||
const settings = {
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
};
|
||||
dispatch({ type: 'INIT_SETTINGS', payload: settings });
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleInputChange = (key, value) => {
|
||||
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const updates = {};
|
||||
const needsPasswordConfirmation =
|
||||
state.localSettings.email !== state.initialSettings.email;
|
||||
|
||||
// Add display name if changed
|
||||
if (state.localSettings.displayName !== state.initialSettings.displayName) {
|
||||
updates.displayName = state.localSettings.displayName;
|
||||
}
|
||||
|
||||
// Handle password change
|
||||
if (state.localSettings.newPassword) {
|
||||
if (!state.localSettings.currentPassword) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Current password is required to change password',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
updates.newPassword = state.localSettings.newPassword;
|
||||
updates.currentPassword = state.localSettings.currentPassword;
|
||||
}
|
||||
|
||||
// If we're only changing display name or have password already provided, proceed directly
|
||||
if (!needsPasswordConfirmation || state.localSettings.currentPassword) {
|
||||
if (needsPasswordConfirmation) {
|
||||
updates.email = state.localSettings.email;
|
||||
// If we don't have a password change, we still need to include the current password for email change
|
||||
if (!updates.currentPassword) {
|
||||
updates.currentPassword = state.localSettings.currentPassword;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await updateProfile(updates);
|
||||
if (result.success) {
|
||||
await refreshUser();
|
||||
dispatch({ type: 'MARK_SAVED' });
|
||||
onClose();
|
||||
}
|
||||
} else {
|
||||
// Only show the email confirmation modal if we don't already have the password
|
||||
setEmailModalOpened(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailConfirm = async (password) => {
|
||||
const updates = {
|
||||
...state.localSettings,
|
||||
currentPassword: password,
|
||||
};
|
||||
// Remove any undefined/empty values
|
||||
Object.keys(updates).forEach((key) => {
|
||||
if (updates[key] === undefined || updates[key] === '') {
|
||||
delete updates[key];
|
||||
}
|
||||
});
|
||||
// Remove keys that haven't changed
|
||||
if (updates.displayName === state.initialSettings.displayName) {
|
||||
delete updates.displayName;
|
||||
}
|
||||
if (updates.email === state.initialSettings.email) {
|
||||
delete updates.email;
|
||||
}
|
||||
|
||||
const result = await updateProfile(updates);
|
||||
if (result.success) {
|
||||
await refreshUser();
|
||||
dispatch({ type: 'MARK_SAVED' });
|
||||
setEmailModalOpened(false);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Title order={2}>Account Settings</Title>}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Stack spacing="xl">
|
||||
{state.hasUnsavedChanges && (
|
||||
<Badge color="yellow" variant="light">
|
||||
Unsaved Changes
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Accordion
|
||||
defaultValue={['profile', 'security', 'danger']}
|
||||
multiple
|
||||
styles={(theme) => ({
|
||||
control: {
|
||||
paddingTop: theme.spacing.md,
|
||||
paddingBottom: theme.spacing.md,
|
||||
},
|
||||
item: {
|
||||
borderBottom: `1px solid ${
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[4]
|
||||
: theme.colors.gray[3]
|
||||
}`,
|
||||
'&[data-active]': {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[7]
|
||||
: theme.colors.gray[0],
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Accordion.Item value="profile">
|
||||
<AccordionControl>Profile</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<ProfileSettings
|
||||
settings={state.localSettings}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="security">
|
||||
<AccordionControl>Security</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<SecuritySettings
|
||||
settings={state.localSettings}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="danger">
|
||||
<AccordionControl>Danger Zone</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<DangerZoneSettings />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={!state.hasUnsavedChanges}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<EmailPasswordModal
|
||||
opened={emailModalOpened}
|
||||
onClose={() => setEmailModalOpened(false)}
|
||||
onConfirm={handleEmailConfirm}
|
||||
email={state.localSettings.email}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountSettings;
|
||||
43
app/src/components/settings/account/DangerZoneSettings.jsx
Normal file
43
app/src/components/settings/account/DangerZoneSettings.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Button, Text } from '@mantine/core';
|
||||
import DeleteAccountModal from '../../modals/account/DeleteAccountModal';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { useProfileSettings } from '../../../hooks/useProfileSettings';
|
||||
|
||||
const DangerZoneSettings = () => {
|
||||
const { logout } = useAuth();
|
||||
const { deleteAccount } = useProfileSettings();
|
||||
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
|
||||
|
||||
const handleDelete = async (password) => {
|
||||
const result = await deleteAccount(password);
|
||||
if (result.success) {
|
||||
setDeleteModalOpened(false);
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box mb="md">
|
||||
<Text size="sm" mb="sm" c="dimmed">
|
||||
Once you delete your account, there is no going back. Please be certain.
|
||||
</Text>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={() => setDeleteModalOpened(true)}
|
||||
fullWidth
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
|
||||
<DeleteAccountModal
|
||||
opened={deleteModalOpened}
|
||||
onClose={() => setDeleteModalOpened(false)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DangerZoneSettings;
|
||||
23
app/src/components/settings/account/ProfileSettings.jsx
Normal file
23
app/src/components/settings/account/ProfileSettings.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, TextInput } from '@mantine/core';
|
||||
|
||||
const ProfileSettings = ({ settings, onInputChange }) => (
|
||||
<Box>
|
||||
<Stack spacing="md">
|
||||
<TextInput
|
||||
label="Display Name"
|
||||
value={settings.displayName || ''}
|
||||
onChange={(e) => onInputChange('displayName', e.currentTarget.value)}
|
||||
placeholder="Enter display name"
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
value={settings.email || ''}
|
||||
onChange={(e) => onInputChange('email', e.currentTarget.value)}
|
||||
placeholder="Enter email"
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default ProfileSettings;
|
||||
65
app/src/components/settings/account/SecuritySettings.jsx
Normal file
65
app/src/components/settings/account/SecuritySettings.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, PasswordInput, Stack, Text } from '@mantine/core';
|
||||
|
||||
const SecuritySettings = ({ settings, onInputChange }) => {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handlePasswordChange = (field, value) => {
|
||||
if (field === 'confirmNewPassword') {
|
||||
setConfirmPassword(value);
|
||||
// Check if passwords match when either password field changes
|
||||
if (value !== settings.newPassword) {
|
||||
setError('Passwords do not match');
|
||||
} else {
|
||||
setError('');
|
||||
}
|
||||
} else {
|
||||
onInputChange(field, value);
|
||||
// Check if passwords match when either password field changes
|
||||
if (field === 'newPassword' && value !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
} else if (value === confirmPassword) {
|
||||
setError('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack spacing="md">
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
value={settings.currentPassword || ''}
|
||||
onChange={(e) =>
|
||||
handlePasswordChange('currentPassword', e.currentTarget.value)
|
||||
}
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
<PasswordInput
|
||||
label="New Password"
|
||||
value={settings.newPassword || ''}
|
||||
onChange={(e) =>
|
||||
handlePasswordChange('newPassword', e.currentTarget.value)
|
||||
}
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Confirm New Password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) =>
|
||||
handlePasswordChange('confirmNewPassword', e.currentTarget.value)
|
||||
}
|
||||
placeholder="Confirm new password"
|
||||
error={error}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
Password must be at least 8 characters long. Leave password fields
|
||||
empty if you don't want to change it.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecuritySettings;
|
||||
44
app/src/components/settings/admin/AdminDashboard.jsx
Normal file
44
app/src/components/settings/admin/AdminDashboard.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Tabs } from '@mantine/core';
|
||||
import { IconUsers, IconFolders, IconChartBar } from '@tabler/icons-react';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import AdminUsersTab from './AdminUsersTab';
|
||||
import AdminWorkspacesTab from './AdminWorkspacesTab';
|
||||
import AdminStatsTab from './AdminStatsTab';
|
||||
|
||||
const AdminDashboard = ({ opened, onClose }) => {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState('users');
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} size="xl" title="Admin Dashboard">
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
|
||||
Users
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="workspaces" leftSection={<IconFolders size={16} />}>
|
||||
Workspaces
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="stats" leftSection={<IconChartBar size={16} />}>
|
||||
Statistics
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="users" pt="md">
|
||||
<AdminUsersTab currentUser={currentUser} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="workspaces" pt="md">
|
||||
<AdminWorkspacesTab />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="stats" pt="md">
|
||||
<AdminStatsTab />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
56
app/src/components/settings/admin/AdminStatsTab.jsx
Normal file
56
app/src/components/settings/admin/AdminStatsTab.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { Table, Text, Box, LoadingOverlay, Alert } from '@mantine/core';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useAdminData } from '../../../hooks/useAdminData';
|
||||
import { formatBytes } from '../../../utils/formatBytes';
|
||||
|
||||
const AdminStatsTab = () => {
|
||||
const { data: stats, loading, error } = useAdminData('stats');
|
||||
|
||||
if (loading) {
|
||||
return <LoadingOverlay visible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const statsRows = [
|
||||
{ label: 'Total Users', value: stats.totalUsers },
|
||||
{ label: 'Active Users', value: stats.activeUsers },
|
||||
{ label: 'Total Workspaces', value: stats.totalWorkspaces },
|
||||
{ label: 'Total Files', value: stats.totalFiles },
|
||||
{ label: 'Total Storage Size', value: formatBytes(stats.totalSize) },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
<Text size="xl" fw={700} mb="md">
|
||||
System Statistics
|
||||
</Text>
|
||||
|
||||
<Table striped highlightOnHover withBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Metric</Table.Th>
|
||||
<Table.Th>Value</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{statsRows.map((row) => (
|
||||
<Table.Tr key={row.label}>
|
||||
<Table.Td>{row.label}</Table.Td>
|
||||
<Table.Td>{row.value}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminStatsTab;
|
||||
162
app/src/components/settings/admin/AdminUsersTab.jsx
Normal file
162
app/src/components/settings/admin/AdminUsersTab.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Group,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Box,
|
||||
LoadingOverlay,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconTrash,
|
||||
IconEdit,
|
||||
IconPlus,
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useUserAdmin } from '../../../hooks/useUserAdmin';
|
||||
import CreateUserModal from '../../modals/user/CreateUserModal';
|
||||
import EditUserModal from '../../modals/user/EditUserModal';
|
||||
import DeleteUserModal from '../../modals/user/DeleteUserModal';
|
||||
|
||||
const AdminUsersTab = ({ currentUser }) => {
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
create,
|
||||
update,
|
||||
delete: deleteUser,
|
||||
} = useUserAdmin();
|
||||
|
||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
||||
const [editModalData, setEditModalData] = useState(null);
|
||||
const [deleteModalData, setDeleteModalData] = useState(null);
|
||||
|
||||
const handleCreateUser = async (userData) => {
|
||||
return await create(userData);
|
||||
};
|
||||
|
||||
const handleEditUser = async (id, userData) => {
|
||||
return await update(id, userData);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (user) => {
|
||||
if (user.id === currentUser.id) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'You cannot delete your own account',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setDeleteModalData(user);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteModalData) return;
|
||||
const result = await deleteUser(deleteModalData.id);
|
||||
if (result.success) {
|
||||
setDeleteModalData(null);
|
||||
}
|
||||
};
|
||||
|
||||
const rows = users.map((user) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>{user.email}</Table.Td>
|
||||
<Table.Td>{user.displayName}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text transform="capitalize">{user.role}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{new Date(user.createdAt).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => setEditModalData(user)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => handleDeleteClick(user)}
|
||||
disabled={user.id === currentUser.id}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
<LoadingOverlay visible={loading} />
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
title="Error"
|
||||
color="red"
|
||||
mb="md"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text size="xl" fw={700}>
|
||||
User Management
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setCreateModalOpened(true)}
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Email</Table.Th>
|
||||
<Table.Th>Display Name</Table.Th>
|
||||
<Table.Th>Role</Table.Th>
|
||||
<Table.Th>Created At</Table.Th>
|
||||
<Table.Th style={{ width: 100 }}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{rows}</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<CreateUserModal
|
||||
opened={createModalOpened}
|
||||
onClose={() => setCreateModalOpened(false)}
|
||||
onCreateUser={handleCreateUser}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<EditUserModal
|
||||
opened={!!editModalData}
|
||||
onClose={() => setEditModalData(null)}
|
||||
onEditUser={handleEditUser}
|
||||
user={editModalData}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<DeleteUserModal
|
||||
opened={!!deleteModalData}
|
||||
onClose={() => setDeleteModalData(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
user={deleteModalData}
|
||||
loading={loading}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsersTab;
|
||||
67
app/src/components/settings/admin/AdminWorkspacesTab.jsx
Normal file
67
app/src/components/settings/admin/AdminWorkspacesTab.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Table,
|
||||
Group,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Box,
|
||||
LoadingOverlay,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { IconTrash, IconEdit, IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useAdminData } from '../../../hooks/useAdminData';
|
||||
import { formatBytes } from '../../../utils/formatBytes';
|
||||
|
||||
const AdminWorkspacesTab = () => {
|
||||
const { data: workspaces, loading, error } = useAdminData('workspaces');
|
||||
|
||||
const rows = workspaces.map((workspace) => (
|
||||
<Table.Tr key={workspace.id}>
|
||||
<Table.Td>{workspace.userEmail}</Table.Td>
|
||||
<Table.Td>{workspace.workspaceName}</Table.Td>
|
||||
<Table.Td>
|
||||
{new Date(workspace.workspaceCreatedAt).toLocaleDateString()}
|
||||
</Table.Td>
|
||||
<Table.Td>{workspace.totalFiles}</Table.Td>
|
||||
<Table.Td>{formatBytes(workspace.totalSize)}</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
<LoadingOverlay visible={loading} />
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
title="Error"
|
||||
color="red"
|
||||
mb="md"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text size="xl" fw={700}>
|
||||
Workspace Management
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Owner</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Created At</Table.Th>
|
||||
<Table.Th>Total Files</Table.Th>
|
||||
<Table.Th>Total Size</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{rows}</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminWorkspacesTab;
|
||||
24
app/src/components/settings/workspace/AppearanceSettings.jsx
Normal file
24
app/src/components/settings/workspace/AppearanceSettings.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Text, Switch, Group, Box, Title } from '@mantine/core';
|
||||
import { useWorkspace } from '../../../contexts/WorkspaceContext';
|
||||
|
||||
const AppearanceSettings = ({ themeSettings, onThemeChange }) => {
|
||||
const { colorScheme, updateColorScheme } = useWorkspace();
|
||||
|
||||
const handleThemeChange = () => {
|
||||
const newTheme = colorScheme === 'dark' ? 'light' : 'dark';
|
||||
updateColorScheme(newTheme);
|
||||
onThemeChange(newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box mb="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm">Dark Mode</Text>
|
||||
<Switch checked={colorScheme === 'dark'} onChange={handleThemeChange} />
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppearanceSettings;
|
||||
46
app/src/components/settings/workspace/DangerZoneSettings.jsx
Normal file
46
app/src/components/settings/workspace/DangerZoneSettings.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Button, Title } from '@mantine/core';
|
||||
import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal';
|
||||
import { useWorkspace } from '../../../contexts/WorkspaceContext';
|
||||
import { useModalContext } from '../../../contexts/ModalContext';
|
||||
|
||||
const DangerZoneSettings = () => {
|
||||
const { currentWorkspace, workspaces, deleteCurrentWorkspace } =
|
||||
useWorkspace();
|
||||
const { setSettingsModalVisible } = useModalContext();
|
||||
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteCurrentWorkspace();
|
||||
setDeleteModalOpened(false);
|
||||
setSettingsModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box mb="md">
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={() => setDeleteModalOpened(true)}
|
||||
fullWidth
|
||||
disabled={workspaces.length <= 1}
|
||||
title={
|
||||
workspaces.length <= 1
|
||||
? 'Cannot delete the last workspace'
|
||||
: 'Delete this workspace'
|
||||
}
|
||||
>
|
||||
Delete Workspace
|
||||
</Button>
|
||||
|
||||
<DeleteWorkspaceModal
|
||||
opened={deleteModalOpened}
|
||||
onClose={() => setDeleteModalOpened(false)}
|
||||
onConfirm={handleDelete}
|
||||
workspaceName={currentWorkspace?.name}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DangerZoneSettings;
|
||||
36
app/src/components/settings/workspace/EditorSettings.jsx
Normal file
36
app/src/components/settings/workspace/EditorSettings.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Text, Switch, Tooltip, Group, Box } from '@mantine/core';
|
||||
|
||||
const EditorSettings = ({
|
||||
autoSave,
|
||||
showHiddenFiles,
|
||||
onAutoSaveChange,
|
||||
onShowHiddenFilesChange,
|
||||
}) => {
|
||||
return (
|
||||
<Box mb="md">
|
||||
<Tooltip label="Auto Save feature is coming soon!" position="left">
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<Text size="sm">Auto Save</Text>
|
||||
<Switch
|
||||
checked={autoSave}
|
||||
onChange={(event) => onAutoSaveChange(event.currentTarget.checked)}
|
||||
disabled
|
||||
/>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm">Show Hidden Files</Text>
|
||||
<Switch
|
||||
checked={showHiddenFiles}
|
||||
onChange={(event) =>
|
||||
onShowHiddenFilesChange(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorSettings;
|
||||
26
app/src/components/settings/workspace/GeneralSettings.jsx
Normal file
26
app/src/components/settings/workspace/GeneralSettings.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Title, Box, TextInput, Text, Grid } from '@mantine/core';
|
||||
|
||||
const GeneralSettings = ({ name, onInputChange }) => {
|
||||
return (
|
||||
<Box mb="md">
|
||||
<Grid gutter="md" align="center">
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Workspace Name</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
value={name || ''}
|
||||
onChange={(event) =>
|
||||
onInputChange('name', event.currentTarget.value)
|
||||
}
|
||||
placeholder="Enter workspace name"
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSettings;
|
||||
113
app/src/components/settings/workspace/GitSettings.jsx
Normal file
113
app/src/components/settings/workspace/GitSettings.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Text,
|
||||
Switch,
|
||||
TextInput,
|
||||
Stack,
|
||||
PasswordInput,
|
||||
Group,
|
||||
Grid,
|
||||
} from '@mantine/core';
|
||||
|
||||
const GitSettings = ({
|
||||
gitEnabled,
|
||||
gitUrl,
|
||||
gitUser,
|
||||
gitToken,
|
||||
gitAutoCommit,
|
||||
gitCommitMsgTemplate,
|
||||
onInputChange,
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing="md">
|
||||
<Grid gutter="md" align="center">
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Enable Git</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Group justify="flex-end">
|
||||
<Switch
|
||||
checked={gitEnabled}
|
||||
onChange={(event) =>
|
||||
onInputChange('gitEnabled', event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Git URL</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
value={gitUrl}
|
||||
onChange={(event) =>
|
||||
onInputChange('gitUrl', event.currentTarget.value)
|
||||
}
|
||||
disabled={!gitEnabled}
|
||||
placeholder="Enter Git URL"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Git Username</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
value={gitUser}
|
||||
onChange={(event) =>
|
||||
onInputChange('gitUser', event.currentTarget.value)
|
||||
}
|
||||
disabled={!gitEnabled}
|
||||
placeholder="Enter Git username"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Git Token</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<PasswordInput
|
||||
value={gitToken}
|
||||
onChange={(event) =>
|
||||
onInputChange('gitToken', event.currentTarget.value)
|
||||
}
|
||||
disabled={!gitEnabled}
|
||||
placeholder="Enter Git token"
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Auto Commit</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Group justify="flex-end">
|
||||
<Switch
|
||||
checked={gitAutoCommit}
|
||||
onChange={(event) =>
|
||||
onInputChange('gitAutoCommit', event.currentTarget.checked)
|
||||
}
|
||||
disabled={!gitEnabled}
|
||||
/>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Commit Message Template</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
value={gitCommitMsgTemplate}
|
||||
onChange={(event) =>
|
||||
onInputChange('gitCommitMsgTemplate', event.currentTarget.value)
|
||||
}
|
||||
disabled={!gitEnabled}
|
||||
placeholder="Enter commit message template"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitSettings;
|
||||
231
app/src/components/settings/workspace/WorkspaceSettings.jsx
Normal file
231
app/src/components/settings/workspace/WorkspaceSettings.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useReducer, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Title,
|
||||
Stack,
|
||||
Accordion,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useWorkspace } from '../../../contexts/WorkspaceContext';
|
||||
import AppearanceSettings from './AppearanceSettings';
|
||||
import EditorSettings from './EditorSettings';
|
||||
import GitSettings from './GitSettings';
|
||||
import GeneralSettings from './GeneralSettings';
|
||||
import { useModalContext } from '../../../contexts/ModalContext';
|
||||
import DangerZoneSettings from './DangerZoneSettings';
|
||||
import AccordionControl from '../AccordionControl';
|
||||
|
||||
const initialState = {
|
||||
localSettings: {},
|
||||
initialSettings: {},
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
|
||||
function settingsReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'INIT_SETTINGS':
|
||||
return {
|
||||
...state,
|
||||
localSettings: action.payload,
|
||||
initialSettings: action.payload,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
case 'UPDATE_LOCAL_SETTINGS':
|
||||
const newLocalSettings = { ...state.localSettings, ...action.payload };
|
||||
const hasChanges =
|
||||
JSON.stringify(newLocalSettings) !==
|
||||
JSON.stringify(state.initialSettings);
|
||||
return {
|
||||
...state,
|
||||
localSettings: newLocalSettings,
|
||||
hasUnsavedChanges: hasChanges,
|
||||
};
|
||||
case 'MARK_SAVED':
|
||||
return {
|
||||
...state,
|
||||
initialSettings: state.localSettings,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const WorkspaceSettings = () => {
|
||||
const { currentWorkspace, updateSettings } = useWorkspace();
|
||||
const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
|
||||
const [state, dispatch] = useReducer(settingsReducer, initialState);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
const settings = {
|
||||
name: currentWorkspace.name,
|
||||
theme: currentWorkspace.theme,
|
||||
autoSave: currentWorkspace.autoSave,
|
||||
showHiddenFiles: currentWorkspace.showHiddenFiles,
|
||||
gitEnabled: currentWorkspace.gitEnabled,
|
||||
gitUrl: currentWorkspace.gitUrl,
|
||||
gitUser: currentWorkspace.gitUser,
|
||||
gitToken: currentWorkspace.gitToken,
|
||||
gitAutoCommit: currentWorkspace.gitAutoCommit,
|
||||
gitCommitMsgTemplate: currentWorkspace.gitCommitMsgTemplate,
|
||||
};
|
||||
dispatch({ type: 'INIT_SETTINGS', payload: settings });
|
||||
}
|
||||
}, [currentWorkspace]);
|
||||
|
||||
const handleInputChange = useCallback((key, value) => {
|
||||
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
if (!state.localSettings.name?.trim()) {
|
||||
notifications.show({
|
||||
message: 'Workspace name cannot be empty',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSettings(state.localSettings);
|
||||
dispatch({ type: 'MARK_SAVED' });
|
||||
notifications.show({
|
||||
message: 'Settings saved successfully',
|
||||
color: 'green',
|
||||
});
|
||||
setSettingsModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
notifications.show({
|
||||
message: 'Failed to save settings: ' + error.message,
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSettingsModalVisible(false);
|
||||
}, [setSettingsModalVisible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={settingsModalVisible}
|
||||
onClose={handleClose}
|
||||
title={<Title order={2}>Workspace Settings</Title>}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Stack spacing="xl">
|
||||
{state.hasUnsavedChanges && (
|
||||
<Badge color="yellow" variant="light">
|
||||
Unsaved Changes
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Accordion
|
||||
defaultValue={['general', 'appearance', 'editor', 'git', 'danger']}
|
||||
multiple
|
||||
styles={(theme) => ({
|
||||
control: {
|
||||
paddingTop: theme.spacing.md,
|
||||
paddingBottom: theme.spacing.md,
|
||||
},
|
||||
item: {
|
||||
borderBottom: `1px solid ${
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[4]
|
||||
: theme.colors.gray[3]
|
||||
}`,
|
||||
'&[data-active]': {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[7]
|
||||
: theme.colors.gray[0],
|
||||
},
|
||||
},
|
||||
chevron: {
|
||||
'&[data-rotate]': {
|
||||
transform: 'rotate(180deg)',
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Accordion.Item value="general">
|
||||
<AccordionControl>General</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<GeneralSettings
|
||||
name={state.localSettings.name}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="appearance">
|
||||
<AccordionControl>Appearance</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<AppearanceSettings
|
||||
themeSettings={state.localSettings.theme}
|
||||
onThemeChange={(newTheme) =>
|
||||
handleInputChange('theme', newTheme)
|
||||
}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="editor">
|
||||
<AccordionControl>Editor</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<EditorSettings
|
||||
autoSave={state.localSettings.autoSave}
|
||||
onAutoSaveChange={(value) =>
|
||||
handleInputChange('autoSave', value)
|
||||
}
|
||||
showHiddenFiles={state.localSettings.showHiddenFiles}
|
||||
onShowHiddenFilesChange={(value) =>
|
||||
handleInputChange('showHiddenFiles', value)
|
||||
}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="git">
|
||||
<AccordionControl>Git Integration</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<GitSettings
|
||||
gitEnabled={state.localSettings.gitEnabled}
|
||||
gitUrl={state.localSettings.gitUrl}
|
||||
gitUser={state.localSettings.gitUser}
|
||||
gitToken={state.localSettings.gitToken}
|
||||
gitAutoCommit={state.localSettings.gitAutoCommit}
|
||||
gitCommitMsgTemplate={state.localSettings.gitCommitMsgTemplate}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="danger">
|
||||
<AccordionControl>Danger Zone</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<DangerZoneSettings />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>Save Changes</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceSettings;
|
||||
Reference in New Issue
Block a user