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:
68
frontend/dist/assets/index-39f1aff0.js
vendored
Normal file
68
frontend/dist/assets/index-39f1aff0.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-8d1b4242.css
vendored
Normal file
1
frontend/dist/assets/index-8d1b4242.css
vendored
Normal file
File diff suppressed because one or more lines are too long
15
frontend/dist/index.html
vendored
Normal file
15
frontend/dist/index.html
vendored
Normal 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
13
frontend/index.html
Normal 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
8700
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
frontend/package.json
Normal file
42
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
29
frontend/src/App.test.tsx
Normal file
29
frontend/src/App.test.tsx
Normal 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
125
frontend/src/App.tsx
Normal 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
|
||||
53
frontend/src/components/FileSidebar.test.tsx
Normal file
53
frontend/src/components/FileSidebar.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
93
frontend/src/components/FileSidebar.tsx
Normal file
93
frontend/src/components/FileSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
frontend/src/components/MarkdownEditor.test.tsx
Normal file
42
frontend/src/components/MarkdownEditor.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
57
frontend/src/components/MarkdownEditor.tsx
Normal file
57
frontend/src/components/MarkdownEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
frontend/src/components/ThemeSwitcher.tsx
Normal file
55
frontend/src/components/ThemeSwitcher.tsx
Normal 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
17
frontend/src/index.css
Normal 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
10
frontend/src/index.tsx
Normal 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>,
|
||||
)
|
||||
3
frontend/src/test-setup.ts
Normal file
3
frontend/src/test-setup.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { expect } from 'vitest'
|
||||
|
||||
// No custom matchers for now - using standard assertions
|
||||
33
frontend/tailwind.config.js
Normal file
33
frontend/tailwind.config.js
Normal 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
26
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
11
frontend/vite.config.test.ts
Normal file
11
frontend/vite.config.test.ts
Normal 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
21
frontend/vite.config.ts
Normal 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
11
frontend/vitest.config.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user