Rename root folders

This commit is contained in:
2024-11-12 21:25:02 +01:00
parent f4c21edca0
commit fb1c9a499f
101 changed files with 11 additions and 11 deletions

31
app/.eslintrc.json Normal file
View File

@@ -0,0 +1,31 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"react"
],
"rules": {
"react/prop-types": "off",
"no-unused-vars": "warn"
},
"settings": {
"react": {
"version": "detect"
}
}
}

7
app/.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5"
}

6060
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

75
app/package.json Normal file
View File

@@ -0,0 +1,75 @@
{
"name": "novamd-frontend",
"version": "0.1.0",
"description": "Yet another markdown editor",
"type": "module",
"scripts": {
"start": "vite",
"build": "vite build",
"preview": "vite preview"
},
"repository": {
"type": "git",
"url": "git+https://github.com/LordMathis/NovaMD.git"
},
"keywords": [
"markdown",
"editor"
],
"author": "Matúš Námešný",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/LordMathis/NovaMD/issues"
},
"homepage": "https://github.com/LordMathis/NovaMD#readme",
"dependencies": {
"@codemirror/commands": "^6.6.2",
"@codemirror/lang-markdown": "^6.2.5",
"@codemirror/state": "^6.4.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.34.0",
"@mantine/core": "^7.13.2",
"@mantine/hooks": "^7.13.2",
"@mantine/modals": "^7.13.2",
"@mantine/notifications": "^7.13.2",
"@react-hook/resize-observer": "^2.0.2",
"@tabler/icons-react": "^3.19.0",
"codemirror": "^6.0.1",
"react": "^18.3.1",
"react-arborist": "^3.4.0",
"react-dom": "^18.3.1",
"react-syntax-highlighter": "^15.5.0",
"rehype-mathjax": "^6.0.0",
"rehype-prism": "^2.3.3",
"rehype-react": "^8.0.0",
"remark": "^15.0.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",
"postcss": "^8.4.47",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"sass": "^1.80.4",
"vite": "^5.4.10",
"vite-plugin-compression2": "^1.3.0"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

54
app/src/App.jsx Normal file
View File

@@ -0,0 +1,54 @@
import React from 'react';
import { MantineProvider, ColorSchemeScript } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals';
import Layout from './components/layout/Layout';
import LoginPage from './components/auth/LoginPage';
import { WorkspaceProvider } from './contexts/WorkspaceContext';
import { ModalProvider } from './contexts/ModalContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import './App.scss';
function AuthenticatedContent() {
const { user, loading, initialized } = useAuth();
if (!initialized) {
return null;
}
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return <LoginPage />;
}
return (
<WorkspaceProvider>
<ModalProvider>
<Layout />
</ModalProvider>
</WorkspaceProvider>
);
}
function App() {
return (
<>
<ColorSchemeScript defaultColorScheme="light" />
<MantineProvider defaultColorScheme="light">
<Notifications />
<ModalsProvider>
<AuthProvider>
<AuthenticatedContent />
</AuthProvider>
</ModalsProvider>
</MantineProvider>
</>
);
}
export default App;

110
app/src/App.scss Normal file
View File

