load project

This commit is contained in:
2026-06-15 09:18:58 +03:00
commit bf1839607f
70 changed files with 7503 additions and 0 deletions

5
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.git
.gitignore
*.local

4
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.local

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document Template Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

21
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,21 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 50M;
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

1774
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "document-template-editor-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}

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

@@ -0,0 +1,59 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import Navbar from './components/Navbar'
import { AuthProvider, useAuth } from './context/AuthContext'
import CreateTemplatePage from './pages/CreateTemplatePage'
import DocumentDetailPage from './pages/DocumentDetailPage'
import DocumentsPage from './pages/DocumentsPage'
import EditDocumentPage from './pages/EditDocumentPage'
import FillTemplatePage from './pages/FillTemplatePage'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
import ActivatePage from './pages/ActivatePage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage'
import TemplateDetailPage from './pages/TemplateDetailPage'
import TemplatesPage from './pages/TemplatesPage'
import UsersPage from './pages/UsersPage'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
if (loading) return <div className="container"><p>Loading...</p></div>
if (!user) return <Navigate to="/login" replace />
return <>{children}</>
}
function AppRoutes() {
const { user } = useAuth()
return (
<>
{user && <Navbar />}
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/activate" element={<ActivatePage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/" element={<Navigate to="/templates" replace />} />
<Route path="/templates" element={<ProtectedRoute><TemplatesPage /></ProtectedRoute>} />
<Route path="/templates/new" element={<ProtectedRoute><CreateTemplatePage /></ProtectedRoute>} />
<Route path="/templates/:id" element={<ProtectedRoute><TemplateDetailPage /></ProtectedRoute>} />
<Route path="/templates/:id/fill" element={<ProtectedRoute><FillTemplatePage /></ProtectedRoute>} />
<Route path="/documents" element={<ProtectedRoute><DocumentsPage /></ProtectedRoute>} />
<Route path="/documents/:id" element={<ProtectedRoute><DocumentDetailPage /></ProtectedRoute>} />
<Route path="/documents/:id/edit" element={<ProtectedRoute><EditDocumentPage /></ProtectedRoute>} />
<Route path="/users" element={<ProtectedRoute><UsersPage /></ProtectedRoute>} />
</Routes>
</>
)
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
)
}

