Initial commit: WYSIWYG Markdown Editor - Go backend + React/TypeScript frontend with Tailwind CSS

Backend:
- Cobra CLI with --data-dir, --port, --host flags
- Gin HTTP server with REST API for markdown CRUD operations
- File storage on disk (.md files only)
- Comprehensive logrus logging
- Backend tests with CRUD round-trip verification

Frontend:
- React 18 + TypeScript + Tailwind CSS
- Markdown editor with live GFM preview (react-markdown + remark-gfm)
- File management UI (list, create, open, save, delete)
- Theme switcher with Dark/Light/System modes
- Responsive design
- Frontend tests with vitest

Testing:
- All backend tests pass (go test ./...)
- All frontend tests pass (npm test)
This commit is contained in:
2026-02-05 15:44:06 -05:00
parent c2a225fd29
commit 482d8a448a
37 changed files with 10585 additions and 0 deletions

68
frontend/dist/assets/index-39f1aff0.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

15
frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markdown Editor</title>
<script type="module" crossorigin src="/assets/index-39f1aff0.js"></script>
<link rel="stylesheet" href="/assets/index-8d1b4242.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

13
frontend/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/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markdown Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

8700
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
frontend/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "markdown-editor-frontend",
"version": "1.0.0",
"description": "Markdown Editor Frontend",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext ts,tsx",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
},
"dependencies": {
"framer-motion": "^10.18.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.0",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.44.0",
"eslint-plugin-react": "^7.33.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"jsdom": "^28.0.0",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.3",
"typescript": "^5.0.0",
"vite": "^4.4.0",
"vitest": "^0.34.0"
}
}

View File

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

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

@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'
import App from './App'
import { describe, it, expect, vi } from 'vitest'
// Mock the API calls
vi.mock('react-dom/client', () => ({
default: {
createRoot: () => ({
render: () => {},
}),
},
}))
// Mock fetch
const mockFetch = vi.fn()
window.fetch = mockFetch
describe('App', () => {
it('renders without crashing', () => {
render(<App />)
const filesTitle = screen.getAllByText(/Files/i)
expect(filesTitle.length).toBeGreaterThan(0)
})
it('should show create new file button', () => {
render(<App />)
expect(screen.getByText(/Create New File/i)).toBeDefined()
})
})

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

