mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 23:44: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/icons": "^1.0.2",
|
||||
"codemirror": "^6.0.1",
|
||||
"katex": "^0.16.11",
|
||||
"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": {
|
||||
"extends": [
|
||||
|
||||
@@ -1,102 +1,40 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { GeistProvider, CssBaseline, useToasts, Page, Text, User, Button, Spacer, Breadcrumbs } from '@geist-ui/core';
|
||||
import { Settings } from '@geist-ui/icons';
|
||||
import Editor from './components/Editor';
|
||||
import FileTree from './components/FileTree';
|
||||
import { fetchFileList, fetchFileContent, saveFileContent } from './services/api';
|
||||
import React from 'react';
|
||||
import { GeistProvider, CssBaseline, Page } from '@geist-ui/core';
|
||||
import Header from './components/Header';
|
||||
import MainContent from './components/MainContent';
|
||||
import useFileManagement from './hooks/useFileManagement';
|
||||
import './App.scss';
|
||||
|
||||
function App() {
|
||||
const [content, setContent] = useState('# Welcome to NovaMD\n\nStart editing here!');
|
||||
const [files, setFiles] = useState([]);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
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) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
const {
|
||||
content,
|
||||
files,
|
||||
selectedFile,
|
||||
isNewFile,
|
||||
hasUnsavedChanges,
|
||||
error,
|
||||
handleFileSelect,
|
||||
handleContentChange,
|
||||
handleSave,
|
||||
} = useFileManagement();
|
||||
|
||||
return (
|
||||
<GeistProvider>
|
||||
<CssBaseline />
|
||||
<Page>
|
||||
<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>
|
||||
<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
|
||||
<Header />
|
||||
<Page.Content>
|
||||
<MainContent
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
files={files}
|
||||
selectedFile={selectedFile}
|
||||
isNewFile={isNewFile}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
error={error}
|
||||
onFileSelect={handleFileSelect}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
filePath={selectedFile}
|
||||
/>
|
||||
</div>
|
||||
</Page.Content>
|
||||
</Page>
|
||||
</GeistProvider>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Variables
|
||||
$sidebar-width: 250px;
|
||||
$border-color: #eaeaea;
|
||||
$padding: 20px;
|
||||
$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;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
display: flex;
|
||||
height: calc(100vh - #{$navbar-height});
|
||||
padding: 0 !important; // Override Geist UI's default padding
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: $sidebar-width;
|
||||
padding: $padding;
|
||||
border-right: 1px solid $border-color;
|
||||
overflow-y: auto;
|
||||
padding: $padding;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex-grow: 1;
|
||||
padding: $padding;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Add styles for breadcrumbs
|
||||
.breadcrumbs {
|
||||
margin-bottom: $padding;
|
||||
.content-header {
|
||||
display: flex;
|
||||
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: [
|
||||
basicSetup,
|
||||
markdown(),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of(defaultKeymap),
|
||||
keymap.of([{
|
||||
key: "Ctrl-s",
|
||||
@@ -54,7 +55,7 @@ const Editor = ({ content, onChange, onSave, filePath }) => {
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
return <div ref={editorRef} />;
|
||||
return <div ref={editorRef} className="editor-container" />;
|
||||
};
|
||||
|
||||
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$/,
|
||||
use: ['style-loader', 'css-loader', 'sass-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
|
||||
Reference in New Issue
Block a user