@@ -0,0 +1,110 @@
// Variables
$padding: 20px;
$navbar-height: 64px;
.custom-navbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar {
overflow: auto;
padding: $padding;
.file-tree-container {
width: 100%;
}
}
.image-preview {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
}
.image-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.page-content {
padding: 0 $padding;
}
.main-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 $padding;
.breadcrumbs-container {
flex-grow: 1;
display: flex;
align-items: center;
.unsaved-indicator {
margin-left: 8px;
}
}
.tabs {
flex-shrink: 0;
}
}
.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.setting-group {
margin-bottom: 1rem;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.content-body {
flex-grow: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.editor-container,
.markdown-preview {
flex-grow: 1;
overflow-y: auto;
padding: $padding;
}
.editor-container {
height: 100%;
.cm-editor {
height: 100%;
}
.cm-scroller {
overflow: auto;
}
}
.tree {
padding-top: $padding;
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,120 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
} from 'react';
import { notifications } from '@mantine/notifications';
import * as authApi from '../services/authApi';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [initialized, setInitialized] = useState(false);
// Load user data on mount
useEffect(() => {
const initializeAuth = async () => {
try {
const storedToken = localStorage.getItem('accessToken');
if (storedToken) {
authApi.setAuthToken(storedToken);
const userData = await authApi.getCurrentUser();
setUser(userData);
}
} catch (error) {
console.error('Failed to initialize auth:', error);
localStorage.removeItem('accessToken');
authApi.clearAuthToken();
} finally {
setLoading(false);
setInitialized(true);
}
};
initializeAuth();
}, []);
const login = useCallback(async (email, password) => {
try {
const { accessToken, user: userData } = await authApi.login(
email,
password
);
localStorage.setItem('accessToken', accessToken);
authApi.setAuthToken(accessToken);
setUser(userData);
notifications.show({
title: 'Success',
message: 'Logged in successfully',
color: 'green',
});
return true;
} catch (error) {
console.error('Login failed:', error);
notifications.show({
title: 'Error',
message: error.message || 'Login failed',
color: 'red',
});
return false;
}
}, []);
const logout = useCallback(async () => {
try {
await authApi.logout();
} catch (error) {
console.error('Logout failed:', error);
} finally {
localStorage.removeItem('accessToken');
authApi.clearAuthToken();
setUser(null);
}
}, []);
const refreshToken = useCallback(async () => {
try {
const { accessToken } = await authApi.refreshToken();
localStorage.setItem('accessToken', accessToken);
authApi.setAuthToken(accessToken);
return true;
} catch (error) {
console.error('Token refresh failed:', error);
await logout();
return false;
}
}, [logout]);
const refreshUser = useCallback(async () => {
try {
const userData = await authApi.getCurrentUser();
setUser(userData);
} catch (error) {
console.error('Failed to refresh user data:', error);
}
}, []);
const value = {
user,
loading,
initialized,
login,
logout,
refreshToken,
refreshUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,36 @@
import React, { createContext, useContext, useState } from 'react';
const ModalContext = createContext();
export const ModalProvider = ({ children }) => {
const [newFileModalVisible, setNewFileModalVisible] = useState(false);
const [deleteFileModalVisible, setDeleteFileModalVisible] = useState(false);
const [commitMessageModalVisible, setCommitMessageModalVisible] =
useState(false);
const [settingsModalVisible, setSettingsModalVisible] = useState(false);
const [switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible] =
useState(false);
const [createWorkspaceModalVisible, setCreateWorkspaceModalVisible] =
useState(false);
const value = {
newFileModalVisible,
setNewFileModalVisible,
deleteFileModalVisible,
setDeleteFileModalVisible,
commitMessageModalVisible,
setCommitMessageModalVisible,
settingsModalVisible,
setSettingsModalVisible,
switchWorkspaceModalVisible,
setSwitchWorkspaceModalVisible,
createWorkspaceModalVisible,
setCreateWorkspaceModalVisible,
};
return (
<ModalContext.Provider value={value}>{children}</ModalContext.Provider>
);
};
export const useModalContext = () => useContext(ModalContext);

View File

@@ -0,0 +1,211 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from 'react';
import { useMantineColorScheme } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
fetchLastWorkspaceName,
getWorkspace,
updateWorkspace,
updateLastWorkspaceName,
deleteWorkspace,
listWorkspaces,
} from '../services/api';
import { DEFAULT_WORKSPACE_SETTINGS } from '../utils/constants';
const WorkspaceContext = createContext();
export const WorkspaceProvider = ({ children }) => {
const [currentWorkspace, setCurrentWorkspace] = useState(null);
const [workspaces, setWorkspaces] = useState([]);
const [loading, setLoading] = useState(true);
const { colorScheme, setColorScheme } = useMantineColorScheme();
const loadWorkspaces = useCallback(async () => {
try {
const workspaceList = await listWorkspaces();
setWorkspaces(workspaceList);
return workspaceList;
} catch (error) {
console.error('Failed to load workspaces:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspaces list',
color: 'red',
});
return [];
}
}, []);
const loadWorkspaceData = useCallback(async (workspaceName) => {
try {
const workspace = await getWorkspace(workspaceName);
setCurrentWorkspace(workspace);
setColorScheme(workspace.theme);
} catch (error) {
console.error('Failed to load workspace data:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace data',
color: 'red',
});
}
}, []);
const loadFirstAvailableWorkspace = useCallback(async () => {
try {
const allWorkspaces = await listWorkspaces();
if (allWorkspaces.length > 0) {
const firstWorkspace = allWorkspaces[0];
await updateLastWorkspaceName(firstWorkspace.name);
await loadWorkspaceData(firstWorkspace.name);
}
} catch (error) {
console.error('Failed to load first available workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace',
color: 'red',
});
}
}, []);
useEffect(() => {
const initializeWorkspace = async () => {
try {
const { lastWorkspaceName } = await fetchLastWorkspaceName();
if (lastWorkspaceName) {
await loadWorkspaceData(lastWorkspaceName);
} else {
await loadFirstAvailableWorkspace();
}
await loadWorkspaces();
} catch (error) {
console.error('Failed to initialize workspace:', error);
await loadFirstAvailableWorkspace();
} finally {
setLoading(false);
}
};
initializeWorkspace();
}, []);
const switchWorkspace = useCallback(async (workspaceName) => {
try {
setLoading(true);
await updateLastWorkspaceName(workspaceName);
await loadWorkspaceData(workspaceName);
await loadWorkspaces();
} catch (error) {
console.error('Failed to switch workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to switch workspace',
color: 'red',
});
} finally {
setLoading(false);
}
}, []);
const deleteCurrentWorkspace = useCallback(async () => {
if (!currentWorkspace) return;
try {
const allWorkspaces = await loadWorkspaces();
if (allWorkspaces.length <= 1) {
notifications.show({
title: 'Error',
message:
'Cannot delete the last workspace. At least one workspace must exist.',
color: 'red',
});
return;
}
// Delete workspace and get the next workspace ID
const response = await deleteWorkspace(currentWorkspace.name);
// Load the new workspace data
await loadWorkspaceData(response.nextWorkspaceName);
notifications.show({
title: 'Success',
message: 'Workspace deleted successfully',
color: 'green',
});
await loadWorkspaces();
} catch (error) {
console.error('Failed to delete workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete workspace',
color: 'red',
});
}
}, [currentWorkspace]);
const updateSettings = useCallback(
async (newSettings) => {
if (!currentWorkspace) return;
try {
const updatedWorkspace = {
...currentWorkspace,
...newSettings,
};
const response = await updateWorkspace(
currentWorkspace.name,
updatedWorkspace
);
setCurrentWorkspace(response);
setColorScheme(response.theme);
await loadWorkspaces();
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
},
[currentWorkspace, setColorScheme]
);
const updateColorScheme = useCallback(
(newTheme) => {
setColorScheme(newTheme);
},
[setColorScheme]
);
const value = {
currentWorkspace,
workspaces,
settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS,
updateSettings,
loading,
colorScheme,
updateColorScheme,
switchWorkspace,
deleteCurrentWorkspace,
};
return (
<WorkspaceContext.Provider value={value}>
{children}
</WorkspaceContext.Provider>
);
};
export const useWorkspace = () => {
const context = useContext(WorkspaceContext);
if (context === undefined) {
throw new Error('useWorkspace must be used within a WorkspaceProvider');
}
return context;
};