276
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,276 @@
import type {
DocumentTemplate,
FilledDocument,
PreviewResponse,
TemplateListItem,
User,
} from './types'
const API_BASE = '/api'
function getToken(): string | null {
return localStorage.getItem('token')
}
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const token = getToken()
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
}
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = headers['Content-Type'] || 'application/json'
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers,
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Request failed' }))
const detail = error.detail
const message =
typeof detail === 'string'
? detail
: Array.isArray(detail)
? detail.map((d: { msg?: string }) => d.msg).filter(Boolean).join(', ') || 'Request failed'
: `HTTP ${response.status}`
throw new Error(message)
}
if (response.status === 204) {
return undefined as T
}
return response.json()
}
export const api = {
async login(username: string, password: string): Promise<{ access_token: string }> {
const form = new FormData()
form.append('username', username)
form.append('password', password)
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
body: form,
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Login failed' }))
const detail = error.detail
const message =
typeof detail === 'string'
? detail
: Array.isArray(detail)
? detail.map((d: { msg?: string }) => d.msg).filter(Boolean).join(', ') || 'Login failed'
: 'Login failed'
throw new Error(message)
}
return response.json()
},
async register(data: {
email: string
username: string
password: string
confirm_password: string
}): Promise<{ message: string; email: string }> {
return request<{ message: string; email: string }>('/auth/register', {
method: 'POST',
body: JSON.stringify(data),
})
},
async activateAccount(email: string, code: string): Promise<{ message: string }> {
return request<{ message: string }>('/auth/activate', {
method: 'POST',
body: JSON.stringify({ email, code }),
})
},
async resendActivation(email: string): Promise<{ message: string }> {
return request<{ message: string }>('/auth/resend-activation', {
method: 'POST',
body: JSON.stringify({ email }),
})
},
async forgotPassword(email: string): Promise<{ message: string }> {
return request<{ message: string }>('/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email }),
})
},
async resetPassword(data: {
email: string
code: string
password: string
confirm_password: string
}): Promise<{ message: string }> {
return request<{ message: string }>('/auth/reset-password', {
method: 'POST',
body: JSON.stringify(data),
})
},
async getMe(): Promise<User> {
return request<User>('/auth/me')
},
async listUsers(): Promise<User[]> {
return request<User[]>('/users')
},
async createUser(data: {
email: string
username: string
password: string
role: 'user' | 'admin'
}): Promise<User> {
return request<User>('/users', {
method: 'POST',
body: JSON.stringify(data),
})
},
async updateUser(
id: number,
data: {
email?: string
username?: string
role?: 'user' | 'admin'
is_active?: boolean
password?: string
},
): Promise<User> {
return request<User>(`/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
},
async deleteUser(id: number): Promise<void> {
return request<void>(`/users/${id}`, { method: 'DELETE' })
},
async listTemplates(): Promise<TemplateListItem[]> {
return request<TemplateListItem[]>('/templates')
},
async getTemplate(id: number): Promise<DocumentTemplate> {
return request<DocumentTemplate>(`/templates/${id}`)
},
async createTemplate(
file: File,
name: string,
description?: string,
isPublic?: boolean,
): Promise<DocumentTemplate> {
const form = new FormData()
form.append('file', file)
form.append('name', name)
if (description) form.append('description', description)
form.append('is_public', String(isPublic ?? false))
return request<DocumentTemplate>('/templates', {
method: 'POST',
body: form,
})
},
async deleteTemplate(id: number): Promise<void> {
return request<void>(`/templates/${id}`, { method: 'DELETE' })
},
async updateTemplate(
id: number,
data: { name?: string; description?: string; is_public?: boolean },
): Promise<DocumentTemplate> {
return request<DocumentTemplate>(`/templates/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
},
getTemplateSourceUrl(templateId: number): string {
return `${API_BASE}/templates/${templateId}/download`
},
async listDocuments(): Promise<FilledDocument[]> {
return request<FilledDocument[]>('/documents')
},
async getDocument(id: number): Promise<FilledDocument> {
return request<FilledDocument>(`/documents/${id}`)
},
async createDocument(data: {
template_id: number
name: string
field_data: Record<string, unknown>
}): Promise<FilledDocument> {
return request<FilledDocument>('/documents', {
method: 'POST',
body: JSON.stringify(data),
})
},
async updateDocument(
id: number,
data: { name?: string; field_data?: Record<string, unknown> },
): Promise<FilledDocument> {
return request<FilledDocument>(`/documents/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
},
async deleteDocument(id: number): Promise<void> {
return request<void>(`/documents/${id}`, { method: 'DELETE' })
},
async previewDocument(data: {
template_id: number
name: string
field_data: Record<string, unknown>
}): Promise<PreviewResponse> {
return request<PreviewResponse>('/documents/preview', {
method: 'POST',
body: JSON.stringify(data),
})
},
getExportDocxUrl(documentId: number): string {
return `${API_BASE}/documents/${documentId}/export/docx`
},
getExportPdfUrl(documentId: number): string {
return `${API_BASE}/documents/${documentId}/export/pdf`
},
}
export async function downloadWithAuth(url: string, filename: string) {
const token = getToken()
const response = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!response.ok) throw new Error('Download failed')
const blob = await response.blob()
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
link.click()
URL.revokeObjectURL(link.href)
}

View File

@@ -0,0 +1,42 @@
import { Link, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function Navbar() {
const { user, logout } = useAuth()
const location = useLocation()
const isActive = (path: string) =>
location.pathname === path || location.pathname.startsWith(path + '/')
return (
<nav className="navbar">
<Link to="/" className="navbar-brand">
Document Template Editor
</Link>
<div className="navbar-links">
<Link to="/templates" className={isActive('/templates') ? 'active' : ''}>
Templates
</Link>
<Link to="/documents" className={isActive('/documents') ? 'active' : ''}>
Documents
</Link>
{user?.role === 'admin' && (
<Link to="/users" className={isActive('/users') ? 'active' : ''}>
Users
</Link>
)}
{user && (
<>
<span>
{user.username}{' '}
<span className={`badge badge-${user.role}`}>{user.role}</span>
</span>
<button className="btn btn-secondary btn-sm" onClick={logout}>
Logout
</button>
</>
)}
</div>
</nav>
)
}

View File

@@ -0,0 +1,80 @@
import type { TemplateVariable } from '../types'
import VariableField from './VariableField'
interface TableRowEditorProps {
tableVariable: TemplateVariable
childVariables: TemplateVariable[]
rows: Record<string, unknown>[]
onChange: (rows: Record<string, unknown>[]) => void
}
export default function TableRowEditor({
tableVariable,
childVariables,
rows,
onChange,
}: TableRowEditorProps) {
const addRow = () => {
const newRow: Record<string, unknown> = {}
childVariables.forEach((v) => {
newRow[v.name] = v.default_value ?? ''
})
onChange([...rows, newRow])
}
const removeRow = (index: number) => {
onChange(rows.filter((_, i) => i !== index))
}
const updateRowField = (rowIndex: number, field: string, value: unknown) => {
const updated = rows.map((row, i) =>
i === rowIndex ? { ...row, [field]: value } : row,
)
onChange(updated)
}
const tableStyle = tableVariable.style_params?.table_style as
| { row_styles?: Record<string, unknown>[][] }
| undefined
return (
<div className="form-group">
<label>{tableVariable.label} (repeating table rows)</label>
{tableStyle && (
<div className="style-hint">
Table styles preserved from template ({tableStyle.row_styles?.length ?? 0} reference
rows)
</div>
)}
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="table-row-editor">
<div className="table-row-editor-header">
<strong>Row {rowIndex + 1}</strong>
<button
type="button"
className="btn btn-danger btn-sm"
onClick={() => removeRow(rowIndex)}
>
Remove
</button>
</div>
<div className="table-row-fields">
{childVariables.map((child) => (
<VariableField
key={child.id}
variable={child}
value={row[child.name]}
onChange={(val) => updateRowField(rowIndex, child.name, val)}
/>
))}
</div>
</div>
))}
<button type="button" className="btn btn-secondary" onClick={addRow}>
+ Add Row
</button>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import type { TemplateVariable } from '../types'
interface VariableFieldProps {
variable: TemplateVariable
value: unknown
onChange: (value: unknown) => void
}
export default function VariableField({ variable, value, onChange }: VariableFieldProps) {
const styleHint = variable.style_params
? [
variable.style_params.font_name && `Font: ${variable.style_params.font_name}`,
variable.style_params.font_size && `Size: ${variable.style_params.font_size}pt`,
variable.style_params.bold && 'Bold',
variable.style_params.alignment && `Align: ${variable.style_params.alignment}`,
]
.filter(Boolean)
.join(' · ')
: null
const renderInput = () => {
switch (variable.field_type) {
case 'textarea':
return (
<textarea
value={(value as string) ?? ''}
onChange={(e) => onChange(e.target.value)}
required={variable.is_required}
/>
)
case 'number':
return (
<input
type="number"
value={(value as number) ?? ''}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : '')}
required={variable.is_required}
/>
)
case 'date':
return (
<input
type="date"
value={(value as string) ?? ''}
onChange={(e) => onChange(e.target.value)}
required={variable.is_required}
/>
)
default:
return (
<input
type="text"
value={(value as string) ?? ''}
onChange={(e) => onChange(e.target.value)}
required={variable.is_required}
/>
)
}
}
return (
<div className="form-group">
<label>
{variable.label}
{variable.is_required && ' *'}
</label>
{renderInput()}
{styleHint && <div className="style-hint">Style: {styleHint}</div>}
</div>
)
}

View File

@@ -0,0 +1,83 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react'
import { api } from '../api'
import type { User } from '../types'
interface AuthContextType {
user: User | null
loading: boolean
login: (username: string, password: string) => Promise<void>
register: (email: string, username: string, password: string, confirmPassword: string) => Promise<{ message: string; email: string }>
logout: () => void
}
const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const loadUser = useCallback(async () => {
const token = localStorage.getItem('token')
if (!token) {
setLoading(false)
return
}
try {
const me = await api.getMe()
setUser(me)
} catch {
localStorage.removeItem('token')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadUser()
}, [loadUser])
const login = async (username: string, password: string) => {
const { access_token } = await api.login(username, password)
localStorage.setItem('token', access_token)
const me = await api.getMe()
setUser(me)
}
const register = async (
email: string,
username: string,
password: string,
confirmPassword: string,
) => {
return api.register({
email,
username,
password,
confirm_password: confirmPassword,
})
}
const logout = () => {
localStorage.removeItem('token')
setUser(null)
}
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}

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

@@ -0,0 +1,380 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
:root {
--bg: #f4f6f9;
--surface: #ffffff;
--border: #dde3ec;
--text: #1a2332;
--muted: #5c6b7f;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--danger: #dc2626;
--success: #16a34a;
--radius: 10px;
--shadow: 0 4px 20px rgba(15, 23, 42, 0.08);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--text);
background: var(--bg);
}
body {
margin: 0;
min-height: 100vh;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button,
input,
select,
textarea {
font: inherit;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 18px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: background 0.15s, transform 0.1s;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
}
.btn-secondary {
background: #e8eef7;
color: var(--text);
}
.btn-secondary:hover {
background: #d8e3f3;
}
.btn-danger {
background: #fee2e2;
color: var(--danger);
}
.btn-danger:hover {
background: #fecaca;
}
.btn-sm {
padding: 6px 12px;
font-size: 0.875rem;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 600;
font-size: 0.9rem;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: white;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: 2px solid #93c5fd;
border-color: var(--primary);
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.error {
color: var(--danger);
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 16px;
}
.success {
color: var(--success);
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 16px;
}
.navbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.navbar-brand {
font-weight: 700;
font-size: 1.1rem;
color: var(--text);
}
.navbar-links {
display: flex;
align-items: center;
gap: 16px;
}
.navbar-links a {
color: var(--muted);
font-weight: 500;
}
.navbar-links a.active {
color: var(--primary);
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-admin {
background: #dbeafe;
color: #1d4ed8;
}
.badge-user {
background: #e5e7eb;
color: #374151;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 12px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.table th {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
.doc-preview {
background: white;
padding: 32px;
border: 1px solid var(--border);
border-radius: var(--radius);
min-height: 400px;
}
.doc-preview .doc-table {
border-collapse: collapse;
width: 100%;
table-layout: fixed;
margin: 10px 0;
}
.doc-preview .doc-table td,
.doc-preview .doc-table th {
border: 1px solid #333;
padding: 4px 8px;
vertical-align: top;
word-wrap: break-word;
overflow-wrap: break-word;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
gap: 16px;
}
.page-header h1 {
margin: 0;
font-size: 1.6rem;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--muted);
}
.style-hint {
font-size: 0.8rem;
color: var(--muted);
margin-top: 4px;
}
.table-row-editor {
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
background: #fafbfc;
}
.table-row-editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.table-row-fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.auth-card {
width: 100%;
max-width: 420px;
}
.auth-card h1 {
margin: 0 0 8px;
font-size: 1.5rem;
}
.auth-card p {
margin: 0 0 24px;
color: var(--muted);
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.tab {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: white;
cursor: pointer;
font-weight: 500;
}
.tab.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.steps {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.step {
flex: 1;
text-align: center;
padding: 10px;
border-radius: 8px;
background: #e8eef7;
font-size: 0.85rem;
font-weight: 600;
color: var(--muted);
}
.step.active {
background: var(--primary);
color: white;
}
.step.done {
background: #dcfce7;
color: var(--success);
}

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

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

View File

@@ -0,0 +1,92 @@
import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { api } from '../api'
export default function ActivatePage() {
const navigate = useNavigate()
const location = useLocation()
const initialEmail = (location.state as { email?: string } | null)?.email ?? ''
const [email, setEmail] = useState(initialEmail)
const [code, setCode] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [loading, setLoading] = useState(false)
const handleActivate = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccess('')
setLoading(true)
try {
const result = await api.activateAccount(email, code)
setSuccess(result.message)
setTimeout(() => navigate('/login'), 1500)
} catch (err) {
setError(err instanceof Error ? err.message : 'Activation failed')
} finally {
setLoading(false)
}
}
const handleResend = async () => {
setError('')
setSuccess('')
try {
const result = await api.resendActivation(email)
setSuccess(result.message)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to resend code')
}
}
return (
<div className="auth-page">
<div className="card auth-card">
<h1>Activate Account</h1>
<p>Enter the activation code sent to your email.</p>
{error && <div className="error">{error}</div>}
{success && <div className="success">{success}</div>}
<form onSubmit={handleActivate}>
<div className="form-group">
<label>Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Activation Code</label>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
required
placeholder="6-digit code"
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Please wait...' : 'Activate'}
</button>
</form>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%', marginTop: 12 }}
onClick={handleResend}
disabled={!email}
>
Resend Code
</button>
<p style={{ marginTop: 16 }}>
<Link to="/login">Back to login</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,105 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
export default function CreateTemplatePage() {
const navigate = useNavigate()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [file, setFile] = useState<File | null>(null)
const [isPublic, setIsPublic] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!file) {
setError('Please select a .docx file')
return
}
setError('')
setLoading(true)
try {
const template = await api.createTemplate(
file,
name,
description || undefined,
isPublic,
)
navigate(`/templates/${template.id}/fill`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed')
} finally {
setLoading(false)
}
}
return (
<div className="container">
<div className="page-header">
<h1>Upload Template</h1>
</div>
<div className="steps">
<div className="step active">1. Upload .docx</div>
<div className="step">2. Fill Variables</div>
<div className="step">3. Preview & Export</div>
</div>
<div className="card" style={{ maxWidth: 600 }}>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Template Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="form-group">
<label>Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
<div className="form-group">
<label>.docx File with Jinja2 Variables *</label>
<input
type="file"
accept=".docx"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
required
/>
<div className="style-hint" style={{ marginTop: 8 }}>
Use {'{{ variable }}'} for text fields. For repeating table rows use{' '}
{'{%tr for item in items %}'} ... {'{%tr endfor %}'}.
</div>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
style={{ width: 'auto', marginRight: 8 }}
/>
Make template public (visible to all users)
</label>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Parsing...' : 'Upload & Continue'}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate('/templates')}
>
Cancel
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, downloadWithAuth } from '../api'
import { useAuth } from '../context/AuthContext'
import type { DocumentTemplate, FilledDocument } from '../types'
export default function DocumentDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { user } = useAuth()
const [document, setDocument] = useState<FilledDocument | null>(null)
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
const load = async () => {
try {
const doc = await api.getDocument(Number(id))
setDocument(doc)
const t = await api.getTemplate(doc.template_id)
setTemplate(t)
const preview = await api.previewDocument({
template_id: doc.template_id,
name: doc.name,
field_data: doc.field_data,
})
setPreviewHtml(preview.html)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load')
} finally {
setLoading(false)
}
}
load()
}, [id])
const handleExportDocx = async () => {
if (!document) return
try {
await downloadWithAuth(
api.getExportDocxUrl(document.id),
`${document.name}.docx`,
)
} catch (err) {
alert(err instanceof Error ? err.message : 'Export failed')
}
}
const handleExportPdf = async () => {
if (!document) return
try {
await downloadWithAuth(
api.getExportPdfUrl(document.id),
`${document.name}.pdf`,
)
} catch (err) {
alert(err instanceof Error ? err.message : 'PDF export failed. Install LibreOffice for PDF support.')
}
}
const handleDelete = async () => {
if (!document || !confirm(`Delete document "${document.name}"?`)) return
try {
await api.deleteDocument(document.id)
navigate('/documents')
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed')
}
}
const canManage =
user?.role === 'admin' || document?.owner_id === user?.id
if (loading) return <div className="container"><p>Loading...</p></div>
if (!document) return <div className="container"><div className="error">{error}</div></div>
return (
<div className="container">
<div className="page-header">
<div>
<h1>{document.name}</h1>
{template && (
<div className="style-hint">
From template: {template.name}
{document.owner_username && ` · Owner: ${document.owner_username}`}
{' · '}Saved {new Date(document.created_at).toLocaleString()}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={handleExportDocx}>
Export DOCX
</button>
<button className="btn btn-secondary" onClick={handleExportPdf}>
Export PDF
</button>
<Link to={`/documents/${document.id}/edit`} className="btn btn-secondary">
Edit
</Link>
{template && (
<Link to={`/templates/${template.id}/fill`} className="btn btn-secondary">
Reuse Template
</Link>
)}
{canManage && (
<button className="btn btn-danger" onClick={handleDelete}>
Delete
</button>
)}
</div>
</div>
{error && <div className="error">{error}</div>}
<div className="grid-2">
<div className="card">
<h2 style={{ marginTop: 0 }}>Preview</h2>
{previewHtml ? (
<div
className="doc-preview"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
) : (
<p>Preview unavailable</p>
)}
</div>
<div className="card">
<h2 style={{ marginTop: 0 }}>Field Data</h2>
<pre style={{
background: '#f8fafc',
padding: 16,
borderRadius: 8,
overflow: 'auto',
fontSize: '0.85rem',
}}>
{JSON.stringify(document.field_data, null, 2)}
</pre>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../context/AuthContext'
import type { FilledDocument } from '../types'
export default function DocumentsPage() {
const { user } = useAuth()
const [documents, setDocuments] = useState<FilledDocument[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = () => {
api.listDocuments()
.then(setDocuments)
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load'))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [])
const handleDelete = async (doc: FilledDocument) => {
if (!confirm(`Delete document "${doc.name}"?`)) return
try {
await api.deleteDocument(doc.id)
setDocuments((list) => list.filter((d) => d.id !== doc.id))
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed')
}
}
const isAdmin = user?.role === 'admin'
return (
<div className="container">
<div className="page-header">
<h1>Documents</h1>
<Link to="/templates" className="btn btn-primary">
Create from Template
</Link>
</div>
{error && <div className="error">{error}</div>}
{loading ? (
<p>Loading...</p>
) : documents.length === 0 ? (
<div className="card empty-state">
<p>No filled documents yet. Choose a template and fill in the variables.</p>
</div>
) : (
<div className="card">
<table className="table">
<thead>
<tr>
<th>Name</th>
{isAdmin && <th>Owner</th>}
<th>Template ID</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{documents.map((d) => (
<tr key={d.id}>
<td><strong>{d.name}</strong></td>
{isAdmin && <td>{d.owner_username ?? `#${d.owner_id}`}</td>}
<td>{d.template_id}</td>
<td>{new Date(d.created_at).toLocaleString()}</td>
<td style={{ display: 'flex', gap: 8 }}>
<Link to={`/documents/${d.id}`} className="btn btn-primary btn-sm">
View
</Link>
<Link to={`/documents/${d.id}/edit`} className="btn btn-secondary btn-sm">
Edit
</Link>
{(isAdmin || d.owner_id === user?.id) && (
<button
className="btn btn-danger btn-sm"
onClick={() => handleDelete(d)}
>
Delete
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,179 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import TableRowEditor from '../components/TableRowEditor'
import VariableField from '../components/VariableField'
import type { DocumentTemplate } from '../types'
export default function EditDocumentPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
const [fieldData, setFieldData] = useState<Record<string, unknown>>({})
const [documentName, setDocumentName] = useState('')
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
const [step, setStep] = useState<2 | 3>(2)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
const load = async () => {
try {
const doc = await api.getDocument(Number(id))
const t = await api.getTemplate(doc.template_id)
setTemplate(t)
setDocumentName(doc.name)
setFieldData(doc.field_data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load document')
} finally {
setLoading(false)
}
}
load()
}, [id])
const topLevelVars = useMemo(
() => template?.variables.filter((v) => !v.parent_variable) ?? [],
[template],
)
const childVarsByParent = useMemo(() => {
const map: Record<string, typeof topLevelVars> = {}
template?.variables
.filter((v) => v.parent_variable)
.forEach((v) => {
const parent = v.parent_variable!
if (!map[parent]) map[parent] = []
map[parent].push(v)
})
return map
}, [template, topLevelVars])
const handlePreview = async () => {
if (!template) return
setError('')
setSubmitting(true)
try {
const result = await api.previewDocument({
template_id: template.id,
name: documentName,
field_data: fieldData,
})
setPreviewHtml(result.html)
setStep(3)
} catch (err) {
setError(err instanceof Error ? err.message : 'Preview failed')
} finally {
setSubmitting(false)
}
}
const handleSave = async () => {
if (!template) return
setError('')
setSubmitting(true)
try {
const doc = await api.updateDocument(Number(id), {
name: documentName,
field_data: fieldData,
})
navigate(`/documents/${doc.id}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSubmitting(false)
}
}
if (loading) return <div className="container"><p>Loading...</p></div>
if (!template) return <div className="container"><div className="error">{error || 'Not found'}</div></div>
return (
<div className="container">
<div className="page-header">
<div>
<h1>Edit Document</h1>
<div className="style-hint">Template: {template.name}</div>
</div>
<Link to={`/documents/${id}`} className="btn btn-secondary">Back</Link>
</div>
{error && <div className="error">{error}</div>}
{step === 2 && (
<div className="card" style={{ maxWidth: 720 }}>
<div className="form-group">
<label>Document Name *</label>
<input
value={documentName}
onChange={(e) => setDocumentName(e.target.value)}
required
/>
</div>
{topLevelVars.map((variable) => {
if (variable.field_type === 'table_row') {
const children = childVarsByParent[variable.name] ?? []
return (
<TableRowEditor
key={variable.id}
tableVariable={variable}
childVariables={children}
rows={(fieldData[variable.name] as Record<string, unknown>[]) ?? []}
onChange={(rows) =>
setFieldData((prev) => ({ ...prev, [variable.name]: rows }))
}
/>
)
}
return (
<VariableField
key={variable.id}
variable={variable}
value={fieldData[variable.name]}
onChange={(val) =>
setFieldData((prev) => ({ ...prev, [variable.name]: val }))
}
/>
)
})}
<div style={{ display: 'flex', gap: 12, marginTop: 24 }}>
<button
className="btn btn-primary"
onClick={handlePreview}
disabled={submitting}
>
{submitting ? 'Rendering...' : 'Preview'}
</button>
</div>
</div>
)}
{step === 3 && previewHtml && (
<div>
<div className="card" style={{ marginBottom: 24 }}>
<div
className="doc-preview"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn-secondary" onClick={() => setStep(2)}>
Edit Fields
</button>
<button
className="btn btn-primary"
onClick={handleSave}
disabled={submitting}
>
{submitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,226 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import TableRowEditor from '../components/TableRowEditor'
import VariableField from '../components/VariableField'
import type { DocumentTemplate } from '../types'
export default function FillTemplatePage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
const [fieldData, setFieldData] = useState<Record<string, unknown>>({})
const [documentName, setDocumentName] = useState('')
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
const [step, setStep] = useState<1 | 2 | 3>(2)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
const load = async () => {
try {
const t = await api.getTemplate(Number(id))
setTemplate(t)
setDocumentName(`${t.name} - ${new Date().toLocaleDateString()}`)
const initial: Record<string, unknown> = {}
t.variables.forEach((v) => {
if (v.field_type === 'table_row') {
initial[v.name] = []
} else if (!v.parent_variable) {
initial[v.name] = v.default_value ?? ''
}
})
setFieldData(initial)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load template')
} finally {
setLoading(false)
}
}
load()
}, [id])
const topLevelVars = useMemo(
() => template?.variables.filter((v) => !v.parent_variable) ?? [],
[template],
)
const childVarsByParent = useMemo(() => {
const map: Record<string, typeof topLevelVars> = {}
template?.variables
.filter((v) => v.parent_variable)
.forEach((v) => {
const parent = v.parent_variable!
if (!map[parent]) map[parent] = []
map[parent].push(v)
})
return map
}, [template, topLevelVars])
const handlePreview = async () => {
if (!template) return
setError('')
setSubmitting(true)
try {
const result = await api.previewDocument({
template_id: template.id,
name: documentName,
field_data: fieldData,
})
setPreviewHtml(result.html)
setStep(3)
} catch (err) {
setError(err instanceof Error ? err.message : 'Preview failed')
} finally {
setSubmitting(false)
}
}
const handleSave = async () => {
if (!template) return
setError('')
setSubmitting(true)
try {
const doc = await api.createDocument({
template_id: template.id,
name: documentName,
field_data: fieldData,
})
navigate(`/documents/${doc.id}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSubmitting(false)
}
}
if (loading) return <div className="container"><p>Loading...</p></div>
if (!template) return <div className="container"><div className="error">{error || 'Not found'}</div></div>
return (
<div className="container">
<div className="page-header">
<div>
<h1>Fill: {template.name}</h1>
<div className="style-hint">Reusable template · {template.variables.length} variables detected</div>
</div>
<Link to="/templates" className="btn btn-secondary">Back</Link>
</div>
<div className="steps">
<div className="step done">1. Upload .docx</div>
<div className={`step ${step === 2 ? 'active' : step > 2 ? 'done' : ''}`}>2. Fill Variables</div>
<div className={`step ${step === 3 ? 'active' : ''}`}>3. Preview & Export</div>
</div>
{error && <div className="error">{error}</div>}
{step === 2 && (
<div className="grid-2">
<div className="card">
<h2 style={{ marginTop: 0 }}>Document Fields</h2>
<div className="form-group">
<label>Document Name *</label>
<input
value={documentName}
onChange={(e) => setDocumentName(e.target.value)}
required
/>
</div>
{topLevelVars.map((variable) => {
if (variable.field_type === 'table_row') {
const children = childVarsByParent[variable.name] ?? []
return (
<TableRowEditor
key={variable.id}
tableVariable={variable}
childVariables={children}
rows={(fieldData[variable.name] as Record<string, unknown>[]) ?? []}
onChange={(rows) =>
setFieldData((prev) => ({ ...prev, [variable.name]: rows }))
}
/>
)
}
return (
<VariableField
key={variable.id}
variable={variable}
value={fieldData[variable.name]}
onChange={(val) =>
setFieldData((prev) => ({ ...prev, [variable.name]: val }))
}
/>
)
})}
<div style={{ display: 'flex', gap: 12, marginTop: 24 }}>
<button
className="btn btn-primary"
onClick={handlePreview}
disabled={submitting}
>
{submitting ? 'Rendering...' : 'Preview Document'}
</button>
</div>
</div>
<div className="card">
<h2 style={{ marginTop: 0 }}>Template Info</h2>
<p><strong>File:</strong> {template.original_filename}</p>
{template.description && <p>{template.description}</p>}
<h3>Detected Variables</h3>
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Style</th>
</tr>
</thead>
<tbody>
{template.variables.map((v) => (
<tr key={v.id}>
<td>{v.name}</td>
<td>{v.field_type}</td>
<td className="style-hint">
{v.style_params?.font_name ?? '—'}
{v.style_params?.alignment ? ` / ${v.style_params.alignment}` : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{step === 3 && previewHtml && (
<div>
<div className="card" style={{ marginBottom: 24 }}>
<div
className="doc-preview"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn-secondary" onClick={() => setStep(2)}>
Edit Fields
</button>
<button
className="btn btn-primary"
onClick={handleSave}
disabled={submitting}
>
{submitting ? 'Saving...' : 'Save & Export'}
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,59 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccess('')
setLoading(true)
try {
const result = await api.forgotPassword(email)
setSuccess(result.message)
} catch (err) {
setError(err instanceof Error ? err.message : 'Request failed')
} finally {
setLoading(false)
}
}
return (
<div className="auth-page">
<div className="card auth-card">
<h1>Forgot Password</h1>
<p>Enter your email and we will send a reset code.</p>
{error && <div className="error">{error}</div>}
{success && <div className="success">{success}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Please wait...' : 'Send Reset Code'}
</button>
</form>
<p style={{ marginTop: 16 }}>
Have a code? <Link to="/reset-password">Reset password</Link>
</p>
<p>
<Link to="/login">Back to login</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,72 @@
import { useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function LoginPage() {
const { user, login } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
if (user) return <Navigate to="/templates" replace />
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(username, password)
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="auth-page">
<div className="card auth-card">
<h1>Document Template Editor</h1>
<p>Upload Jinja2 .docx templates, fill variables, preview and export.</p>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Username</label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Please wait...' : 'Login'}
</button>
</form>
<div style={{ marginTop: 20, fontSize: '0.9rem' }}>
<p style={{ margin: '8px 0' }}>
<Link to="/register">Create account</Link>
</p>
<p style={{ margin: '8px 0' }}>
<Link to="/activate">Activate account</Link>
</p>
<p style={{ margin: '8px 0' }}>
<Link to="/forgot-password">Forgot password?</Link>
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,99 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function RegisterPage() {
const { register } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccess('')
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
setLoading(true)
try {
const result = await register(email, username, password, confirmPassword)
setSuccess(result.message)
setTimeout(() => {
navigate('/activate', { state: { email: result.email } })
}, 1500)
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed')
} finally {
setLoading(false)
}
}
return (
<div className="auth-page">
<div className="card auth-card">
<h1>Create Account</h1>
<p>Register to use document templates. You will receive an activation code by email.</p>
{error && <div className="error">{error}</div>}
{success && <div className="success">{success}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Username</label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
/>
</div>
<div className="form-group">
<label>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<div className="form-group">
<label>Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Please wait...' : 'Register'}
</button>
</form>
<p style={{ marginTop: 16 }}>
Already have an account? <Link to="/login">Login</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,100 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { api } from '../api'
export default function ResetPasswordPage() {
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccess('')
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
setLoading(true)
try {
const result = await api.resetPassword({
email,
code,
password,
confirm_password: confirmPassword,
})
setSuccess(result.message)
setTimeout(() => navigate('/login'), 1500)
} catch (err) {
setError(err instanceof Error ? err.message : 'Reset failed')
} finally {
setLoading(false)
}
}
return (
<div className="auth-page">
<div className="card auth-card">
<h1>Reset Password</h1>
<p>Enter the code from your email and choose a new password.</p>
{error && <div className="error">{error}</div>}
{success && <div className="success">{success}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Reset Code</label>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>New Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<div className="form-group">
<label>Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Please wait...' : 'Update Password'}
</button>
</form>
<p style={{ marginTop: 16 }}>
<Link to="/login">Back to login</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { api, downloadWithAuth } from '../api'
import { useAuth } from '../context/AuthContext'
import type { DocumentTemplate } from '../types'
export default function TemplateDetailPage() {
const { id } = useParams<{ id: string }>()
const { user } = useAuth()
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
api.getTemplate(Number(id))
.then(setTemplate)
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load'))
.finally(() => setLoading(false))
}, [id])
const handleTogglePublic = async () => {
if (!template) return
try {
const updated = await api.updateTemplate(template.id, {
is_public: !template.is_public,
})
setTemplate(updated)
} catch (err) {
alert(err instanceof Error ? err.message : 'Update failed')
}
}
const handleDownloadSource = async () => {
if (!template) return
try {
await downloadWithAuth(
api.getTemplateSourceUrl(template.id),
template.original_filename,
)
} catch (err) {
alert(err instanceof Error ? err.message : 'Download failed')
}
}
if (loading) return <div className="container"><p>Loading...</p></div>
if (!template) return <div className="container"><div className="error">{error}</div></div>
const canManage = template.is_owner || user?.role === 'admin'
return (
<div className="container">
<div className="page-header">
<h1>{template.name}</h1>
<div style={{ display: 'flex', gap: 8 }}>
<Link to={`/templates/${template.id}/fill`} className="btn btn-primary">
Use Template
</Link>
<button className="btn btn-secondary" onClick={handleDownloadSource}>
Download Source
</button>
{canManage && (
<button className="btn btn-secondary" onClick={handleTogglePublic}>
{template.is_public ? 'Make Private' : 'Make Public'}
</button>
)}
</div>
</div>
<div className="card">
<p><strong>File:</strong> {template.original_filename}</p>
<p><strong>Owner:</strong> {template.owner_username ?? `#${template.owner_id}`}</p>
<p>
<strong>Visibility:</strong>{' '}
{template.is_public ? 'Public (visible to all users)' : 'Private'}
</p>
{template.description && <p>{template.description}</p>}
<p><strong>Created:</strong> {new Date(template.created_at).toLocaleString()}</p>
<h2>Variables ({template.variables.length})</h2>
<table className="table">
<thead>
<tr>
<th>Label</th>
<th>Name</th>
<th>Type</th>
<th>Required</th>
<th>Style Parameters</th>
</tr>
</thead>
<tbody>
{template.variables.map((v) => (
<tr key={v.id}>
<td>{v.label}</td>
<td><code>{v.name}</code></td>
<td>{v.field_type}</td>
<td>{v.is_required ? 'Yes' : 'No'}</td>
<td className="style-hint">
{v.style_params
? JSON.stringify(v.style_params, null, 0).slice(0, 80) + '...'
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,160 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api, downloadWithAuth } from '../api'
import { useAuth } from '../context/AuthContext'
import type { TemplateListItem } from '../types'
export default function TemplatesPage() {
const { user } = useAuth()
const [templates, setTemplates] = useState<TemplateListItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = async () => {
try {
setTemplates(await api.listTemplates())
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load templates')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const handleDelete = async (t: TemplateListItem) => {
if (!confirm(`Delete template "${t.name}"?`)) return
try {
await api.deleteTemplate(t.id)
setTemplates((list) => list.filter((x) => x.id !== t.id))
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed')
}
}
const handleTogglePublic = async (t: TemplateListItem) => {
try {
const updated = await api.updateTemplate(t.id, { is_public: !t.is_public })
setTemplates((list) =>
list.map((x) =>
x.id === t.id
? { ...x, is_public: updated.is_public }
: x,
),
)
} catch (err) {
alert(err instanceof Error ? err.message : 'Update failed')
}
}
const handleDownloadSource = async (t: TemplateListItem) => {
try {
await downloadWithAuth(
api.getTemplateSourceUrl(t.id),
t.original_filename,
)
} catch (err) {
alert(err instanceof Error ? err.message : 'Download failed')
}
}
const canManage = (t: TemplateListItem) =>
t.is_owner || user?.role === 'admin'
return (
<div className="container">
<div className="page-header">
<h1>Templates</h1>
<Link to="/templates/new" className="btn btn-primary">
+ Upload Template
</Link>
</div>
{error && <div className="error">{error}</div>}
{loading ? (
<p>Loading...</p>
) : templates.length === 0 ? (
<div className="card empty-state">
<p>No templates yet. Upload a .docx file with Jinja2 variables to get started.</p>
<Link to="/templates/new" className="btn btn-primary" style={{ marginTop: 16 }}>
Upload Template
</Link>
</div>
) : (
<div className="card">
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>File</th>
<th>Owner</th>
<th>Visibility</th>
<th>Variables</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{templates.map((t) => (
<tr key={t.id}>
<td>
<strong>{t.name}</strong>
{t.description && (
<div className="style-hint">{t.description}</div>
)}
</td>
<td>{t.original_filename}</td>
<td>{t.owner_username ?? `#${t.owner_id}`}</td>
<td>
{t.is_public ? (
<span className="badge" style={{ background: '#dbeafe', color: '#1d4ed8' }}>
public
</span>
) : (
<span className="badge badge-user">private</span>
)}
</td>
<td>{t.variable_count}</td>
<td>{new Date(t.created_at).toLocaleDateString()}</td>
<td style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Link to={`/templates/${t.id}/fill`} className="btn btn-primary btn-sm">
Fill
</Link>
<Link to={`/templates/${t.id}`} className="btn btn-secondary btn-sm">
View
</Link>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleDownloadSource(t)}
>
Source
</button>
{canManage(t) && (
<>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleTogglePublic(t)}
>
{t.is_public ? 'Make Private' : 'Make Public'}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDelete(t)}
>
Delete
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,307 @@
import { useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../context/AuthContext'
import type { User } from '../types'
export default function UsersPage() {
const { user: currentUser } = useAuth()
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showForm, setShowForm] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [form, setForm] = useState({
email: '',
username: '',
password: '',
role: 'user' as 'user' | 'admin',
})
const [editForm, setEditForm] = useState({
email: '',
username: '',
password: '',
role: 'user' as 'user' | 'admin',
is_active: true,
})
const load = async () => {
try {
setUsers(await api.listUsers())
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load users')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
try {
await api.createUser(form)
setForm({ email: '', username: '', password: '', role: 'user' })
setShowForm(false)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create user')
}
}
const openEdit = (u: User) => {
setEditingUser(u)
setEditForm({
email: u.email,
username: u.username,
password: '',
role: u.role,
is_active: u.is_active,
})
}
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault()
if (!editingUser) return
setError('')
try {
await api.updateUser(editingUser.id, {
email: editForm.email,
username: editForm.username,
role: editForm.role,
is_active: editForm.is_active,
...(editForm.password ? { password: editForm.password } : {}),
})
setEditingUser(null)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Update failed')
}
}
const handleToggleActive = async (u: User) => {
try {
await api.updateUser(u.id, { is_active: !u.is_active })
await load()
} catch (err) {
alert(err instanceof Error ? err.message : 'Update failed')
}
}
const handleDelete = async (u: User) => {
if (!confirm(`Delete user "${u.username}"?`)) return
try {
await api.deleteUser(u.id)
await load()
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed')
}
}
if (currentUser?.role !== 'admin') {
return <Navigate to="/templates" replace />
}
return (
<div className="container">
<div className="page-header">
<h1>User Management</h1>
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ Add User'}
</button>
</div>
{error && <div className="error">{error}</div>}
{showForm && (
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
<h2 style={{ marginTop: 0 }}>Create User</h2>
<form onSubmit={handleCreate}>
<div className="form-group">
<label>Email</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
/>
</div>
<div className="form-group">
<label>Username</label>
<input
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
required
minLength={3}
/>
</div>
<div className="form-group">
<label>Password</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
minLength={6}
/>
</div>
<div className="form-group">
<label>Role</label>
<select
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as 'user' | 'admin' })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit" className="btn btn-primary">Create</button>
</form>
</div>
)}
{editingUser && (
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
<h2 style={{ marginTop: 0 }}>Edit User: {editingUser.username}</h2>
<form onSubmit={handleEdit}>
<div className="form-group">
<label>Email</label>
<input
type="email"
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
required
/>
</div>
<div className="form-group">
<label>Username</label>
<input
value={editForm.username}
onChange={(e) => setEditForm({ ...editForm, username: e.target.value })}
required
minLength={3}
/>
</div>
<div className="form-group">
<label>New Password (leave empty to keep)</label>
<input
type="password"
value={editForm.password}
onChange={(e) => setEditForm({ ...editForm, password: e.target.value })}
minLength={6}
/>
</div>
<div className="form-group">
<label>Role</label>
<select
value={editForm.role}
onChange={(e) =>
setEditForm({ ...editForm, role: e.target.value as 'user' | 'admin' })
}
disabled={editingUser.id === currentUser?.id}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={editForm.is_active}
onChange={(e) =>
setEditForm({ ...editForm, is_active: e.target.checked })
}
disabled={editingUser.id === currentUser?.id}
style={{ width: 'auto', marginRight: 8 }}
/>
Active
</label>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary">Save</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setEditingUser(null)}
>
Cancel
</button>
</div>
</form>
</div>
)}
{loading ? (
<p>Loading...</p>
) : (
<div className="card">
<table className="table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<strong>{u.username}</strong>
{u.id === currentUser?.id && (
<span className="style-hint"> (you)</span>
)}
</td>
<td>{u.email}</td>
<td>{u.role}</td>
<td>
<span
className={`badge badge-${u.is_active ? 'user' : 'admin'}`}
style={
u.is_active
? { background: '#dcfce7', color: '#16a34a' }
: { background: '#fee2e2', color: '#dc2626' }
}
>
{u.is_active ? 'active' : 'inactive'}
</span>
</td>
<td>{new Date(u.created_at).toLocaleDateString()}</td>
<td style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
className="btn btn-primary btn-sm"
onClick={() => openEdit(u)}
>
Edit
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleToggleActive(u)}
disabled={u.id === currentUser?.id}
>
{u.is_active ? 'Deactivate' : 'Activate'}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDelete(u)}
disabled={u.id === currentUser?.id}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

80
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,80 @@
export type UserRole = 'user' | 'admin'
export interface User {
id: number
email: string
username: string
role: UserRole
is_active: boolean
created_at: string
}
export interface StyleParams {
font_name?: string
font_size?: number
bold?: boolean
italic?: boolean
underline?: boolean
color?: string
alignment?: string
background_color?: string
border_style?: string
width?: number
height?: number
table_style?: Record<string, unknown>
is_repeating_table?: boolean
}
export interface TemplateVariable {
id: number
name: string
label: string
field_type: string
default_value?: string | null
is_required: boolean
order: number
parent_variable?: string | null
style_params?: StyleParams | null
}
export interface DocumentTemplate {
id: number
name: string
description?: string | null
original_filename: string
owner_id: number
owner_username?: string | null
is_public: boolean
is_owner: boolean
created_at: string
updated_at: string
variables: TemplateVariable[]
}
export interface TemplateListItem {
id: number
name: string
description?: string | null
original_filename: string
owner_id: number
owner_username?: string | null
is_public: boolean
is_owner: boolean
created_at: string
variable_count: number
}
export interface FilledDocument {
id: number
template_id: number
owner_id: number
owner_username?: string | null
name: string
field_data: Record<string, unknown>
created_at: string
}
export interface PreviewResponse {
html: string
field_data: Record<string, unknown>
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/Navbar.tsx","./src/components/TableRowEditor.tsx","./src/components/VariableField.tsx","./src/context/AuthContext.tsx","./src/pages/ActivatePage.tsx","./src/pages/CreateTemplatePage.tsx","./src/pages/DocumentDetailPage.tsx","./src/pages/DocumentsPage.tsx","./src/pages/EditDocumentPage.tsx","./src/pages/FillTemplatePage.tsx","./src/pages/ForgotPasswordPage.tsx","./src/pages/LoginPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/ResetPasswordPage.tsx","./src/pages/TemplateDetailPage.tsx","./src/pages/TemplatesPage.tsx","./src/pages/UsersPage.tsx"],"version":"5.9.3"}

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

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})