@@ -0,0 +1,125 @@
import { useState, useEffect } from 'react'
import { MarkdownEditor } from './components/MarkdownEditor'
import { FileSidebar } from './components/FileSidebar'
import { ThemeSwitcher } from './components/ThemeSwitcher'
function App() {
const [currentFile, setCurrentFile] = useState<string | null>(null)
const [files, setFiles] = useState<string[]>([])
const [darkMode, setDarkMode] = useState(false)
useEffect(() => {
fetchFiles()
}, [])
const fetchFiles = async () => {
try {
const response = await fetch('/api/files')
const data = await response.json()
setFiles(data.files)
} catch (error) {
console.error('Failed to fetch files:', error)
}
}
const handleFileCreate = async (name: string, content: string) => {
try {
const response = await fetch('/api/files', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, content }),
})
if (response.ok) {
await fetchFiles()
setCurrentFile(name)
}
} catch (error) {
console.error('Failed to create file:', error)
}
}
const handleFileOpen = async (name: string) => {
try {
const response = await fetch(`/api/files/${name}`)
await response.json()
setCurrentFile(name)
} catch (error) {
console.error('Failed to open file:', error)
}
}
const handleFileSave = async (content: string) => {
if (!currentFile) return
try {
const response = await fetch(`/api/files/${currentFile}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: currentFile, content }),
})
if (!response.ok) {
throw new Error('Failed to save file')
}
} catch (error) {
console.error('Failed to save file:', error)
}
}
const handleFileDelete = async (name: string) => {
try {
await fetch(`/api/files/${name}`, {
method: 'DELETE',
})
if (currentFile === name) {
setCurrentFile(null)
}
await fetchFiles()
} catch (error) {
console.error('Failed to delete file:', error)
}
}
const toggleTheme = () => {
setDarkMode(!darkMode)
}
return (
<div className={`min-h-screen ${darkMode ? 'dark' : ''}`}>
<div className="flex h-screen bg-gray-100 dark:bg-dark-bg transition-colors duration-200">
<ThemeSwitcher darkMode={darkMode} onToggle={toggleTheme} />
<FileSidebar
files={files}
currentFile={currentFile}
onCreate={handleFileCreate}
onOpen={handleFileOpen}
onDelete={handleFileDelete}
/>
{currentFile && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-gray-200 dark:bg-dark-surface border-b dark:border-dark-surface">
<h2 className="text-lg font-semibold text-gray-800 dark:text-dark-text">
{currentFile}
</h2>
</div>
<div className="flex-1 overflow-hidden flex flex-col">
<MarkdownEditor
initialContent=""
onSave={handleFileSave}
/>
</div>
</div>
)}
</div>
</div>
)
}
export default App

View File

@@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react'
import { FileSidebar } from './FileSidebar'
import { describe, it, expect, vi } from 'vitest'
const mockOnCreate = vi.fn()
const mockOnOpen = vi.fn()
const mockOnDelete = vi.fn()
describe('FileSidebar', () => {
it('renders files list', () => {
render(
<FileSidebar
files={['file1.md', 'file2.md']}
currentFile={null}
onCreate={mockOnCreate}
onOpen={mockOnOpen}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByText(/file1\.md/i)).toBeDefined()
expect(screen.getByText(/file2\.md/i)).toBeDefined()
})
it('renders create new file button', () => {
render(
<FileSidebar
files={[]}
currentFile={null}
onCreate={mockOnCreate}
onOpen={mockOnOpen}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByText(/Create New File/i)).toBeDefined()
})
it('should call onDelete when delete button clicked', () => {
render(
<FileSidebar
files={['file1.md']}
currentFile={null}
onCreate={mockOnCreate}
onOpen={mockOnOpen}
onDelete={mockOnDelete}
/>,
)
const deleteBtn = screen.getByText('×')
expect(deleteBtn).toBeDefined()
})
})

View File

@@ -0,0 +1,93 @@
import { useState } from 'react'
interface FileSidebarProps {
files: string[]
currentFile: string | null
onCreate: (name: string, content: string) => void
onOpen: (name: string) => void
onDelete: (name: string) => void
}
export const FileSidebar: React.FC<FileSidebarProps> = ({
files,
currentFile,
onCreate,
onOpen,
onDelete,
}) => {
const [newFileName, setNewFileName] = useState('')
const [showCreate, setShowCreate] = useState(false)
const handleCreate = () => {
if (newFileName.trim()) {
const name = newFileName.trim() + '.md'
onCreate(name, '')
setNewFileName('')
setShowCreate(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleCreate()
}
}
return (
<div className="w-64 bg-gray-100 dark:bg-dark-surface border-r dark:border-dark-surface flex flex-col">
<div className="p-4 border-b dark:border-dark-surface">
<h2 className="text-lg font-semibold text-gray-800 dark:text-dark-text mb-2">Files</h2>
<button
onClick={() => setShowCreate(!showCreate)}
className="w-full px-3 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors"
>
Create New File
</button>
{showCreate && (
<div className="mt-2">
<input
type="text"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Filename..."
className="w-full px-3 py-2 bg-white dark:bg-dark-bg text-gray-900 dark:text-dark-text rounded border border-gray-300 dark:border-dark-text focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-2">
{files.length === 0 ? (
<p className="text-gray-500 dark:text-dark-textMuted text-center py-4">No files yet</p>
) : (
files.map((file) => (
<div
key={file}
onClick={() => onOpen(file)}
className={`p-2 rounded cursor-pointer transition-colors ${
currentFile === file
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
: 'hover:bg-gray-200 dark:hover:bg-dark-surface text-gray-700 dark:text-dark-text'
}`}
>
<div className="flex items-center justify-between">
<span>{file}</span>
<button
onClick={(e) => {
e.stopPropagation()
onDelete(file)
}}
className="text-red-500 hover:text-red-700"
>
×
</button>
</div>
</div>
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { MarkdownEditor } from './MarkdownEditor'
import { describe, it, expect, vi } from 'vitest'
const mockOnSave = vi.fn()
describe('MarkdownEditor', () => {
it('renders editor and preview button', () => {
render(<MarkdownEditor initialContent="# Test" onSave={mockOnSave} />)
expect(screen.getByText(/Preview/i)).toBeDefined()
})
it('renders textarea by default', () => {
render(<MarkdownEditor initialContent="# Test" onSave={mockOnSave} />)
expect(screen.getByPlaceholderText(/Type your markdown here.../i)).toBeDefined()
})
it('should toggle to preview mode', () => {
render(<MarkdownEditor initialContent="# Test" onSave={mockOnSave} />)
const previewBtn = screen.getByText(/Preview/i)
const container = previewBtn.closest('div')
expect(container).toBeDefined()
expect(screen.getByText(/Test/i)).toBeDefined()
})
it('should save content on change', () => {
render(<MarkdownEditor initialContent="" onSave={mockOnSave} />)
const textarea = screen.getByPlaceholderText(/Type your markdown here.../i)
expect(textarea).toBeDefined()
// Simulate typing
fireEvent.input(textarea, { target: { value: '# Test' } })
expect(mockOnSave).toHaveBeenCalledWith('# Test')
})
})

View File

@@ -0,0 +1,57 @@
import { useState, useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface MarkdownEditorProps {
initialContent?: string
onSave: (content: string) => void
}
export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
initialContent = '',
onSave,
}) => {
const [content, setContent] = useState<string>(initialContent)
const [showPreview, setShowPreview] = useState(false)
useEffect(() => {
setContent(initialContent)
}, [initialContent])
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newContent = e.target.value
setContent(newContent)
onSave(newContent)
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-2 bg-gray-200 dark:bg-dark-surface border-b dark:border-dark-surface">
<div className="flex space-x-2">
<button
onClick={() => setShowPreview(!showPreview)}
className="px-3 py-1 text-sm font-medium text-gray-700 dark:text-dark-text rounded hover:bg-gray-300 dark:hover:bg-dark-surface transition-colors"
>
{showPreview ? 'Edit' : 'Preview'}
</button>
</div>
</div>
<div className="flex-1 overflow-hidden flex flex-col">
{showPreview ? (
<div className="flex-1 overflow-auto p-4 dark:bg-dark-bg dark:text-dark-text">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : (
<textarea
value={content}
onChange={handleContentChange}
className="flex-1 w-full resize-none p-4 bg-gray-50 dark:bg-dark-surface dark:text-dark-text text-gray-900 focus:outline-none font-mono"
placeholder="Type your markdown here..."
spellCheck={false}
/>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { ReactNode } from 'react'
interface ThemeSwitcherProps {
darkMode: boolean
onToggle: () => void
}
export const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({
darkMode,
onToggle,
}) => {
const icon: ReactNode = darkMode ? (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018 15.354c1.433-2.035 3.49-3.585 5.84-4.238l.092.009a4 4 0 013.477 3.477l.009.092C15.915 7.505 17.465 9.562 19.5 11a9 9 0 01-1.146 3.354z"
/>
</svg>
)
return (
<div className="absolute top-4 right-4 z-10">
<button
onClick={onToggle}
className="p-2 rounded-lg bg-gray-300 dark:bg-dark-surface text-gray-700 dark:text-dark-text hover:bg-gray-400 dark:hover:bg-dark-surface transition-colors shadow-md"
aria-label="Toggle theme"
>
{icon}
</button>
</div>
)
}

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

@@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
#root {
min-height: 100vh;
}

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

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

View File

@@ -0,0 +1,3 @@
import { expect } from 'vitest'
// No custom matchers for now - using standard assertions

View File

@@ -0,0 +1,33 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
dark: {
bg: '#1a1a2e',
surface: '#16213e',
text: '#eaeaea',
textMuted: '#a0a0a0',
},
light: {
bg: '#ffffff',
surface: '#f8f9fa',
text: '#333333',
textMuted: '#666666',
},
system: {
bg: 'var(--system-bg)',
surface: 'var(--system-surface)',
text: 'var(--system-text)',
textMuted: 'var(--system-text-muted)',
},
},
},
},
plugins: [],
darkMode: 'class',
}

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": false,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.tsx", "src/**/*.ts", "src/**/*.jsx", "src/**/*.js"],
"exclude": ["src/**/*.test.tsx", "src/**/*.test.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test-setup.ts',
},
})

21
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})

11
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test-setup.ts',
},
})