View File

@@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
import { notifications } from '@mantine/notifications';
import { getUsers, getWorkspaces, getSystemStats } from '../services/adminApi';
// Hook for admin data fetching (stats and workspaces)
export const useAdminData = (type) => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const loadData = async () => {
setLoading(true);
setError(null);
try {
let response;
switch (type) {
case 'stats':
response = await getSystemStats();
break;
case 'workspaces':
response = await getWorkspaces();
break;
case 'users':
response = await getUsers();
break;
default:
throw new Error('Invalid data type');
}
setData(response);
} catch (err) {
const message = err.response?.data?.error || err.message;
setError(message);
notifications.show({
title: 'Error',
message: `Failed to load ${type}: ${message}`,
color: 'red',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, [type]);
return { data, loading, error, reload: loadData };
};

View File

@@ -0,0 +1,61 @@
import { useState, useCallback, useEffect } from 'react';
import { fetchFileContent } from '../services/api';
import { isImageFile } from '../utils/fileHelpers';
import { DEFAULT_FILE } from '../utils/constants';
import { useWorkspace } from '../contexts/WorkspaceContext';
export const useFileContent = (selectedFile) => {
const { currentWorkspace } = useWorkspace();
const [content, setContent] = useState(DEFAULT_FILE.content);
const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const loadFileContent = useCallback(
async (filePath) => {
if (!currentWorkspace) return;
try {
let newContent;
if (filePath === DEFAULT_FILE.path) {
newContent = DEFAULT_FILE.content;
} else if (!isImageFile(filePath)) {
newContent = await fetchFileContent(currentWorkspace.name, filePath);
} else {
newContent = ''; // Set empty content for image files
}
setContent(newContent);
setOriginalContent(newContent);
setHasUnsavedChanges(false);
} catch (err) {
console.error('Error loading file content:', err);
setContent(''); // Set empty content on error
setOriginalContent('');
setHasUnsavedChanges(false);
}
},
[currentWorkspace]
);
useEffect(() => {
if (selectedFile && currentWorkspace) {
loadFileContent(selectedFile);
}
}, [selectedFile, currentWorkspace, loadFileContent]);
const handleContentChange = useCallback(
(newContent) => {
setContent(newContent);
setHasUnsavedChanges(newContent !== originalContent);
},
[originalContent]
);
return {
content,
setContent,
hasUnsavedChanges,
setHasUnsavedChanges,
loadFileContent,
handleContentChange,
};
};

View File

@@ -0,0 +1,26 @@
import { useState, useCallback } from 'react';
import { fetchFileList } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
export const useFileList = () => {
const [files, setFiles] = useState([]);
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
const loadFileList = useCallback(async () => {
if (!currentWorkspace || workspaceLoading) return;
try {
const fileList = await fetchFileList(currentWorkspace.name);
if (Array.isArray(fileList)) {
setFiles(fileList);
} else {
throw new Error('File list is not an array');
}
} catch (error) {
console.error('Failed to load file list:', error);
setFiles([]);
}
}, [currentWorkspace, workspaceLoading]);
return { files, loadFileList };
};

View File

@@ -0,0 +1,45 @@
import { useState, useCallback, useEffect } from 'react';
import { DEFAULT_FILE } from '../utils/constants';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useLastOpenedFile } from './useLastOpenedFile';
export const useFileNavigation = () => {
const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path);
const [isNewFile, setIsNewFile] = useState(true);
const { currentWorkspace } = useWorkspace();
const { loadLastOpenedFile, saveLastOpenedFile } = useLastOpenedFile();
const handleFileSelect = useCallback(
async (filePath) => {
const newPath = filePath || DEFAULT_FILE.path;
setSelectedFile(newPath);
setIsNewFile(!filePath);
if (filePath) {
await saveLastOpenedFile(filePath);
}
},
[saveLastOpenedFile]
);
// Load last opened file when workspace changes
useEffect(() => {
const initializeFile = async () => {
setSelectedFile(DEFAULT_FILE.path);
setIsNewFile(true);
const lastFile = await loadLastOpenedFile();
if (lastFile) {
handleFileSelect(lastFile);
} else {
handleFileSelect(null);
}
};
if (currentWorkspace) {
initializeFile();
}
}, [currentWorkspace, loadLastOpenedFile, handleFileSelect]);
return { selectedFile, isNewFile, handleFileSelect };
};

