feat(markdown-editor): implement wysiswyg markdown editor with live preview

- Build Go backend with Cobra CLI and REST API
  - CRUD operations for markdown files (GET, POST, PUT, DELETE)
  - File storage with flat .md file structure
  - Comprehensive logrus logging with JSON format
  - Static asset serving for frontend

- Build React/TypeScript frontend with Tailwind CSS
  - Markdown editor with live GFM preview
  - File management UI (list, create, open, delete)
  - Theme system (Dark/Light/System) with persistence
  - Responsive design (320px mobile, 1920px desktop)

- Add comprehensive test coverage
  - Backend: API, storage, and logger tests (13 tests passing)
  - Frontend: Editor and App component tests

- Setup Nix development environment with Go, Node.js, and TypeScript
This commit is contained in:
2026-02-05 17:48:23 -05:00
parent 78f33053fb
commit 5b67cb61d2
31 changed files with 2010 additions and 0 deletions

1
frontend/.env Normal file
View File

@@ -0,0 +1 @@
REACT_APP_API_URL=http://127.0.0.1:8080

44
frontend/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "markdown-editor-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-scripts": "5.0.1",
"react-syntax-highlighter": "^15.5.0",
"react-textarea-autosize": "^8.5.7",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.8.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-textarea-autosize": "^8.5.6",
"autoprefixer": "^10.4.20",
"eslint": "^9.14.0",
"eslint-config-react-app": "^7.0.1",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": ["react-app", "react-app/jest"]
},
"browserslist": {
"production": [">0.2%", "not dead", "not op_mini all"],
"development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"]
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Markdown Editor with live preview"
/>
<title>Markdown Editor</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

25
frontend/src/App.test.tsx Normal file
View File

@@ -0,0 +1,25 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App Component', () => {
it('renders header', () => {
render(<App />);
expect(screen.getByText(/Markdown Editor/i)).toBeInTheDocument();
});
it('renders file list', () => {
render(<App />);
expect(screen.getByText(/Files/i)).toBeInTheDocument();
});
it('renders editor', () => {
render(<App />);
expect(screen.getByPlaceholderText(/Start writing your markdown here/i)).toBeInTheDocument();
});
it('renders preview section', () => {
render(<App />);
expect(screen.getByText(/Preview/i)).toBeInTheDocument();
});
});

