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:
1
frontend/.env
Normal file
1
frontend/.env
Normal file
@@ -0,0 +1 @@
|
||||
REACT_APP_API_URL=http://127.0.0.1:8080
|
||||
44
frontend/package.json
Normal file
44
frontend/package.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
17
frontend/public/index.html
Normal file
17
frontend/public/index.html
Normal 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
25
frontend/src/App.test.tsx
Normal 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
213
frontend/src/App.tsx
Normal 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;
|
||||
38
frontend/src/components/Editor.tsx
Normal file
38
frontend/src/components/Editor.tsx
Normal 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;
|
||||
58
frontend/src/components/FileList.tsx
Normal file
58
frontend/src/components/FileList.tsx
Normal 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;
|
||||
23
frontend/src/components/MarkdownPreview.tsx
Normal file
23
frontend/src/components/MarkdownPreview.tsx
Normal 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;
|
||||
39
frontend/src/components/__tests__/Editor.test.tsx
Normal file
39
frontend/src/components/__tests__/Editor.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
53
frontend/src/hooks/useTheme.ts
Normal file
53
frontend/src/hooks/useTheme.ts
Normal 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
72
frontend/src/index.css
Normal 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
14
frontend/src/index.tsx
Normal 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
1
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const API_URL = process.env.REACT_APP_API_URL || 'http://127.0.0.1:8080';
|
||||
8
frontend/tailwind.config.js
Normal file
8
frontend/tailwind.config.js
Normal 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
25
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user