View File

@@ -0,0 +1,106 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { saveFileContent, deleteFile } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useGitOperations } from './useGitOperations';
export const useFileOperations = () => {
const { currentWorkspace, settings } = useWorkspace();
const { handleCommitAndPush } = useGitOperations();
const autoCommit = useCallback(
async (filePath, action) => {
if (settings.gitAutoCommit && settings.gitEnabled) {
let commitMessage = settings.gitCommitMsgTemplate
.replace('${filename}', filePath)
.replace('${action}', action);
commitMessage =
commitMessage.charAt(0).toUpperCase() + commitMessage.slice(1);
await handleCommitAndPush(commitMessage);
}
},
[settings]
);
const handleSave = useCallback(
async (filePath, content) => {
if (!currentWorkspace) return false;
try {
await saveFileContent(currentWorkspace.name, filePath, content);
notifications.show({
title: 'Success',
message: 'File saved successfully',
color: 'green',
});
autoCommit(filePath, 'update');
return true;
} catch (error) {
console.error('Error saving file:', error);
notifications.show({
title: 'Error',
message: 'Failed to save file',
color: 'red',
});
return false;
}
},
[currentWorkspace, autoCommit]
);
const handleDelete = useCallback(
async (filePath) => {
if (!currentWorkspace) return false;
try {
await deleteFile(currentWorkspace.name, filePath);
notifications.show({
title: 'Success',
message: 'File deleted successfully',
color: 'green',
});
autoCommit(filePath, 'delete');
return true;
} catch (error) {
console.error('Error deleting file:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete file',
color: 'red',
});
return false;
}
},
[currentWorkspace, autoCommit]
);
const handleCreate = useCallback(
async (fileName, initialContent = '') => {
if (!currentWorkspace) return false;
try {
await saveFileContent(currentWorkspace.name, fileName, initialContent);
notifications.show({
title: 'Success',
message: 'File created successfully',
color: 'green',
});
autoCommit(fileName, 'create');
return true;
} catch (error) {
console.error('Error creating new file:', error);
notifications.show({
title: 'Error',
message: 'Failed to create new file',
color: 'red',
});
return false;
}
},
[currentWorkspace, autoCommit]
);
return { handleSave, handleDelete, handleCreate };
};

View File

@@ -0,0 +1,57 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { pullChanges, commitAndPush } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
export const useGitOperations = () => {
const { currentWorkspace, settings } = useWorkspace();
const handlePull = useCallback(async () => {
if (!currentWorkspace || !settings.gitEnabled) return false;
try {
await pullChanges(currentWorkspace.name);
notifications.show({
title: 'Success',
message: 'Successfully pulled latest changes',
color: 'green',
});
return true;
} catch (error) {
console.error('Failed to pull latest changes:', error);
notifications.show({
title: 'Error',
message: 'Failed to pull latest changes',
color: 'red',
});
return false;
}
}, [currentWorkspace, settings.gitEnabled]);
const handleCommitAndPush = useCallback(
async (message) => {
if (!currentWorkspace || !settings.gitEnabled) return false;
try {
await commitAndPush(currentWorkspace.name, message);
notifications.show({
title: 'Success',
message: 'Successfully committed and pushed changes',
color: 'green',
});
return true;
} catch (error) {
console.error('Failed to commit and push changes:', error);
notifications.show({
title: 'Error',
message: 'Failed to commit and push changes',
color: 'red',
});
return false;
}
},
[currentWorkspace, settings.gitEnabled]
);
return { handlePull, handleCommitAndPush };
};