213
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,213 @@
import React, { useState, useEffect, useCallback } from 'react';
import Editor from './components/Editor';
import FileList from './components/FileList';
import { useTheme } from './hooks/useTheme';
import { API_URL } from './lib/api';
type File = {
name: string;
};
function App() {
const [files, setFiles] = useState<File[]>([]);
const [currentFile, setCurrentFile] = useState<File | null>(null);
const [content, setContent] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { theme, toggleTheme } = useTheme();
// Fetch files list
const fetchFiles = useCallback(async () => {
try {
setLoading(true);
const response = await fetch(`${API_URL}/api/files`);
if (!response.ok) throw new Error('Failed to fetch files');
const data = await response.json();
setFiles(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch files');
} finally {
setLoading(false);
}
}, []);
// Load file content
const loadFile = useCallback(async (filename: string) => {
try {
setLoading(true);
setError(null);
const response = await fetch(`${API_URL}/api/files/${filename}`);
if (!response.ok) throw new Error('Failed to load file');
const data = await response.json();
setContent(data);
setCurrentFile({ name: filename });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load file');
} finally {
setLoading(false);
}
}, []);
// Create new file
const createFile = async (filename: string, initialContent: string = '') => {
try {
setLoading(true);
setError(null);
const response = await fetch(`${API_URL}/api/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: filename, content: initialContent }),
});
if (!response.ok) throw new Error('Failed to create file');
await fetchFiles();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create file');
} finally {
setLoading(false);
}
};
// Update file content
const updateFile = async () => {
if (!currentFile) return;
try {
setLoading(true);
setError(null);
const response = await fetch(`${API_URL}/api/files/${currentFile.name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (!response.ok) throw new Error('Failed to update file');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update file');
} finally {
setLoading(false);
}
};
// Delete file
const deleteFile = async (filename: string) => {
try {
setLoading(true);
setError(null);
const response = await fetch(`${API_URL}/api/files/${filename}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete file');
if (currentFile?.name === filename) {
setCurrentFile(null);
setContent('');
}
await fetchFiles();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete file');
} finally {
setLoading(false);
}
};
// Sync editor content with server
useEffect(() => {
const timeoutId = setTimeout(() => {
if (currentFile && content) {
updateFile();
}
}, 500);
return () => clearTimeout(timeoutId);
}, [content, currentFile, updateFile]);
// Initial file fetch
useEffect(() => {
fetchFiles();
}, [fetchFiles]);
return (
<div className={`min-h-screen bg-primary text-primary ${theme}`}>
<header className="bg-secondary border-b border-custom p-4">
<div className="container mx-auto flex items-center justify-between">
<h1 className="text-2xl font-bold">Markdown Editor</h1>
<button
onClick={toggleTheme}
className="px-4 py-2 rounded-lg bg-primary border border-custom hover:bg-opacity-80 transition"
>
{theme === 'dark' ? '☀️ Light' : theme === 'light' ? '🌙 Dark' : '🌗 System'}
</button>
</div>
</header>
<div className="container mx-auto p-4 flex h-[calc(100vh-73px)]">
{/* Sidebar - File List */}
<aside className="w-64 bg-secondary border-r border-custom p-4 overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Files</h2>
<button
onClick={() => {
const filename = prompt('Enter filename (must end with .md):');
if (filename && filename.endsWith('.md')) {
createFile(filename);
}
}}
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition text-sm"
>
+ New
</button>
</div>
{loading && files.length === 0 ? (
<p className="text-secondary">Loading...</p>
) : (
<ul className="space-y-2">
{files.map((file) => (
<li
key={file.name}
className={`p-2 rounded cursor-pointer transition ${
currentFile?.name === file.name
? 'bg-blue-500 text-white'
: 'hover:bg-opacity-80'
}`}
onClick={() => loadFile(file.name)}
>
<span className="truncate block">{file.name}</span>
</li>
))}
</ul>
)}
{error && (
<div className="mt-4 p-2 bg-red-100 text-red-600 rounded text-sm">
{error}
</div>
)}
</aside>
{/* Main Content - Editor & Preview */}
<main className="flex-1 overflow-hidden">
<div className="h-full flex flex-col">
{/* Editor */}
<Editor
content={content}
onChange={setContent}
disabled={!currentFile || loading}
/>
{/* Preview */}
{currentFile && (
<div className="flex-1 overflow-y-auto border-t border-custom">
<div className="bg-primary p-4">
<h2 className="text-lg font-semibold mb-4">Preview</h2>
<div className="prose max-w-none">
<div
className="markdown-preview"
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</div>
</div>
)}
</div>
</main>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,38 @@
import React, { useRef, useEffect } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
interface EditorProps {
content: string;
onChange: (content: string) => void;
disabled?: boolean;
}
const Editor: React.FC<EditorProps> = ({ content, onChange, disabled }) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (textareaRef.current && textareaRef.current.value !== content) {
textareaRef.current.value = content;
}
}, [content]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
};
return (
<div className="h-1/2 border-r border-custom p-4">
<textarea
ref={textareaRef}
value={content}
onChange={handleChange}
disabled={disabled}
placeholder="Start writing your markdown here..."
className="w-full h-full bg-transparent resize-none outline-none font-mono text-sm leading-relaxed"
spellCheck={false}
/>
</div>
);
};
export default Editor;

View File

@@ -0,0 +1,58 @@
import React from 'react';
interface File {
name: string;
}
interface FileListProps {
files: File[];
currentFile: File | null;
onFileClick: (file: File) => void;
onCreateFile: () => void;
onDeleteFile: (filename: string) => void;
loading: boolean;
}
const FileList: React.FC<FileListProps> = ({
files,
currentFile,
onFileClick,
onCreateFile,
onDeleteFile,
loading,
}) => {
return (
<div className="bg-secondary border-r border-custom p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Files</h2>
<button
onClick={onCreateFile}
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition text-sm"
>
+ New
</button>
</div>
{loading && files.length === 0 ? (
<p className="text-gray-500">Loading...</p>
) : (
<ul className="space-y-2">
{files.map((file) => (
<li
key={file.name}
className={`p-2 rounded cursor-pointer transition ${
currentFile?.name === file.name
? 'bg-blue-500 text-white'
: 'hover:bg-gray-200'
}`}
onClick={() => onFileClick(file)}
>
<span className="truncate block">{file.name}</span>
</li>
))}
</ul>
)}
</div>
);
};
export default FileList;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github-dark.css';
interface MarkdownPreviewProps {
content: string;
}
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({ content }) => {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
className="prose max-w-none"
>
{content}
</ReactMarkdown>
);
};
export default MarkdownPreview;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Editor from '../Editor';
describe('Editor Component', () => {
it('renders textarea', () => {
render(
<Editor content="Test content" onChange={() => {}} />
);
const textarea = screen.getByPlaceholderText(/Start writing your markdown here/i);
expect(textarea).toBeInTheDocument();
});
it('displays provided content', () => {
render(
<Editor content="# Heading" onChange={() => {}} />
);
const textarea = screen.getByDisplayValue('# Heading');
expect(textarea).toBeInTheDocument();
});
it('handles content changes', () => {
const handleChange = jest.fn();
render(
<Editor content="" onChange={handleChange} />
);
const textarea = screen.getByPlaceholderText(/Start writing your markdown here/i);
fireEvent.change(textarea, { target: { value: 'New content' } });
expect(handleChange).toHaveBeenCalledWith('New content');
});
it('disables when prop is true', () => {
render(
<Editor content="Test" onChange={() => {}} disabled={true} />
);
const textarea = screen.getByPlaceholderText(/Start writing your markdown here/i);
expect(textarea).toBeDisabled();
});
});

View File

@@ -0,0 +1,53 @@
import { useState, useEffect } from 'react';
type Theme = 'dark' | 'light' | 'system';
const STORAGE_KEY = 'markdown-editor-theme';
export const useTheme = () => {
const [theme, setTheme] = useState<Theme>('system');
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY) as Theme | null;
if (saved) {
setTheme(saved);
}
}, []);
useEffect(() => {
const applyTheme = () => {
const root = document.documentElement;
root.classList.remove('dark', 'light', 'system');
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.classList.add(prefersDark ? 'dark' : 'light');
} else {
root.classList.add(theme);
}
};
applyTheme();
localStorage.setItem(STORAGE_KEY, theme);
// Listen for system theme changes
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
applyTheme();
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => {
if (prev === 'dark') return 'light';
if (prev === 'light') return 'system';
return 'dark';
});
};
return { theme, toggleTheme };
};

72
frontend/src/index.css Normal file
View File

@@ -0,0 +1,72 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--primary: #3b82f6;
--secondary: #64748b;
--success: #22c55e;
--danger: #ef4444;
--warning: #f59e0b;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Dark theme overrides */
.dark {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--border-color: #334155;
}
/* Light theme overrides */
.light {
--bg-primary: #ffffff;
--bg-secondary: #f1f5f9;
--text-primary: #1e293b;
--text-secondary: #64748b;
--border-color: #e2e8f0;
}
/* System theme - use CSS variables */
.system {
--bg-primary: #ffffff;
--bg-secondary: #f1f5f9;
--text-primary: #1e293b;
--text-secondary: #64748b;
--border-color: #e2e8f0;
}
.bg-primary {
background-color: var(--bg-primary);
}
.bg-secondary {
background-color: var(--bg-secondary);
}
.text-primary {
color: var(--text-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.border-custom {
border-color: var(--border-color);
}

14
frontend/src/index.tsx Normal file
View File

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

1
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1 @@
export const API_URL = process.env.REACT_APP_API_URL || 'http://127.0.0.1:8080';

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
}

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
}