mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-06 07:54:22 +00:00
Add preview mode
This commit is contained in:
1806
frontend/package-lock.json
generated
1806
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -30,8 +30,13 @@
|
|||||||
"@geist-ui/core": "^2.3.8",
|
"@geist-ui/core": "^2.3.8",
|
||||||
"@geist-ui/icons": "^1.0.2",
|
"@geist-ui/icons": "^1.0.2",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
|
"katex": "^0.16.11",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"remark-math": "^6.0.0"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
|
|||||||
@@ -1,102 +1,40 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React from 'react';
|
||||||
import { GeistProvider, CssBaseline, useToasts, Page, Text, User, Button, Spacer, Breadcrumbs } from '@geist-ui/core';
|
import { GeistProvider, CssBaseline, Page } from '@geist-ui/core';
|
||||||
import { Settings } from '@geist-ui/icons';
|
import Header from './components/Header';
|
||||||
import Editor from './components/Editor';
|
import MainContent from './components/MainContent';
|
||||||
import FileTree from './components/FileTree';
|
import useFileManagement from './hooks/useFileManagement';
|
||||||
import { fetchFileList, fetchFileContent, saveFileContent } from './services/api';
|
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [content, setContent] = useState('# Welcome to NovaMD\n\nStart editing here!');
|
const {
|
||||||
const [files, setFiles] = useState([]);
|
content,
|
||||||
const [selectedFile, setSelectedFile] = useState(null);
|
files,
|
||||||
const [error, setError] = useState(null);
|
selectedFile,
|
||||||
const { setToast } = useToasts();
|
isNewFile,
|
||||||
|
hasUnsavedChanges,
|
||||||
useEffect(() => {
|
error,
|
||||||
const loadFileList = async () => {
|
handleFileSelect,
|
||||||
try {
|
handleContentChange,
|
||||||
const fileList = await fetchFileList();
|
handleSave,
|
||||||
if (Array.isArray(fileList)) {
|
} = useFileManagement();
|
||||||
setFiles(fileList);
|
|
||||||
} else {
|
|
||||||
throw new Error('File list is not an array');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load file list:', error);
|
|
||||||
setError('Failed to load file list. Please try again later.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadFileList();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFileSelect = async (filePath) => {
|
|
||||||
try {
|
|
||||||
const fileContent = await fetchFileContent(filePath);
|
|
||||||
setContent(fileContent);
|
|
||||||
setSelectedFile(filePath);
|
|
||||||
setError(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load file content:', error);
|
|
||||||
setError('Failed to load file content. Please try again.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = useCallback(async (filePath, fileContent) => {
|
|
||||||
try {
|
|
||||||
await saveFileContent(filePath, fileContent);
|
|
||||||
setToast({ text: 'File saved successfully', type: 'success' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving file:', error);
|
|
||||||
setToast({ text: 'Failed to save file. Please try again.', type: 'error' });
|
|
||||||
}
|
|
||||||
}, [setToast]);
|
|
||||||
|
|
||||||
const renderBreadcrumbs = () => {
|
|
||||||
if (!selectedFile) return null;
|
|
||||||
const pathParts = selectedFile.split('/');
|
|
||||||
return (
|
|
||||||
<Breadcrumbs>
|
|
||||||
{pathParts.map((part, index) => (
|
|
||||||
<Breadcrumbs.Item key={index}>{part}</Breadcrumbs.Item>
|
|
||||||
))}
|
|
||||||
</Breadcrumbs>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GeistProvider>
|
<GeistProvider>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<Page>
|
<Page>
|
||||||
<Page.Header className="custom-navbar">
|
<Header />
|
||||||
<Text b>NovaMD</Text>
|
<Page.Content>
|
||||||
<Spacer w={1} />
|
<MainContent
|
||||||
<User src="https://via.placeholder.com/40" name="User" />
|
|
||||||
<Spacer w={0.5} />
|
|
||||||
<Button auto icon={<Settings />} />
|
|
||||||
</Page.Header>
|
|
||||||
<Page.Content className="main-container">
|
|
||||||
<div className="sidebar">
|
|
||||||
{error ? (
|
|
||||||
<div className="error">{error}</div>
|
|
||||||
) : (
|
|
||||||
<FileTree
|
|
||||||
files={files}
|
|
||||||
onFileSelect={handleFileSelect}
|
|
||||||
selectedFile={selectedFile}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="main-content">
|
|
||||||
{renderBreadcrumbs()}
|
|
||||||
<Editor
|
|
||||||
content={content}
|
content={content}
|
||||||
onChange={setContent}
|
files={files}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
isNewFile={isNewFile}
|
||||||
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
|
error={error}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
onContentChange={handleContentChange}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
filePath={selectedFile}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Page.Content>
|
</Page.Content>
|
||||||
</Page>
|
</Page>
|
||||||
</GeistProvider>
|
</GeistProvider>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// Variables
|
// Variables
|
||||||
$sidebar-width: 250px;
|
|
||||||
$border-color: #eaeaea;
|
$border-color: #eaeaea;
|
||||||
$padding: 20px;
|
$padding: 20px;
|
||||||
$navbar-height: 64px; // Adjust this value based on your preference
|
$navbar-height: 64px; // Adjust this value based on your preference
|
||||||
@@ -13,27 +12,63 @@ $navbar-height: 64px; // Adjust this value based on your preference
|
|||||||
border-bottom: 1px solid $border-color;
|
border-bottom: 1px solid $border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
|
||||||
display: flex;
|
|
||||||
height: calc(100vh - #{$navbar-height});
|
|
||||||
padding: 0 !important; // Override Geist UI's default padding
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: $sidebar-width;
|
|
||||||
padding: $padding;
|
|
||||||
border-right: 1px solid $border-color;
|
border-right: 1px solid $border-color;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding: $padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
flex-grow: 1;
|
display: flex;
|
||||||
padding: $padding;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
// Add styles for breadcrumbs
|
.content-header {
|
||||||
.breadcrumbs {
|
display: flex;
|
||||||
margin-bottom: $padding;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: $padding;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
|
||||||
|
.breadcrumbs-container {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.unsaved-indicator {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-body {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-container, .markdown-preview {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: $padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure CodeMirror takes full height of its container
|
||||||
|
.editor-container {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.cm-editor {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-scroller {
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const Editor = ({ content, onChange, onSave, filePath }) => {
|
|||||||
extensions: [
|
extensions: [
|
||||||
basicSetup,
|
basicSetup,
|
||||||
markdown(),
|
markdown(),
|
||||||
|
EditorView.lineWrapping,
|
||||||
keymap.of(defaultKeymap),
|
keymap.of(defaultKeymap),
|
||||||
keymap.of([{
|
keymap.of([{
|
||||||
key: "Ctrl-s",
|
key: "Ctrl-s",
|
||||||
@@ -54,7 +55,7 @@ const Editor = ({ content, onChange, onSave, filePath }) => {
|
|||||||
}
|
}
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
return <div ref={editorRef} />;
|
return <div ref={editorRef} className="editor-container" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Editor;
|
export default Editor;
|
||||||
17
frontend/src/components/Header.js
Normal file
17
frontend/src/components/Header.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Page, Text, User, Button, Spacer } from '@geist-ui/core';
|
||||||
|
import { Settings } from '@geist-ui/icons';
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
return (
|
||||||
|
<Page.Header className="custom-navbar">
|
||||||
|
<Text b>NovaMD</Text>
|
||||||
|
<Spacer w={1} />
|
||||||
|
<User src="https://via.placeholder.com/40" name="User" />
|
||||||
|
<Spacer w={0.5} />
|
||||||
|
<Button auto icon={<Settings />} />
|
||||||
|
</Page.Header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
73
frontend/src/components/MainContent.js
Normal file
73
frontend/src/components/MainContent.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Grid, Breadcrumbs, Tabs, Dot } from '@geist-ui/core';
|
||||||
|
import { Code, Eye } from '@geist-ui/icons';
|
||||||
|
import Editor from './Editor';
|
||||||
|
import FileTree from './FileTree';
|
||||||
|
import MarkdownPreview from './MarkdownPreview';
|
||||||
|
|
||||||
|
const MainContent = ({
|
||||||
|
content,
|
||||||
|
files,
|
||||||
|
selectedFile,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
error,
|
||||||
|
onFileSelect,
|
||||||
|
onContentChange,
|
||||||
|
onSave,
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState('source');
|
||||||
|
|
||||||
|
const renderBreadcrumbs = () => {
|
||||||
|
if (!selectedFile) return null;
|
||||||
|
const pathParts = selectedFile.split('/');
|
||||||
|
return (
|
||||||
|
<div className="breadcrumbs-container">
|
||||||
|
<Breadcrumbs>
|
||||||
|
{pathParts.map((part, index) => (
|
||||||
|
<Breadcrumbs.Item key={index}>{part}</Breadcrumbs.Item>
|
||||||
|
))}
|
||||||
|
</Breadcrumbs>
|
||||||
|
{hasUnsavedChanges && <Dot type="warning" className="unsaved-indicator" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid.Container gap={1} height="calc(100vh - 64px)">
|
||||||
|
<Grid xs={24} sm={6} md={5} lg={4} height="100%" className="sidebar">
|
||||||
|
{error ? (
|
||||||
|
<div className="error">{error}</div>
|
||||||
|
) : (
|
||||||
|
<FileTree
|
||||||
|
files={files}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={24} sm={18} md={19} lg={20} height="100%" className="main-content">
|
||||||
|
<div className="content-header">
|
||||||
|
{renderBreadcrumbs()}
|
||||||
|
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||||
|
<Tabs.Item label={<Code />} value="source" />
|
||||||
|
<Tabs.Item label={<Eye />} value="preview" />
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
<div className="content-body">
|
||||||
|
{activeTab === 'source' ? (
|
||||||
|
<Editor
|
||||||
|
content={content}
|
||||||
|
onChange={onContentChange}
|
||||||
|
onSave={onSave}
|
||||||
|
filePath={selectedFile}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MarkdownPreview content={content} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Grid>
|
||||||
|
</Grid.Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainContent;
|
||||||
41
frontend/src/components/MarkdownPreview.js
Normal file
41
frontend/src/components/MarkdownPreview.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkMath from 'remark-math';
|
||||||
|
import rehypeKatex from 'rehype-katex';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
|
||||||
|
const MarkdownPreview = ({ content }) => {
|
||||||
|
return (
|
||||||
|
<div className="markdown-preview">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkMath]}
|
||||||
|
rehypePlugins={[rehypeKatex]}
|
||||||
|
components={{
|
||||||
|
code({node, inline, className, children, ...props}) {
|
||||||
|
const match = /language-(\w+)/.exec(className || '')
|
||||||
|
return !inline && match ? (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={vscDarkPlus}
|
||||||
|
language={match[1]}
|
||||||
|
PreTag="div"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, '')}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
) : (
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkdownPreview;
|
||||||
91
frontend/src/hooks/useFileManagement.js
Normal file
91
frontend/src/hooks/useFileManagement.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useToasts } from '@geist-ui/core';
|
||||||
|
import { fetchFileList, fetchFileContent, saveFileContent } from '../services/api';
|
||||||
|
|
||||||
|
const DEFAULT_FILE = {
|
||||||
|
name: 'New File.md',
|
||||||
|
path: 'New File.md',
|
||||||
|
content: '# Welcome to NovaMD\n\nStart editing here!'
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFileManagement = () => {
|
||||||
|
const [content, setContent] = useState(DEFAULT_FILE.content);
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path);
|
||||||
|
const [isNewFile, setIsNewFile] = useState(true);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const { setToast } = useToasts();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadFileList = async () => {
|
||||||
|
try {
|
||||||
|
const fileList = await fetchFileList();
|
||||||
|
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);
|
||||||
|
setError('Failed to load file list. Please try again later.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadFileList();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileSelect = async (filePath) => {
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
const confirmSwitch = window.confirm('You have unsaved changes. Are you sure you want to switch files?');
|
||||||
|
if (!confirmSwitch) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileContent = await fetchFileContent(filePath);
|
||||||
|
setContent(fileContent);
|
||||||
|
setSelectedFile(filePath);
|
||||||
|
setIsNewFile(false);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
setError(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load file content:', error);
|
||||||
|
setError('Failed to load file content. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContentChange = (newContent) => {
|
||||||
|
setContent(newContent);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = useCallback(async (filePath, fileContent) => {
|
||||||
|
try {
|
||||||
|
await saveFileContent(filePath, fileContent);
|
||||||
|
setToast({ text: 'File saved successfully', type: 'success' });
|
||||||
|
setIsNewFile(false);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
if (isNewFile) {
|
||||||
|
const updatedFileList = await fetchFileList();
|
||||||
|
setFiles(updatedFileList);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving file:', error);
|
||||||
|
setToast({ text: 'Failed to save file. Please try again.', type: 'error' });
|
||||||
|
}
|
||||||
|
}, [setToast, isNewFile]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
files,
|
||||||
|
selectedFile,
|
||||||
|
isNewFile,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
error,
|
||||||
|
handleFileSelect,
|
||||||
|
handleContentChange,
|
||||||
|
handleSave,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFileManagement;
|
||||||
@@ -18,6 +18,10 @@ module.exports = {
|
|||||||
test: /\.scss$/,
|
test: /\.scss$/,
|
||||||
use: ['style-loader', 'css-loader', 'sass-loader'],
|
use: ['style-loader', 'css-loader', 'sass-loader'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: ['style-loader', 'css-loader'],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
Reference in New Issue
Block a user