View File

@@ -0,0 +1,37 @@
import { useCallback } from 'react';
import { getLastOpenedFile, updateLastOpenedFile } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
export const useLastOpenedFile = () => {
const { currentWorkspace } = useWorkspace();
const loadLastOpenedFile = useCallback(async () => {
if (!currentWorkspace) return null;
try {
const response = await getLastOpenedFile(currentWorkspace.name);
return response.lastOpenedFilePath || null;
} catch (error) {
console.error('Failed to load last opened file:', error);
return null;
}
}, [currentWorkspace]);
const saveLastOpenedFile = useCallback(
async (filePath) => {
if (!currentWorkspace) return;
try {
await updateLastOpenedFile(currentWorkspace.name, filePath);
} catch (error) {
console.error('Failed to save last opened file:', error);
}
},
[currentWorkspace]
);
return {
loadLastOpenedFile,
saveLastOpenedFile,
};
};

View File

@@ -0,0 +1,71 @@
import { useState, useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { updateProfile, deleteProfile } from '../services/api';
export function useProfileSettings() {
const [loading, setLoading] = useState(false);
const handleProfileUpdate = useCallback(async (updates) => {
setLoading(true);
try {
const updatedUser = await updateProfile(updates);
notifications.show({
title: 'Success',
message: 'Profile updated successfully',
color: 'green',
});
return { success: true, user: updatedUser };
} catch (error) {
let errorMessage = 'Failed to update profile';
if (error.message.includes('password')) {
errorMessage = 'Current password is incorrect';
} else if (error.message.includes('email')) {
errorMessage = 'Email is already in use';
}
notifications.show({
title: 'Error',
message: errorMessage,
color: 'red',
});
return { success: false, error: error.message };
} finally {
setLoading(false);
}
}, []);
const handleAccountDeletion = useCallback(async (password) => {
setLoading(true);
try {
await deleteProfile(password);
notifications.show({
title: 'Success',
message: 'Account deleted successfully',
color: 'green',
});
return { success: true };
} catch (error) {
notifications.show({
title: 'Error',
message: error.message || 'Failed to delete account',
color: 'red',
});
return { success: false, error: error.message };
} finally {
setLoading(false);
}
}, []);
return {
loading,
updateProfile: handleProfileUpdate,
deleteAccount: handleAccountDeletion,
};
}

View File

@@ -0,0 +1,79 @@
import { useAdminData } from './useAdminData';
import { createUser, updateUser, deleteUser } from '../services/adminApi';
import { notifications } from '@mantine/notifications';
export const useUserAdmin = () => {
const { data: users, loading, error, reload } = useAdminData('users');
const handleCreate = async (userData) => {
try {
await createUser(userData);
notifications.show({
title: 'Success',
message: 'User created successfully',
color: 'green',
});
reload();
return { success: true };
} catch (err) {
const message = err.response?.data?.error || err.message;
notifications.show({
title: 'Error',
message: `Failed to create user: ${message}`,
color: 'red',
});
return { success: false, error: message };
}
};
const handleUpdate = async (userId, userData) => {
try {
await updateUser(userId, userData);
notifications.show({
title: 'Success',
message: 'User updated successfully',
color: 'green',
});
reload();
return { success: true };
} catch (err) {
const message = err.response?.data?.error || err.message;
notifications.show({
title: 'Error',
message: `Failed to update user: ${message}`,
color: 'red',
});
return { success: false, error: message };
}
};
const handleDelete = async (userId) => {
try {
await deleteUser(userId);
notifications.show({
title: 'Success',
message: 'User deleted successfully',
color: 'green',
});
reload();
return { success: true };
} catch (err) {
const message = err.response?.data?.error || err.message;
notifications.show({
title: 'Error',
message: `Failed to delete user: ${message}`,
color: 'red',
});
return { success: false, error: message };
}
};
return {
users,
loading,
error,
create: handleCreate,
update: handleUpdate,
delete: handleDelete,
};
};

13
app/src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NovaMD</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.jsx"></script>
</body>
</html>

10
app/src/index.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,49 @@
import { apiCall } from './authApi';
import { API_BASE_URL } from '../utils/constants';
const ADMIN_BASE_URL = `${API_BASE_URL}/admin`;
// User Management
export const getUsers = async () => {
const response = await apiCall(`${ADMIN_BASE_URL}/users`);
return response.json();
};
export const createUser = async (userData) => {
const response = await apiCall(`${ADMIN_BASE_URL}/users`, {
method: 'POST',
body: JSON.stringify(userData),
});
return response.json();
};
export const deleteUser = async (userId) => {
const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, {
method: 'DELETE',
});
if (response.status === 204) {
return;
} else {
throw new Error('Failed to delete user with status: ', response.status);
}
};
export const updateUser = async (userId, userData) => {
const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(userData),
});
return response.json();
};
// Workspace Management
export const getWorkspaces = async () => {
const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`);
return response.json();
};
// System Statistics
export const getSystemStats = async () => {
const response = await apiCall(`${ADMIN_BASE_URL}/stats`);
return response.json();
};

177
app/src/services/api.js Normal file
View File

@@ -0,0 +1,177 @@
import { API_BASE_URL } from '../utils/constants';
import { apiCall } from './authApi';
export const updateProfile = async (updates) => {
const response = await apiCall(`${API_BASE_URL}/profile`, {
method: 'PUT',
body: JSON.stringify(updates),
});
return response.json();
};
export const deleteProfile = async (password) => {
const response = await apiCall(`${API_BASE_URL}/profile`, {
method: 'DELETE',
body: JSON.stringify({ password }),
});
return response.json();
};
export const fetchLastWorkspaceName = async () => {
const response = await apiCall(`${API_BASE_URL}/workspaces/last`);
return response.json();
};
export const fetchFileList = async (workspaceName) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files`
);
return response.json();
};
export const fetchFileContent = async (workspaceName, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`
);
return response.text();
};
export const saveFileContent = async (workspaceName, filePath, content) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`,
{
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
body: content,
}
);
return response.text();
};
export const deleteFile = async (workspaceName, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`,
{
method: 'DELETE',
}
);
return response.text();
};
export const getWorkspace = async (workspaceName) => {
const response = await apiCall(`${API_BASE_URL}/workspaces/${workspaceName}`);
return response.json();
};
// Combined function to update workspace data including settings
export const updateWorkspace = async (workspaceName, workspaceData) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(workspaceData),
}
);
return response.json();
};
export const pullChanges = async (workspaceName) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/git/pull`,
{
method: 'POST',
}
);
return response.json();
};
export const commitAndPush = async (workspaceName, message) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/git/commit`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message }),
}
);
return response.json();
};
export const getFileUrl = (workspaceName, filePath) => {
return `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`;
};
export const lookupFileByName = async (workspaceName, filename) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/lookup?filename=${encodeURIComponent(
filename
)}`
);
const data = await response.json();
return data.paths;
};
export const updateLastOpenedFile = async (workspaceName, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/last`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filePath }),
}
);
return response.json();
};
export const getLastOpenedFile = async (workspaceName) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/last`
);
return response.json();
};
export const listWorkspaces = async () => {
const response = await apiCall(`${API_BASE_URL}/workspaces`);
return response.json();
};
export const createWorkspace = async (name) => {
const response = await apiCall(`${API_BASE_URL}/workspaces`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
});
return response.json();
};
export const deleteWorkspace = async (workspaceName) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}`,
{
method: 'DELETE',
}
);
return response.json();
};
export const updateLastWorkspaceName = async (workspaceName) => {
const response = await apiCall(`${API_BASE_URL}/workspaces/last`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ workspaceName }),
});
return response.json();
};

View File

@@ -0,0 +1,97 @@
import { API_BASE_URL } from '../utils/constants';
let authToken = null;
export const setAuthToken = (token) => {
authToken = token;
};
export const clearAuthToken = () => {
authToken = null;
};
export const getAuthHeaders = () => {
const headers = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return headers;
};
// Update the existing apiCall function to include auth headers
export const apiCall = async (url, options = {}) => {
try {
const headers = {
...getAuthHeaders(),
...options.headers,
};
const response = await fetch(url, {
...options,
headers,
});
// Handle 401 responses
if (response.status === 401) {
const isRefreshEndpoint = url.endsWith('/auth/refresh');
if (!isRefreshEndpoint) {
// Attempt token refresh and retry the request
const refreshSuccess = await refreshToken();
if (refreshSuccess) {
// Retry the original request with the new token
return apiCall(url, options);
}
}
throw new Error('Authentication failed');
}
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(
errorData?.message || `HTTP error! status: ${response.status}`
);
}
return response;
} catch (error) {
console.error(`API call failed: ${error.message}`);
throw error;
}
};
// Authentication endpoints
export const login = async (email, password) => {
const response = await apiCall(`${API_BASE_URL}/auth/login`, {
method: 'POST',
body: JSON.stringify({ email, password }),
});
return response.json();
};
export const logout = async () => {
const sessionId = localStorage.getItem('sessionId');
await apiCall(`${API_BASE_URL}/auth/logout`, {
method: 'POST',
headers: {
'X-Session-ID': sessionId,
},
});
};
export const refreshToken = async () => {
const refreshToken = localStorage.getItem('refreshToken');
const response = await apiCall(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
body: JSON.stringify({ refreshToken }),
});
return response.json();
};
export const getCurrentUser = async () => {
const response = await apiCall(`${API_BASE_URL}/auth/me`);
return response.json();
};

View File

@@ -0,0 +1,67 @@
export const API_BASE_URL = window.API_BASE_URL;
export const THEMES = {
LIGHT: 'light',
DARK: 'dark',
};
export const FILE_ACTIONS = {
CREATE: 'create',
DELETE: 'delete',
RENAME: 'rename',
};
export const MODAL_TYPES = {
NEW_FILE: 'newFile',
DELETE_FILE: 'deleteFile',
COMMIT_MESSAGE: 'commitMessage',
};
export const IMAGE_EXTENSIONS = [
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
'.svg',
];
// Renamed from DEFAULT_SETTINGS to be more specific
export const DEFAULT_WORKSPACE_SETTINGS = {
theme: THEMES.LIGHT,
autoSave: false,
gitEnabled: false,
gitUrl: '',
gitUser: '',
gitToken: '',
gitAutoCommit: false,
gitCommitMsgTemplate: '${action} ${filename}',
};
// Template for creating new workspaces
export const DEFAULT_WORKSPACE = {
name: '',
...DEFAULT_WORKSPACE_SETTINGS,
};
export const DEFAULT_FILE = {
name: 'New File.md',
path: 'New File.md',
content: '# Welcome to NovaMD\n\nStart editing here!',
};
export const MARKDOWN_REGEX = {
WIKILINK: /(!?)\[\[(.*?)\]\]/g,
};
// List of element types that can contain inline content
export const INLINE_CONTAINER_TYPES = new Set([
'paragraph',
'listItem',
'tableCell',
'blockquote',
'heading',
'emphasis',
'strong',
'delete',
]);

View File

@@ -0,0 +1,5 @@
import { IMAGE_EXTENSIONS } from './constants';
export const isImageFile = (filePath) => {
return IMAGE_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext));
};

View File

@@ -0,0 +1,10 @@
export const formatBytes = (bytes) => {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};

View File

@@ -0,0 +1,177 @@
import { visit } from 'unist-util-visit';
import { lookupFileByName, getFileUrl } from '../services/api';
import { INLINE_CONTAINER_TYPES, MARKDOWN_REGEX } from './constants';
function createNotFoundLink(fileName, displayText, baseUrl) {
return {
type: 'link',
url: `${baseUrl}/notfound/${encodeURIComponent(fileName)}`,
children: [{ type: 'text', value: displayText }],
data: {
hProperties: { style: { color: 'red', textDecoration: 'underline' } },
},
};
}
function createFileLink(filePath, displayText, heading, baseUrl) {
const url = heading
? `${baseUrl}/internal/${encodeURIComponent(filePath)}#${encodeURIComponent(
heading
)}`
: `${baseUrl}/internal/${encodeURIComponent(filePath)}`;
return {
type: 'link',
url,
children: [{ type: 'text', value: displayText }],
};
}
function createImageNode(workspaceName, filePath, displayText) {
return {
type: 'image',
url: getFileUrl(workspaceName, filePath),
alt: displayText,
title: displayText,
};
}
function addMarkdownExtension(fileName) {
if (fileName.includes('.')) {
return fileName;
}
return `${fileName}.md`;
}
export function remarkWikiLinks(workspaceName) {
return async function transformer(tree) {
if (!workspaceName) {
console.warn('No workspace ID provided to remarkWikiLinks plugin');
return;
}
const baseUrl = window.API_BASE_URL;
const replacements = new Map();
// Find all wiki links
visit(tree, 'text', function (node, index, parent) {
const regex = MARKDOWN_REGEX.WIKILINK;
let match;
const matches = [];
while ((match = regex.exec(node.value)) !== null) {
const [fullMatch, isImage, innerContent] = match;
let fileName, displayText, heading;
const pipeIndex = innerContent.indexOf('|');
const hashIndex = innerContent.indexOf('#');
if (pipeIndex !== -1) {
displayText = innerContent.slice(pipeIndex + 1).trim();
fileName = innerContent.slice(0, pipeIndex).trim();
} else {
displayText = innerContent;
fileName = innerContent;
}
if (hashIndex !== -1 && (pipeIndex === -1 || hashIndex < pipeIndex)) {
heading = fileName.slice(hashIndex + 1).trim();
fileName = fileName.slice(0, hashIndex).trim();
}
matches.push({
fullMatch,
isImage,
fileName,
displayText,
heading,
index: match.index,
});
}
if (matches.length > 0) {
replacements.set(node, { matches, parent, index });
}
});
// Process all matches
for (const [node, { matches, parent }] of replacements) {
const newNodes = [];
let lastIndex = 0;
for (const match of matches) {
// Add text before the match
if (match.index > lastIndex) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex, match.index),
});
}
try {
const lookupFileName = match.isImage
? match.fileName
: addMarkdownExtension(match.fileName);
const paths = await lookupFileByName(workspaceName, lookupFileName);
if (paths && paths.length > 0) {
const filePath = paths[0];
if (match.isImage) {
newNodes.push(
createImageNode(workspaceName, filePath, match.displayText)
);
} else {
newNodes.push(
createFileLink(
filePath,
match.displayText,
match.heading,
baseUrl
)
);
}
} else {
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
);
}
} catch (error) {
console.debug('File lookup failed:', match.fileName, error);
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
);
}
lastIndex = match.index + match.fullMatch.length;
}
// Add any remaining text
if (lastIndex < node.value.length) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex),
});
}
// If the parent is a container that can have inline content,
// replace the text node directly with the new nodes
if (parent && INLINE_CONTAINER_TYPES.has(parent.type)) {
const nodeIndex = parent.children.indexOf(node);
if (nodeIndex !== -1) {
parent.children.splice(nodeIndex, 1, ...newNodes);
}
} else {
// For other types of parents, wrap the nodes in a paragraph
const paragraph = {
type: 'paragraph',
children: newNodes,
};
const nodeIndex = parent.children.indexOf(node);
if (nodeIndex !== -1) {
parent.children.splice(nodeIndex, 1, paragraph);
}
}
}
};
}

141
app/vite.config.js Normal file
View File

@@ -0,0 +1,141 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import postcssPresetMantine from 'postcss-preset-mantine';
import postcssSimpleVars from 'postcss-simple-vars';
import { compression } from 'vite-plugin-compression2';
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
plugins: [
react({
include: ['**/*.jsx', '**/*.js'],
}),
compression(),
],
root: 'src',
publicDir: '../public',
build: {
outDir: '../dist',
emptyOutDir: true,
assetsDir: 'assets',
sourcemap: mode === 'development',
rollupOptions: {
input: {
main: path.resolve(__dirname, 'src/index.html'),
},
output: {
manualChunks: {
// React core libraries
'react-core': ['react', 'react-dom'],
// Mantine UI components and related
mantine: [
'@mantine/core',
'@mantine/hooks',
'@mantine/modals',
'@mantine/notifications',
],
// Editor related packages
editor: [
'codemirror',
'@codemirror/commands',
'@codemirror/lang-markdown',
'@codemirror/state',
'@codemirror/theme-one-dark',
'@codemirror/view',
],
// Markdown processing
markdown: [
'react-syntax-highlighter',
'rehype-mathjax',
'rehype-prism',
'rehype-react',
'remark',
'remark-math',
'remark-parse',
'remark-rehype',
'unified',
'unist-util-visit',
],
// Icons and utilities
utils: [
'@tabler/icons-react',
'@react-hook/resize-observer',
'react-arborist',
],
},
// Optimize chunk naming for better caching
chunkFileNames: (chunkInfo) => {
const name = chunkInfo.name;
if (name === 'react-core') return 'assets/react.[hash].js';
if (name === 'mantine') return 'assets/mantine.[hash].js';
if (name === 'editor') return 'assets/editor.[hash].js';
if (name === 'markdown') return 'assets/markdown.[hash].js';
if (name === 'utils') return 'assets/utils.[hash].js';
return 'assets/[name].[hash].js';
},
// Optimize asset naming
assetFileNames: 'assets/[name].[hash][extname]',
},
},
},
server: {
port: 3000,
open: true,
},
define: {
'window.API_BASE_URL': JSON.stringify(
mode === 'production' ? '/api/v1' : 'http://localhost:8080/api/v1'
),
},
css: {
preprocessorOptions: {
scss: {
api: 'modern',
},
},
postcss: {
plugins: [
postcssPresetMantine(),
postcssSimpleVars({
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
}),
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
extensions: ['.js', '.jsx', '.json'],
},
// Add performance optimization options
optimizeDeps: {
include: [
'react',
'react-dom',
'@mantine/core',
'@mantine/hooks',
'codemirror',
'react-markdown',
],
},
}));