Feature - add app localization
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import Navbar from './components/Navbar'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { I18nProvider, useI18n } from './context/I18nContext'
|
||||
import CreateTemplatePage from './pages/CreateTemplatePage'
|
||||
import DocumentDetailPage from './pages/DocumentDetailPage'
|
||||
import DocumentsPage from './pages/DocumentsPage'
|
||||
@@ -18,7 +19,8 @@ import LogsPage from './pages/LogsPage'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return <div className="container"><p>Loading...</p></div>
|
||||
const { t } = useI18n()
|
||||
if (loading) return <div className="container"><p>{t('common.loading')}</p></div>
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -53,9 +55,11 @@ function AppRoutes() {
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<I18nProvider>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</I18nProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
13
frontend/src/components/AuthShell.tsx
Normal file
13
frontend/src/components/AuthShell.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import LanguageSwitcher from './LanguageSwitcher'
|
||||
|
||||
export default function AuthShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-lang-switcher">
|
||||
<LanguageSwitcher compact />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
@@ -13,12 +15,14 @@ export default function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
danger = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
@@ -36,14 +40,14 @@ export default function ConfirmDialog({
|
||||
<p style={{ color: 'var(--muted)', marginBottom: 24 }}>{message}</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
{cancelLabel ?? t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
{confirmLabel ?? t('common.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
27
frontend/src/components/LanguageSwitcher.tsx
Normal file
27
frontend/src/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import { LOCALES } from '../i18n'
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function LanguageSwitcher({ compact = false }: LanguageSwitcherProps) {
|
||||
const { locale, setLocale, t } = useI18n()
|
||||
|
||||
return (
|
||||
<label className={`lang-switcher${compact ? ' lang-switcher-compact' : ''}`}>
|
||||
{!compact && <span className="lang-switcher-label">{t('language.label')}</span>}
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as 'en' | 'ru')}
|
||||
aria-label={t('language.label')}
|
||||
>
|
||||
{LOCALES.map((item) => (
|
||||
<option key={item.code} value={item.code}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import LanguageSwitcher from './LanguageSwitcher'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
|
||||
export default function Navbar() {
|
||||
const { user, logout } = useAuth()
|
||||
const { t } = useI18n()
|
||||
const location = useLocation()
|
||||
|
||||
const isActive = (path: string) =>
|
||||
@@ -11,33 +14,34 @@ export default function Navbar() {
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<Link to="/" className="navbar-brand">
|
||||
Document Template Editor
|
||||
{t('nav.brand')}
|
||||
</Link>
|
||||
<div className="navbar-links">
|
||||
<Link to="/templates" className={isActive('/templates') ? 'active' : ''}>
|
||||
Templates
|
||||
{t('nav.templates')}
|
||||
</Link>
|
||||
<Link to="/documents" className={isActive('/documents') ? 'active' : ''}>
|
||||
Documents
|
||||
{t('nav.documents')}
|
||||
</Link>
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<Link to="/users" className={isActive('/users') ? 'active' : ''}>
|
||||
Users
|
||||
{t('nav.users')}
|
||||
</Link>
|
||||
<Link to="/logs" className={isActive('/logs') ? 'active' : ''}>
|
||||
Logs
|
||||
{t('nav.logs')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{user && (
|
||||
<>
|
||||
<LanguageSwitcher compact />
|
||||
<span>
|
||||
{user.username}{' '}
|
||||
<span className={`badge badge-${user.role}`}>{user.role}</span>
|
||||
<span className={`badge badge-${user.role}`}>{t(`roles.${user.role}`)}</span>
|
||||
</span>
|
||||
<button className="btn btn-secondary btn-sm" onClick={logout}>
|
||||
Logout
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TemplateVariable } from '../types'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import VariableField from './VariableField'
|
||||
|
||||
interface TableRowEditorProps {
|
||||
@@ -14,6 +15,8 @@ export default function TableRowEditor({
|
||||
rows,
|
||||
onChange,
|
||||
}: TableRowEditorProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const addRow = () => {
|
||||
const newRow: Record<string, unknown> = {}
|
||||
childVariables.forEach((v) => {
|
||||
@@ -39,24 +42,27 @@ export default function TableRowEditor({
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label>{tableVariable.label} (repeating table rows)</label>
|
||||
<label>
|
||||
{tableVariable.label} {t('tableRow.repeatingRows')}
|
||||
</label>
|
||||
{tableStyle && (
|
||||
<div className="style-hint">
|
||||
Table styles preserved from template ({tableStyle.row_styles?.length ?? 0} reference
|
||||
rows)
|
||||
{t('tableRow.stylesPreserved', {
|
||||
count: tableStyle.row_styles?.length ?? 0,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="table-row-editor">
|
||||
<div className="table-row-editor-header">
|
||||
<strong>Row {rowIndex + 1}</strong>
|
||||
<strong>{t('tableRow.row', { index: rowIndex + 1 })}</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => removeRow(rowIndex)}
|
||||
>
|
||||
Remove
|
||||
{t('tableRow.remove')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="table-row-fields">
|
||||
@@ -73,7 +79,7 @@ export default function TableRowEditor({
|
||||
))}
|
||||
|
||||
<button type="button" className="btn btn-secondary" onClick={addRow}>
|
||||
+ Add Row
|
||||
{t('tableRow.addRow')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TemplateVariable } from '../types'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
|
||||
interface VariableFieldProps {
|
||||
variable: TemplateVariable
|
||||
@@ -7,12 +8,17 @@ interface VariableFieldProps {
|
||||
}
|
||||
|
||||
export default function VariableField({ variable, value, onChange }: VariableFieldProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
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}`,
|
||||
variable.style_params.font_name &&
|
||||
t('variableField.font', { name: variable.style_params.font_name }),
|
||||
variable.style_params.font_size &&
|
||||
t('variableField.size', { size: variable.style_params.font_size }),
|
||||
variable.style_params.bold && t('variableField.bold'),
|
||||
variable.style_params.alignment &&
|
||||
t('variableField.align', { align: variable.style_params.alignment }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
@@ -65,7 +71,9 @@ export default function VariableField({ variable, value, onChange }: VariableFie
|
||||
{variable.is_required && ' *'}
|
||||
</label>
|
||||
{renderInput()}
|
||||
{styleHint && <div className="style-hint">Style: {styleHint}</div>}
|
||||
{styleHint && (
|
||||
<div className="style-hint">{t('variableField.style', { hint: styleHint })}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
81
frontend/src/context/I18nContext.tsx
Normal file
81
frontend/src/context/I18nContext.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
interpolate,
|
||||
localeToIntl,
|
||||
LOCALE_STORAGE_KEY,
|
||||
messages,
|
||||
readStoredLocale,
|
||||
getNestedMessage,
|
||||
type Locale,
|
||||
} from '../i18n'
|
||||
|
||||
type TranslateVars = Record<string, string | number>
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale
|
||||
setLocale: (locale: Locale) => void
|
||||
t: (key: string, vars?: TranslateVars) => string
|
||||
formatDate: (value: string | Date) => string
|
||||
formatDateTime: (value: string | Date) => string
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null)
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<Locale>(readStoredLocale)
|
||||
|
||||
const setLocale = useCallback((next: Locale) => {
|
||||
setLocaleState(next)
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, next)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale
|
||||
}, [locale])
|
||||
|
||||
const intlLocale = localeToIntl(locale)
|
||||
|
||||
const t = useCallback(
|
||||
(key: string, vars?: TranslateVars) => {
|
||||
const text =
|
||||
getNestedMessage(messages[locale] as unknown as Record<string, unknown>, key) ??
|
||||
getNestedMessage(messages.en as unknown as Record<string, unknown>, key) ??
|
||||
key
|
||||
return interpolate(text, vars)
|
||||
},
|
||||
[locale],
|
||||
)
|
||||
|
||||
const formatDate = useCallback(
|
||||
(value: string | Date) => new Date(value).toLocaleDateString(intlLocale),
|
||||
[intlLocale],
|
||||
)
|
||||
|
||||
const formatDateTime = useCallback(
|
||||
(value: string | Date) => new Date(value).toLocaleString(intlLocale),
|
||||
[intlLocale],
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ locale, setLocale, t, formatDate, formatDateTime }),
|
||||
[locale, setLocale, t, formatDate, formatDateTime],
|
||||
)
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const ctx = useContext(I18nContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useI18n must be used within I18nProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
46
frontend/src/i18n/index.ts
Normal file
46
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { en } from './locales/en'
|
||||
import { ru } from './locales/ru'
|
||||
|
||||
export type Locale = 'en' | 'ru'
|
||||
|
||||
export const LOCALES: { code: Locale; label: string }[] = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'ru', label: 'Русский' },
|
||||
]
|
||||
|
||||
export const messages = { en, ru } as const
|
||||
|
||||
export const LOCALE_STORAGE_KEY = 'locale'
|
||||
|
||||
export function getNestedMessage(obj: Record<string, unknown>, path: string): string | undefined {
|
||||
const parts = path.split('.')
|
||||
let current: unknown = obj
|
||||
for (const part of parts) {
|
||||
if (!current || typeof current !== 'object' || !(part in current)) {
|
||||
return undefined
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
return typeof current === 'string' ? current : undefined
|
||||
}
|
||||
|
||||
export function interpolate(
|
||||
text: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
if (!vars) return text
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (_, key: string) =>
|
||||
key in vars ? String(vars[key]) : `{{${key}}}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function localeToIntl(locale: Locale): string {
|
||||
return locale === 'ru' ? 'ru-RU' : 'en-US'
|
||||
}
|
||||
|
||||
export function readStoredLocale(): Locale {
|
||||
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
|
||||
if (stored === 'ru' || stored === 'en') return stored
|
||||
const lang = navigator.language.toLowerCase()
|
||||
return lang.startsWith('ru') ? 'ru' : 'en'
|
||||
}
|
||||
228
frontend/src/i18n/locales/en.ts
Normal file
228
frontend/src/i18n/locales/en.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
export const en = {
|
||||
common: {
|
||||
loading: 'Loading...',
|
||||
pleaseWait: 'Please wait...',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
delete: 'Delete',
|
||||
confirm: 'Confirm',
|
||||
apply: 'Apply',
|
||||
clear: 'Clear',
|
||||
back: 'Back',
|
||||
edit: 'Edit',
|
||||
view: 'View',
|
||||
create: 'Create',
|
||||
actions: 'Actions',
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
role: 'Role',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
notFound: 'Not found',
|
||||
cannotUndo: 'This action cannot be undone.',
|
||||
empty: '—',
|
||||
file: 'File',
|
||||
owner: 'Owner',
|
||||
created: 'Created',
|
||||
active: 'Active',
|
||||
you: '(you)',
|
||||
},
|
||||
roles: {
|
||||
user: 'User',
|
||||
admin: 'Admin',
|
||||
},
|
||||
status: {
|
||||
active: 'active',
|
||||
inactive: 'inactive',
|
||||
},
|
||||
visibility: {
|
||||
public: 'public',
|
||||
private: 'private',
|
||||
publicFull: 'Public (visible to all users)',
|
||||
privateFull: 'Private',
|
||||
makePublic: 'Make Public',
|
||||
makePrivate: 'Make Private',
|
||||
},
|
||||
nav: {
|
||||
brand: 'Document Template Editor',
|
||||
templates: 'Templates',
|
||||
documents: 'Documents',
|
||||
users: 'Users',
|
||||
logs: 'Logs',
|
||||
logout: 'Logout',
|
||||
},
|
||||
language: {
|
||||
label: 'Language',
|
||||
en: 'English',
|
||||
ru: 'Русский',
|
||||
},
|
||||
errors: {
|
||||
loginFailed: 'Login failed',
|
||||
registrationFailed: 'Registration failed',
|
||||
passwordsMismatch: 'Passwords do not match',
|
||||
activationFailed: 'Activation failed',
|
||||
resendFailed: 'Failed to resend code',
|
||||
requestFailed: 'Request failed',
|
||||
resetFailed: 'Reset failed',
|
||||
loadFailed: 'Failed to load',
|
||||
loadTemplatesFailed: 'Failed to load templates',
|
||||
loadUsersFailed: 'Failed to load users',
|
||||
loadLogsFailed: 'Failed to load logs',
|
||||
loadTemplateFailed: 'Failed to load template',
|
||||
loadDocumentFailed: 'Failed to load document',
|
||||
deleteFailed: 'Delete failed',
|
||||
updateFailed: 'Update failed',
|
||||
downloadFailed: 'Download failed',
|
||||
uploadFailed: 'Upload failed',
|
||||
previewFailed: 'Preview failed',
|
||||
saveFailed: 'Save failed',
|
||||
exportFailed: 'Export failed',
|
||||
exportPdfFailed: 'PDF export failed. Install LibreOffice for PDF support.',
|
||||
createUserFailed: 'Failed to create user',
|
||||
actionFailed: 'Action failed',
|
||||
selectDocx: 'Please select a .docx file',
|
||||
},
|
||||
auth: {
|
||||
tagline: 'Upload Jinja2 .docx templates, fill variables, preview and export.',
|
||||
login: 'Login',
|
||||
createAccount: 'Create account',
|
||||
activateAccount: 'Activate account',
|
||||
forgotPassword: 'Forgot password?',
|
||||
registerTitle: 'Create Account',
|
||||
registerHint: 'Register to use document templates. You will receive an activation code by email.',
|
||||
register: 'Register',
|
||||
alreadyHaveAccount: 'Already have an account?',
|
||||
activateTitle: 'Activate Account',
|
||||
activateHint: 'Enter the activation code sent to your email.',
|
||||
activationCode: 'Activation Code',
|
||||
activationCodePlaceholder: '6-digit code',
|
||||
activate: 'Activate',
|
||||
resendCode: 'Resend Code',
|
||||
backToLogin: 'Back to login',
|
||||
forgotTitle: 'Forgot Password',
|
||||
forgotHint: 'Enter your email and we will send a reset code.',
|
||||
sendResetCode: 'Send Reset Code',
|
||||
haveCode: 'Have a code?',
|
||||
resetPasswordLink: 'Reset password',
|
||||
resetTitle: 'Reset Password',
|
||||
resetHint: 'Enter the code from your email and choose a new password.',
|
||||
resetCode: 'Reset Code',
|
||||
newPassword: 'New Password',
|
||||
updatePassword: 'Update Password',
|
||||
},
|
||||
templates: {
|
||||
title: 'Templates',
|
||||
upload: '+ Upload Template',
|
||||
uploadShort: 'Upload Template',
|
||||
empty: 'No templates yet. Upload a .docx file with Jinja2 variables to get started.',
|
||||
visibility: 'Visibility',
|
||||
variables: 'Variables',
|
||||
fill: 'Fill',
|
||||
source: 'Source',
|
||||
deleteTitle: 'Delete template',
|
||||
deleteMessage: 'Delete template "{{name}}"? {{cannotUndo}}',
|
||||
uploadTitle: 'Upload Template',
|
||||
stepUpload: '1. Upload .docx',
|
||||
stepFill: '2. Fill Variables',
|
||||
stepPreview: '3. Preview & Export',
|
||||
templateName: 'Template Name *',
|
||||
description: 'Description',
|
||||
docxFile: '.docx File with Jinja2 Variables *',
|
||||
docxHint: 'Use {{ variable }} for text fields. For repeating table rows use {%tr for item in items %} ... {%tr endfor %}.',
|
||||
makePublic: 'Make template public (visible to all users)',
|
||||
parsing: 'Parsing...',
|
||||
uploadContinue: 'Upload & Continue',
|
||||
useTemplate: 'Use Template',
|
||||
downloadSource: 'Download Source',
|
||||
label: 'Label',
|
||||
type: 'Type',
|
||||
required: 'Required',
|
||||
styleParams: 'Style Parameters',
|
||||
variablesCount: 'Variables ({{count}})',
|
||||
fillTitle: 'Fill: {{name}}',
|
||||
fillHint: 'Reusable template · {{count}} variables detected',
|
||||
documentFields: 'Document Fields',
|
||||
documentName: 'Document Name *',
|
||||
templateInfo: 'Template Info',
|
||||
detectedVariables: 'Detected Variables',
|
||||
style: 'Style',
|
||||
previewDocument: 'Preview Document',
|
||||
rendering: 'Rendering...',
|
||||
editFields: 'Edit Fields',
|
||||
saving: 'Saving...',
|
||||
saveExport: 'Save & Export',
|
||||
},
|
||||
documents: {
|
||||
title: 'Documents',
|
||||
createFromTemplate: 'Create from Template',
|
||||
empty: 'No filled documents yet. Choose a template and fill in the variables.',
|
||||
templateId: 'Template ID',
|
||||
deleteTitle: 'Delete document',
|
||||
deleteMessage: 'Delete document "{{name}}"? {{cannotUndo}}',
|
||||
fromTemplate: 'From template: {{name}}',
|
||||
ownerLine: 'Owner: {{name}}',
|
||||
saved: 'Saved {{date}}',
|
||||
exportDocx: 'Export DOCX',
|
||||
exportPdf: 'Export PDF',
|
||||
reuseTemplate: 'Reuse Template',
|
||||
preview: 'Preview',
|
||||
previewUnavailable: 'Preview unavailable',
|
||||
fieldData: 'Field Data',
|
||||
editTitle: 'Edit Document',
|
||||
templateLine: 'Template: {{name}}',
|
||||
previewBtn: 'Preview',
|
||||
saveChanges: 'Save Changes',
|
||||
},
|
||||
users: {
|
||||
title: 'User Management',
|
||||
addUser: '+ Add User',
|
||||
createUser: 'Create User',
|
||||
editUser: 'Edit User: {{username}}',
|
||||
newPasswordHint: 'New Password (leave empty to keep)',
|
||||
status: 'Status',
|
||||
activate: 'Activate',
|
||||
deactivate: 'Deactivate',
|
||||
activateTitle: 'Activate user',
|
||||
deactivateTitle: 'Deactivate user',
|
||||
activateMessage: 'Activate user "{{username}}"? They will be able to sign in again.',
|
||||
deactivateMessage: 'Deactivate user "{{username}}"? They will no longer be able to sign in.',
|
||||
deleteTitle: 'Delete user',
|
||||
deleteMessage: 'Delete user "{{username}}"? {{cannotUndo}}',
|
||||
},
|
||||
logs: {
|
||||
title: 'Activity Logs',
|
||||
filterByAction: 'Filter by action',
|
||||
filterPlaceholder: 'e.g. user.delete, template.create',
|
||||
time: 'Time',
|
||||
user: 'User',
|
||||
action: 'Action',
|
||||
resource: 'Resource',
|
||||
details: 'Details',
|
||||
ip: 'IP',
|
||||
empty: 'No log entries found',
|
||||
},
|
||||
tableRow: {
|
||||
repeatingRows: '(repeating table rows)',
|
||||
stylesPreserved: 'Table styles preserved from template ({{count}} reference rows)',
|
||||
row: 'Row {{index}}',
|
||||
remove: 'Remove',
|
||||
addRow: '+ Add Row',
|
||||
},
|
||||
variableField: {
|
||||
style: 'Style: {{hint}}',
|
||||
font: 'Font: {{name}}',
|
||||
size: 'Size: {{size}}pt',
|
||||
bold: 'Bold',
|
||||
align: 'Align: {{align}}',
|
||||
},
|
||||
} as const
|
||||
|
||||
type DeepStringRecord<T> = {
|
||||
[K in keyof T]: T[K] extends string ? string : DeepStringRecord<T[K]>
|
||||
}
|
||||
|
||||
export type Messages = DeepStringRecord<typeof en>
|
||||
export type MessageKey = string
|
||||
223
frontend/src/i18n/locales/ru.ts
Normal file
223
frontend/src/i18n/locales/ru.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { Messages } from './en'
|
||||
|
||||
export const ru: Messages = {
|
||||
common: {
|
||||
loading: 'Загрузка...',
|
||||
pleaseWait: 'Подождите...',
|
||||
cancel: 'Отмена',
|
||||
save: 'Сохранить',
|
||||
delete: 'Удалить',
|
||||
confirm: 'Подтвердить',
|
||||
apply: 'Применить',
|
||||
clear: 'Сбросить',
|
||||
back: 'Назад',
|
||||
edit: 'Изменить',
|
||||
view: 'Просмотр',
|
||||
create: 'Создать',
|
||||
actions: 'Действия',
|
||||
name: 'Название',
|
||||
email: 'Email',
|
||||
username: 'Имя пользователя',
|
||||
password: 'Пароль',
|
||||
confirmPassword: 'Подтверждение пароля',
|
||||
role: 'Роль',
|
||||
yes: 'Да',
|
||||
no: 'Нет',
|
||||
notFound: 'Не найдено',
|
||||
cannotUndo: 'Это действие нельзя отменить.',
|
||||
empty: '—',
|
||||
file: 'Файл',
|
||||
owner: 'Владелец',
|
||||
created: 'Создано',
|
||||
active: 'Активен',
|
||||
you: '(вы)',
|
||||
},
|
||||
roles: {
|
||||
user: 'Пользователь',
|
||||
admin: 'Администратор',
|
||||
},
|
||||
status: {
|
||||
active: 'активен',
|
||||
inactive: 'неактивен',
|
||||
},
|
||||
visibility: {
|
||||
public: 'публичный',
|
||||
private: 'приватный',
|
||||
publicFull: 'Публичный (виден всем пользователям)',
|
||||
privateFull: 'Приватный',
|
||||
makePublic: 'Сделать публичным',
|
||||
makePrivate: 'Сделать приватным',
|
||||
},
|
||||
nav: {
|
||||
brand: 'Редактор шаблонов документов',
|
||||
templates: 'Шаблоны',
|
||||
documents: 'Документы',
|
||||
users: 'Пользователи',
|
||||
logs: 'Журнал',
|
||||
logout: 'Выйти',
|
||||
},
|
||||
language: {
|
||||
label: 'Язык',
|
||||
en: 'English',
|
||||
ru: 'Русский',
|
||||
},
|
||||
errors: {
|
||||
loginFailed: 'Ошибка входа',
|
||||
registrationFailed: 'Ошибка регистрации',
|
||||
passwordsMismatch: 'Пароли не совпадают',
|
||||
activationFailed: 'Ошибка активации',
|
||||
resendFailed: 'Не удалось отправить код повторно',
|
||||
requestFailed: 'Ошибка запроса',
|
||||
resetFailed: 'Ошибка сброса пароля',
|
||||
loadFailed: 'Не удалось загрузить',
|
||||
loadTemplatesFailed: 'Не удалось загрузить шаблоны',
|
||||
loadUsersFailed: 'Не удалось загрузить пользователей',
|
||||
loadLogsFailed: 'Не удалось загрузить журнал',
|
||||
loadTemplateFailed: 'Не удалось загрузить шаблон',
|
||||
loadDocumentFailed: 'Не удалось загрузить документ',
|
||||
deleteFailed: 'Не удалось удалить',
|
||||
updateFailed: 'Не удалось обновить',
|
||||
downloadFailed: 'Не удалось скачать',
|
||||
uploadFailed: 'Не удалось загрузить файл',
|
||||
previewFailed: 'Не удалось создать предпросмотр',
|
||||
saveFailed: 'Не удалось сохранить',
|
||||
exportFailed: 'Не удалось экспортировать',
|
||||
exportPdfFailed: 'Не удалось экспортировать PDF. Установите LibreOffice для поддержки PDF.',
|
||||
createUserFailed: 'Не удалось создать пользователя',
|
||||
actionFailed: 'Действие не выполнено',
|
||||
selectDocx: 'Выберите файл .docx',
|
||||
},
|
||||
auth: {
|
||||
tagline: 'Загружайте шаблоны .docx с Jinja2, заполняйте переменные, просматривайте и экспортируйте.',
|
||||
login: 'Войти',
|
||||
createAccount: 'Создать аккаунт',
|
||||
activateAccount: 'Активировать аккаунт',
|
||||
forgotPassword: 'Забыли пароль?',
|
||||
registerTitle: 'Создание аккаунта',
|
||||
registerHint: 'Зарегистрируйтесь для работы с шаблонами. Код активации будет отправлен на email.',
|
||||
register: 'Зарегистрироваться',
|
||||
alreadyHaveAccount: 'Уже есть аккаунт?',
|
||||
activateTitle: 'Активация аккаунта',
|
||||
activateHint: 'Введите код активации, отправленный на ваш email.',
|
||||
activationCode: 'Код активации',
|
||||
activationCodePlaceholder: '6-значный код',
|
||||
activate: 'Активировать',
|
||||
resendCode: 'Отправить код повторно',
|
||||
backToLogin: 'Вернуться ко входу',
|
||||
forgotTitle: 'Восстановление пароля',
|
||||
forgotHint: 'Введите email — мы отправим код для сброса пароля.',
|
||||
sendResetCode: 'Отправить код',
|
||||
haveCode: 'Уже есть код?',
|
||||
resetPasswordLink: 'Сбросить пароль',
|
||||
resetTitle: 'Сброс пароля',
|
||||
resetHint: 'Введите код из письма и выберите новый пароль.',
|
||||
resetCode: 'Код сброса',
|
||||
newPassword: 'Новый пароль',
|
||||
updatePassword: 'Обновить пароль',
|
||||
},
|
||||
templates: {
|
||||
title: 'Шаблоны',
|
||||
upload: '+ Загрузить шаблон',
|
||||
uploadShort: 'Загрузить шаблон',
|
||||
empty: 'Шаблонов пока нет. Загрузите файл .docx с переменными Jinja2, чтобы начать.',
|
||||
visibility: 'Видимость',
|
||||
variables: 'Переменные',
|
||||
fill: 'Заполнить',
|
||||
source: 'Исходник',
|
||||
deleteTitle: 'Удалить шаблон',
|
||||
deleteMessage: 'Удалить шаблон «{{name}}»? {{cannotUndo}}',
|
||||
uploadTitle: 'Загрузка шаблона',
|
||||
stepUpload: '1. Загрузка .docx',
|
||||
stepFill: '2. Заполнение переменных',
|
||||
stepPreview: '3. Просмотр и экспорт',
|
||||
templateName: 'Название шаблона *',
|
||||
description: 'Описание',
|
||||
docxFile: 'Файл .docx с переменными Jinja2 *',
|
||||
docxHint: 'Используйте {{ variable }} для текстовых полей. Для повторяющихся строк таблицы: {%tr for item in items %} ... {%tr endfor %}.',
|
||||
makePublic: 'Сделать шаблон публичным (виден всем пользователям)',
|
||||
parsing: 'Разбор файла...',
|
||||
uploadContinue: 'Загрузить и продолжить',
|
||||
useTemplate: 'Использовать шаблон',
|
||||
downloadSource: 'Скачать исходник',
|
||||
label: 'Метка',
|
||||
type: 'Тип',
|
||||
required: 'Обязательное',
|
||||
styleParams: 'Параметры стиля',
|
||||
variablesCount: 'Переменные ({{count}})',
|
||||
fillTitle: 'Заполнение: {{name}}',
|
||||
fillHint: 'Многоразовый шаблон · обнаружено переменных: {{count}}',
|
||||
documentFields: 'Поля документа',
|
||||
documentName: 'Название документа *',
|
||||
templateInfo: 'Информация о шаблоне',
|
||||
detectedVariables: 'Обнаруженные переменные',
|
||||
style: 'Стиль',
|
||||
previewDocument: 'Предпросмотр документа',
|
||||
rendering: 'Формирование...',
|
||||
editFields: 'Изменить поля',
|
||||
saving: 'Сохранение...',
|
||||
saveExport: 'Сохранить и экспортировать',
|
||||
},
|
||||
documents: {
|
||||
title: 'Документы',
|
||||
createFromTemplate: 'Создать из шаблона',
|
||||
empty: 'Заполненных документов пока нет. Выберите шаблон и заполните переменные.',
|
||||
templateId: 'ID шаблона',
|
||||
deleteTitle: 'Удалить документ',
|
||||
deleteMessage: 'Удалить документ «{{name}}»? {{cannotUndo}}',
|
||||
fromTemplate: 'Из шаблона: {{name}}',
|
||||
ownerLine: 'Владелец: {{name}}',
|
||||
saved: 'Сохранено {{date}}',
|
||||
exportDocx: 'Экспорт DOCX',
|
||||
exportPdf: 'Экспорт PDF',
|
||||
reuseTemplate: 'Использовать шаблон снова',
|
||||
preview: 'Предпросмотр',
|
||||
previewUnavailable: 'Предпросмотр недоступен',
|
||||
fieldData: 'Данные полей',
|
||||
editTitle: 'Редактирование документа',
|
||||
templateLine: 'Шаблон: {{name}}',
|
||||
previewBtn: 'Предпросмотр',
|
||||
saveChanges: 'Сохранить изменения',
|
||||
},
|
||||
users: {
|
||||
title: 'Управление пользователями',
|
||||
addUser: '+ Добавить пользователя',
|
||||
createUser: 'Создать пользователя',
|
||||
editUser: 'Редактирование: {{username}}',
|
||||
newPasswordHint: 'Новый пароль (оставьте пустым, чтобы не менять)',
|
||||
status: 'Статус',
|
||||
activate: 'Активировать',
|
||||
deactivate: 'Деактивировать',
|
||||
activateTitle: 'Активация пользователя',
|
||||
deactivateTitle: 'Деактивация пользователя',
|
||||
activateMessage: 'Активировать пользователя «{{username}}»? Он снова сможет входить в систему.',
|
||||
deactivateMessage: 'Деактивировать пользователя «{{username}}»? Он больше не сможет входить в систему.',
|
||||
deleteTitle: 'Удалить пользователя',
|
||||
deleteMessage: 'Удалить пользователя «{{username}}»? {{cannotUndo}}',
|
||||
},
|
||||
logs: {
|
||||
title: 'Журнал действий',
|
||||
filterByAction: 'Фильтр по действию',
|
||||
filterPlaceholder: 'напр. user.delete, template.create',
|
||||
time: 'Время',
|
||||
user: 'Пользователь',
|
||||
action: 'Действие',
|
||||
resource: 'Ресурс',
|
||||
details: 'Подробности',
|
||||
ip: 'IP',
|
||||
empty: 'Записей не найдено',
|
||||
},
|
||||
tableRow: {
|
||||
repeatingRows: '(повторяющиеся строки таблицы)',
|
||||
stylesPreserved: 'Стили таблицы сохранены из шаблона ({{count}} эталонных строк)',
|
||||
row: 'Строка {{index}}',
|
||||
remove: 'Удалить',
|
||||
addRow: '+ Добавить строку',
|
||||
},
|
||||
variableField: {
|
||||
style: 'Стиль: {{hint}}',
|
||||
font: 'Шрифт: {{name}}',
|
||||
size: 'Размер: {{size}}pt',
|
||||
bold: 'Жирный',
|
||||
align: 'Выравнивание: {{align}}',
|
||||
},
|
||||
}
|
||||
@@ -309,6 +309,7 @@ textarea {
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -379,6 +380,36 @@ textarea {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.lang-switcher {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.lang-switcher-label {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lang-switcher select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lang-switcher-compact select {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.auth-lang-switcher {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import AuthShell from '../components/AuthShell'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
|
||||
export default function ActivatePage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { t } = useI18n()
|
||||
const initialEmail = (location.state as { email?: string } | null)?.email ?? ''
|
||||
|
||||
const [email, setEmail] = useState(initialEmail)
|
||||
@@ -23,7 +26,7 @@ export default function ActivatePage() {
|
||||
setSuccess(result.message)
|
||||
setTimeout(() => navigate('/login'), 1500)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Activation failed')
|
||||
setError(err instanceof Error ? err.message : t('errors.activationFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -36,22 +39,22 @@ export default function ActivatePage() {
|
||||
const result = await api.resendActivation(email)
|
||||
setSuccess(result.message)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to resend code')
|
||||
setError(err instanceof Error ? err.message : t('errors.resendFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<AuthShell>
|
||||
<div className="card auth-card">
|
||||
<h1>Activate Account</h1>
|
||||
<p>Enter the activation code sent to your email.</p>
|
||||
<h1>{t('auth.activateTitle')}</h1>
|
||||
<p>{t('auth.activateHint')}</p>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{success && <div className="success">{success}</div>}
|
||||
|
||||
<form onSubmit={handleActivate}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<label>{t('common.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
@@ -60,16 +63,16 @@ export default function ActivatePage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Activation Code</label>
|
||||
<label>{t('auth.activationCode')}</label>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
required
|
||||
placeholder="6-digit code"
|
||||
placeholder={t('auth.activationCodePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Please wait...' : 'Activate'}
|
||||
{loading ? t('common.pleaseWait') : t('auth.activate')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -80,13 +83,13 @@ export default function ActivatePage() {
|
||||
onClick={handleResend}
|
||||
disabled={!email}
|
||||
>
|
||||
Resend Code
|
||||
{t('auth.resendCode')}
|
||||
</button>
|
||||
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/login">Back to login</Link>
|
||||
<Link to="/login">{t('auth.backToLogin')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
|
||||
export default function CreateTemplatePage() {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useI18n()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
@@ -14,7 +16,7 @@ export default function CreateTemplatePage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!file) {
|
||||
setError('Please select a .docx file')
|
||||
setError(t('errors.selectDocx'))
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
@@ -28,7 +30,7 @@ export default function CreateTemplatePage() {
|
||||
)
|
||||
navigate(`/templates/${template.id}/fill`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed')
|
||||
setError(err instanceof Error ? err.message : t('errors.uploadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -37,13 +39,13 @@ export default function CreateTemplatePage() {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<h1>Upload Template</h1>
|
||||
<h1>{t('templates.uploadTitle')}</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 className="step active">{t('templates.stepUpload')}</div>
|
||||
<div className="step">{t('templates.stepFill')}</div>
|
||||
<div className="step">{t('templates.stepPreview')}</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ maxWidth: 600 }}>
|
||||
@@ -51,11 +53,11 @@ export default function CreateTemplatePage() {
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Template Name *</label>
|
||||
<label>{t('templates.templateName')}</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<label>{t('templates.description')}</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
@@ -63,7 +65,7 @@ export default function CreateTemplatePage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>.docx File with Jinja2 Variables *</label>
|
||||
<label>{t('templates.docxFile')}</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".docx"
|
||||
@@ -71,8 +73,7 @@ export default function CreateTemplatePage() {
|
||||
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 %}'}.
|
||||
{t('templates.docxHint')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -83,19 +84,19 @@ export default function CreateTemplatePage() {
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
style={{ width: 'auto', marginRight: 8 }}
|
||||
/>
|
||||
Make template public (visible to all users)
|
||||
{t('templates.makePublic')}
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Parsing...' : 'Upload & Continue'}
|
||||
{loading ? t('templates.parsing') : t('templates.uploadContinue')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate('/templates')}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,12 +3,14 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api, downloadWithAuth } from '../api'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { DocumentTemplate, FilledDocument } from '../types'
|
||||
|
||||
export default function DocumentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const { t, formatDateTime } = useI18n()
|
||||
const [document, setDocument] = useState<FilledDocument | null>(null)
|
||||
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
|
||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
|
||||
@@ -21,8 +23,8 @@ export default function DocumentDetailPage() {
|
||||
try {
|
||||
const doc = await api.getDocument(Number(id))
|
||||
setDocument(doc)
|
||||
const t = await api.getTemplate(doc.template_id)
|
||||
setTemplate(t)
|
||||
const loadedTemplate = await api.getTemplate(doc.template_id)
|
||||
setTemplate(loadedTemplate)
|
||||
const preview = await api.previewDocument({
|
||||
template_id: doc.template_id,
|
||||
name: doc.name,
|
||||
@@ -30,7 +32,7 @@ export default function DocumentDetailPage() {
|
||||
})
|
||||
setPreviewHtml(preview.html)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||
setError(err instanceof Error ? err.message : t('errors.loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -46,7 +48,7 @@ export default function DocumentDetailPage() {
|
||||
`${document.name}.docx`,
|
||||
)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Export failed')
|
||||
alert(err instanceof Error ? err.message : t('errors.exportFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +60,7 @@ export default function DocumentDetailPage() {
|
||||
`${document.name}.pdf`,
|
||||
)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'PDF export failed. Install LibreOffice for PDF support.')
|
||||
alert(err instanceof Error ? err.message : t('errors.exportPdfFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,23 +70,26 @@ export default function DocumentDetailPage() {
|
||||
await api.deleteDocument(document.id)
|
||||
navigate('/documents')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Delete failed')
|
||||
alert(err instanceof Error ? err.message : t('errors.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const canManage =
|
||||
user?.role === 'admin' || document?.owner_id === user?.id
|
||||
|
||||
if (loading) return <div className="container"><p>Loading...</p></div>
|
||||
if (loading) return <div className="container"><p>{t('common.loading')}</p></div>
|
||||
if (!document) return <div className="container"><div className="error">{error}</div></div>
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<ConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
title="Delete document"
|
||||
message={`Delete document "${document.name}"? This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
title={t('documents.deleteTitle')}
|
||||
message={t('documents.deleteMessage', {
|
||||
name: document.name,
|
||||
cannotUndo: t('common.cannotUndo'),
|
||||
})}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
@@ -95,30 +100,31 @@ export default function DocumentDetailPage() {
|
||||
<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()}
|
||||
{t('documents.fromTemplate', { name: template.name })}
|
||||
{document.owner_username && ` · ${t('documents.ownerLine', { name: document.owner_username })}`}
|
||||
{' · '}
|
||||
{t('documents.saved', { date: formatDateTime(document.created_at) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" onClick={handleExportDocx}>
|
||||
Export DOCX
|
||||
{t('documents.exportDocx')}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleExportPdf}>
|
||||
Export PDF
|
||||
{t('documents.exportPdf')}
|
||||
</button>
|
||||
<Link to={`/documents/${document.id}/edit`} className="btn btn-secondary">
|
||||
Edit
|
||||
{t('common.edit')}
|
||||
</Link>
|
||||
{template && (
|
||||
<Link to={`/templates/${template.id}/fill`} className="btn btn-secondary">
|
||||
Reuse Template
|
||||
{t('documents.reuseTemplate')}
|
||||
</Link>
|
||||
)}
|
||||
{canManage && (
|
||||
<button className="btn btn-danger" onClick={() => setShowDeleteConfirm(true)}>
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -128,19 +134,19 @@ export default function DocumentDetailPage() {
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>Preview</h2>
|
||||
<h2 style={{ marginTop: 0 }}>{t('documents.preview')}</h2>
|
||||
{previewHtml ? (
|
||||
<div
|
||||
className="doc-preview"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p>Preview unavailable</p>
|
||||
<p>{t('documents.previewUnavailable')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>Field Data</h2>
|
||||
<h2 style={{ marginTop: 0 }}>{t('documents.fieldData')}</h2>
|
||||
<pre style={{
|
||||
background: '#f8fafc',
|
||||
padding: 16,
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Link } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { FilledDocument } from '../types'
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const { user } = useAuth()
|
||||
const { t, formatDateTime } = useI18n()
|
||||
const [documents, setDocuments] = useState<FilledDocument[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -15,7 +17,7 @@ export default function DocumentsPage() {
|
||||
const load = () => {
|
||||
api.listDocuments()
|
||||
.then(setDocuments)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load'))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : t('errors.loadFailed')))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
@@ -30,7 +32,7 @@ export default function DocumentsPage() {
|
||||
setDocuments((list) => list.filter((d) => d.id !== deleteTarget.id))
|
||||
setDeleteTarget(null)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Delete failed')
|
||||
alert(err instanceof Error ? err.message : t('errors.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,43 +42,46 @@ export default function DocumentsPage() {
|
||||
<div className="container">
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
title="Delete document"
|
||||
title={t('documents.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? `Delete document "${deleteTarget.name}"? This action cannot be undone.`
|
||||
? t('documents.deleteMessage', {
|
||||
name: deleteTarget.name,
|
||||
cannotUndo: t('common.cannotUndo'),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<div className="page-header">
|
||||
<h1>Documents</h1>
|
||||
<h1>{t('documents.title')}</h1>
|
||||
<Link to="/templates" className="btn btn-primary">
|
||||
Create from Template
|
||||
{t('documents.createFromTemplate')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
<p>{t('common.loading')}</p>
|
||||
) : documents.length === 0 ? (
|
||||
<div className="card empty-state">
|
||||
<p>No filled documents yet. Choose a template and fill in the variables.</p>
|
||||
<p>{t('documents.empty')}</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>
|
||||
<th>{t('common.name')}</th>
|
||||
{isAdmin && <th>{t('common.owner')}</th>}
|
||||
<th>{t('documents.templateId')}</th>
|
||||
<th>{t('common.created')}</th>
|
||||
<th>{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -85,20 +90,20 @@ export default function DocumentsPage() {
|
||||
<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>{formatDateTime(d.created_at)}</td>
|
||||
<td style={{ display: 'flex', gap: 8 }}>
|
||||
<Link to={`/documents/${d.id}`} className="btn btn-primary btn-sm">
|
||||
View
|
||||
{t('common.view')}
|
||||
</Link>
|
||||
<Link to={`/documents/${d.id}/edit`} className="btn btn-secondary btn-sm">
|
||||
Edit
|
||||
{t('common.edit')}
|
||||
</Link>
|
||||
{(isAdmin || d.owner_id === user?.id) && (
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => setDeleteTarget(d)}
|
||||
>
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import TableRowEditor from '../components/TableRowEditor'
|
||||
import VariableField from '../components/VariableField'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
|
||||
export default function EditDocumentPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useI18n()
|
||||
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
|
||||
const [fieldData, setFieldData] = useState<Record<string, unknown>>({})
|
||||
const [documentName, setDocumentName] = useState('')
|
||||
@@ -21,12 +23,12 @@ export default function EditDocumentPage() {
|
||||
const load = async () => {
|
||||
try {
|
||||
const doc = await api.getDocument(Number(id))
|
||||
const t = await api.getTemplate(doc.template_id)
|
||||
setTemplate(t)
|
||||
const loadedTemplate = await api.getTemplate(doc.template_id)
|
||||
setTemplate(loadedTemplate)
|
||||
setDocumentName(doc.name)
|
||||
setFieldData(doc.field_data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load document')
|
||||
setError(err instanceof Error ? err.message : t('errors.loadDocumentFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -64,7 +66,7 @@ export default function EditDocumentPage() {
|
||||
setPreviewHtml(result.html)
|
||||
setStep(3)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Preview failed')
|
||||
setError(err instanceof Error ? err.message : t('errors.previewFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -81,23 +83,23 @@ export default function EditDocumentPage() {
|
||||
})
|
||||
navigate(`/documents/${doc.id}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed')
|
||||
setError(err instanceof Error ? err.message : t('errors.saveFailed'))
|
||||
} 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>
|
||||
if (loading) return <div className="container"><p>{t('common.loading')}</p></div>
|
||||
if (!template) return <div className="container"><div className="error">{error || t('common.notFound')}</div></div>
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Edit Document</h1>
|
||||
<div className="style-hint">Template: {template.name}</div>
|
||||
<h1>{t('documents.editTitle')}</h1>
|
||||
<div className="style-hint">{t('documents.templateLine', { name: template.name })}</div>
|
||||
</div>
|
||||
<Link to={`/documents/${id}`} className="btn btn-secondary">Back</Link>
|
||||
<Link to={`/documents/${id}`} className="btn btn-secondary">{t('common.back')}</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
@@ -105,7 +107,7 @@ export default function EditDocumentPage() {
|
||||
{step === 2 && (
|
||||
<div className="card" style={{ maxWidth: 720 }}>
|
||||
<div className="form-group">
|
||||
<label>Document Name *</label>
|
||||
<label>{t('templates.documentName')}</label>
|
||||
<input
|
||||
value={documentName}
|
||||
onChange={(e) => setDocumentName(e.target.value)}
|
||||
@@ -146,7 +148,7 @@ export default function EditDocumentPage() {
|
||||
onClick={handlePreview}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Rendering...' : 'Preview'}
|
||||
{submitting ? t('templates.rendering') : t('documents.previewBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,14 +164,14 @@ export default function EditDocumentPage() {
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button className="btn btn-secondary" onClick={() => setStep(2)}>
|
||||
Edit Fields
|
||||
{t('templates.editFields')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Saving...' : 'Save Changes'}
|
||||
{submitting ? t('templates.saving') : t('documents.saveChanges')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import TableRowEditor from '../components/TableRowEditor'
|
||||
import VariableField from '../components/VariableField'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
|
||||
export default function FillTemplatePage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useI18n()
|
||||
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
|
||||
const [fieldData, setFieldData] = useState<Record<string, unknown>>({})
|
||||
const [documentName, setDocumentName] = useState('')
|
||||
@@ -20,12 +22,12 @@ export default function FillTemplatePage() {
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const t = await api.getTemplate(Number(id))
|
||||
setTemplate(t)
|
||||
setDocumentName(`${t.name} - ${new Date().toLocaleDateString()}`)
|
||||
const loaded = await api.getTemplate(Number(id))
|
||||
setTemplate(loaded)
|
||||
setDocumentName(`${loaded.name} - ${new Date().toLocaleDateString()}`)
|
||||
|
||||
const initial: Record<string, unknown> = {}
|
||||
t.variables.forEach((v) => {
|
||||
loaded.variables.forEach((v) => {
|
||||
if (v.field_type === 'table_row') {
|
||||
initial[v.name] = []
|
||||
} else if (!v.parent_variable) {
|
||||
@@ -34,7 +36,7 @@ export default function FillTemplatePage() {
|
||||
})
|
||||
setFieldData(initial)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load template')
|
||||
setError(err instanceof Error ? err.message : t('errors.loadTemplateFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -72,7 +74,7 @@ export default function FillTemplatePage() {
|
||||
setPreviewHtml(result.html)
|
||||
setStep(3)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Preview failed')
|
||||
setError(err instanceof Error ? err.message : t('errors.previewFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -90,29 +92,31 @@ export default function FillTemplatePage() {
|
||||
})
|
||||
navigate(`/documents/${doc.id}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed')
|
||||
setError(err instanceof Error ? err.message : t('errors.saveFailed'))
|
||||
} 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>
|
||||
if (loading) return <div className="container"><p>{t('common.loading')}</p></div>
|
||||
if (!template) return <div className="container"><div className="error">{error || t('common.notFound')}</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>
|
||||
<h1>{t('templates.fillTitle', { name: template.name })}</h1>
|
||||
<div className="style-hint">
|
||||
{t('templates.fillHint', { count: template.variables.length })}
|
||||
</div>
|
||||
<Link to="/templates" className="btn btn-secondary">Back</Link>
|
||||
</div>
|
||||
<Link to="/templates" className="btn btn-secondary">{t('common.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 className="step done">{t('templates.stepUpload')}</div>
|
||||
<div className={`step ${step === 2 ? 'active' : step > 2 ? 'done' : ''}`}>{t('templates.stepFill')}</div>
|
||||
<div className={`step ${step === 3 ? 'active' : ''}`}>{t('templates.stepPreview')}</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
@@ -120,10 +124,10 @@ export default function FillTemplatePage() {
|
||||
{step === 2 && (
|
||||
<div className="grid-2">
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>Document Fields</h2>
|
||||
<h2 style={{ marginTop: 0 }}>{t('templates.documentFields')}</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Document Name *</label>
|
||||
<label>{t('templates.documentName')}</label>
|
||||
<input
|
||||
value={documentName}
|
||||
onChange={(e) => setDocumentName(e.target.value)}
|
||||
@@ -164,22 +168,22 @@ export default function FillTemplatePage() {
|
||||
onClick={handlePreview}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Rendering...' : 'Preview Document'}
|
||||
{submitting ? t('templates.rendering') : t('templates.previewDocument')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>Template Info</h2>
|
||||
<p><strong>File:</strong> {template.original_filename}</p>
|
||||
<h2 style={{ marginTop: 0 }}>{t('templates.templateInfo')}</h2>
|
||||
<p><strong>{t('common.file')}:</strong> {template.original_filename}</p>
|
||||
{template.description && <p>{template.description}</p>}
|
||||
<h3>Detected Variables</h3>
|
||||
<h3>{t('templates.detectedVariables')}</h3>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Style</th>
|
||||
<th>{t('common.name')}</th>
|
||||
<th>{t('templates.type')}</th>
|
||||
<th>{t('templates.style')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -188,7 +192,7 @@ export default function FillTemplatePage() {
|
||||
<td>{v.name}</td>
|
||||
<td>{v.field_type}</td>
|
||||
<td className="style-hint">
|
||||
{v.style_params?.font_name ?? '—'}
|
||||
{v.style_params?.font_name ?? t('common.empty')}
|
||||
{v.style_params?.alignment ? ` / ${v.style_params.alignment}` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -209,14 +213,14 @@ export default function FillTemplatePage() {
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button className="btn btn-secondary" onClick={() => setStep(2)}>
|
||||
Edit Fields
|
||||
{t('templates.editFields')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Saving...' : 'Save & Export'}
|
||||
{submitting ? t('templates.saving') : t('templates.saveExport')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import AuthShell from '../components/AuthShell'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useI18n()
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
@@ -17,24 +20,24 @@ export default function ForgotPasswordPage() {
|
||||
const result = await api.forgotPassword(email)
|
||||
setSuccess(result.message)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Request failed')
|
||||
setError(err instanceof Error ? err.message : t('errors.requestFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<AuthShell>
|
||||
<div className="card auth-card">
|
||||
<h1>Forgot Password</h1>
|
||||
<p>Enter your email and we will send a reset code.</p>
|
||||
<h1>{t('auth.forgotTitle')}</h1>
|
||||
<p>{t('auth.forgotHint')}</p>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{success && <div className="success">{success}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<label>{t('common.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
@@ -43,17 +46,17 @@ export default function ForgotPasswordPage() {
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Please wait...' : 'Send Reset Code'}
|
||||
{loading ? t('common.pleaseWait') : t('auth.sendResetCode')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ marginTop: 16 }}>
|
||||
Have a code? <Link to="/reset-password">Reset password</Link>
|
||||
{t('auth.haveCode')} <Link to="/reset-password">{t('auth.resetPasswordLink')}</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link to="/login">Back to login</Link>
|
||||
<Link to="/login">{t('auth.backToLogin')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import AuthShell from '../components/AuthShell'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { user, login } = useAuth()
|
||||
const { t } = useI18n()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -18,23 +21,23 @@ export default function LoginPage() {
|
||||
try {
|
||||
await login(username, password)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
setError(err instanceof Error ? err.message : t('errors.loginFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<AuthShell>
|
||||
<div className="card auth-card">
|
||||
<h1>Document Template Editor</h1>
|
||||
<p>Upload Jinja2 .docx templates, fill variables, preview and export.</p>
|
||||
<h1>{t('nav.brand')}</h1>
|
||||
<p>{t('auth.tagline')}</p>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<label>{t('common.username')}</label>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -42,7 +45,7 @@ export default function LoginPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<label>{t('common.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
@@ -51,22 +54,22 @@ export default function LoginPage() {
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Please wait...' : 'Login'}
|
||||
{loading ? t('common.pleaseWait') : t('auth.login')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: 20, fontSize: '0.9rem' }}>
|
||||
<p style={{ margin: '8px 0' }}>
|
||||
<Link to="/register">Create account</Link>
|
||||
<Link to="/register">{t('auth.createAccount')}</Link>
|
||||
</p>
|
||||
<p style={{ margin: '8px 0' }}>
|
||||
<Link to="/activate">Activate account</Link>
|
||||
<Link to="/activate">{t('auth.activateAccount')}</Link>
|
||||
</p>
|
||||
<p style={{ margin: '8px 0' }}>
|
||||
<Link to="/forgot-password">Forgot password?</Link>
|
||||
<Link to="/forgot-password">{t('auth.forgotPassword')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,20 +2,25 @@ import { useEffect, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { AuditLog } from '../types'
|
||||
|
||||
function formatAction(action: string): string {
|
||||
return action.replace(/\./g, ' · ').replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function formatDetails(details: Record<string, unknown> | null): string {
|
||||
if (!details) return '—'
|
||||
function formatDetails(
|
||||
details: Record<string, unknown> | null,
|
||||
emptyLabel: string,
|
||||
): string {
|
||||
if (!details) return emptyLabel
|
||||
const text = JSON.stringify(details, null, 0)
|
||||
return text.length > 120 ? `${text.slice(0, 120)}…` : text
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const { t, formatDateTime } = useI18n()
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -27,7 +32,7 @@ export default function LogsPage() {
|
||||
try {
|
||||
setLogs(await api.listLogs({ action: action || undefined }))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load logs')
|
||||
setError(err instanceof Error ? err.message : t('errors.loadLogsFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -49,21 +54,21 @@ export default function LogsPage() {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<h1>Activity Logs</h1>
|
||||
<h1>{t('logs.title')}</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleFilter} className="card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div className="form-group" style={{ marginBottom: 0, flex: '1 1 240px' }}>
|
||||
<label>Filter by action</label>
|
||||
<label>{t('logs.filterByAction')}</label>
|
||||
<input
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
placeholder="e.g. user.delete, template.create"
|
||||
placeholder={t('logs.filterPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Apply
|
||||
{t('common.apply')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -73,7 +78,7 @@ export default function LogsPage() {
|
||||
load()
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
{t('common.clear')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -81,48 +86,48 @@ export default function LogsPage() {
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
<p>{t('common.loading')}</p>
|
||||
) : (
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Resource</th>
|
||||
<th>Details</th>
|
||||
<th>IP</th>
|
||||
<th>{t('logs.time')}</th>
|
||||
<th>{t('logs.user')}</th>
|
||||
<th>{t('logs.action')}</th>
|
||||
<th>{t('logs.resource')}</th>
|
||||
<th>{t('logs.details')}</th>
|
||||
<th>{t('logs.ip')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--muted)' }}>
|
||||
No log entries found
|
||||
{t('logs.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
{formatDateTime(log.created_at)}
|
||||
</td>
|
||||
<td>{log.username || '—'}</td>
|
||||
<td>{log.username || t('common.empty')}</td>
|
||||
<td>
|
||||
<code className="log-action">{formatAction(log.action)}</code>
|
||||
</td>
|
||||
<td>
|
||||
{log.resource_type
|
||||
? `${log.resource_type}${log.resource_id != null ? ` #${log.resource_id}` : ''}`
|
||||
: '—'}
|
||||
: t('common.empty')}
|
||||
</td>
|
||||
<td>
|
||||
<span className="style-hint" title={JSON.stringify(log.details, null, 2)}>
|
||||
{formatDetails(log.details)}
|
||||
{formatDetails(log.details, t('common.empty'))}
|
||||
</span>
|
||||
</td>
|
||||
<td>{log.ip_address || '—'}</td>
|
||||
<td>{log.ip_address || t('common.empty')}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import AuthShell from '../components/AuthShell'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useI18n()
|
||||
const [email, setEmail] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -19,7 +22,7 @@ export default function RegisterPage() {
|
||||
setSuccess('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
setError(t('errors.passwordsMismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -31,24 +34,24 @@ export default function RegisterPage() {
|
||||
navigate('/activate', { state: { email: result.email } })
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed')
|
||||
setError(err instanceof Error ? err.message : t('errors.registrationFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<AuthShell>
|
||||
<div className="card auth-card">
|
||||
<h1>Create Account</h1>
|
||||
<p>Register to use document templates. You will receive an activation code by email.</p>
|
||||
<h1>{t('auth.registerTitle')}</h1>
|
||||
<p>{t('auth.registerHint')}</p>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{success && <div className="success">{success}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<label>{t('common.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
@@ -57,7 +60,7 @@ export default function RegisterPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<label>{t('common.username')}</label>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -66,7 +69,7 @@ export default function RegisterPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<label>{t('common.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
@@ -76,7 +79,7 @@ export default function RegisterPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Confirm Password</label>
|
||||
<label>{t('common.confirmPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
@@ -86,14 +89,14 @@ export default function RegisterPage() {
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Please wait...' : 'Register'}
|
||||
{loading ? t('common.pleaseWait') : t('auth.register')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ marginTop: 16 }}>
|
||||
Already have an account? <Link to="/login">Login</Link>
|
||||
{t('auth.alreadyHaveAccount')} <Link to="/login">{t('auth.login')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import AuthShell from '../components/AuthShell'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useI18n()
|
||||
const [email, setEmail] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -18,7 +21,7 @@ export default function ResetPasswordPage() {
|
||||
setSuccess('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
setError(t('errors.passwordsMismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -33,24 +36,24 @@ export default function ResetPasswordPage() {
|
||||
setSuccess(result.message)
|
||||
setTimeout(() => navigate('/login'), 1500)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Reset failed')
|
||||
setError(err instanceof Error ? err.message : t('errors.resetFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<AuthShell>
|
||||
<div className="card auth-card">
|
||||
<h1>Reset Password</h1>
|
||||
<p>Enter the code from your email and choose a new password.</p>
|
||||
<h1>{t('auth.resetTitle')}</h1>
|
||||
<p>{t('auth.resetHint')}</p>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{success && <div className="success">{success}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<label>{t('common.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
@@ -59,7 +62,7 @@ export default function ResetPasswordPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Reset Code</label>
|
||||
<label>{t('auth.resetCode')}</label>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
@@ -67,7 +70,7 @@ export default function ResetPasswordPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>New Password</label>
|
||||
<label>{t('auth.newPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
@@ -77,7 +80,7 @@ export default function ResetPasswordPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Confirm Password</label>
|
||||
<label>{t('common.confirmPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
@@ -87,14 +90,14 @@ export default function ResetPasswordPage() {
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Please wait...' : 'Update Password'}
|
||||
{loading ? t('common.pleaseWait') : t('auth.updatePassword')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/login">Back to login</Link>
|
||||
<Link to="/login">{t('auth.backToLogin')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { api, downloadWithAuth } from '../api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
|
||||
export default function TemplateDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { user } = useAuth()
|
||||
const { t, formatDateTime } = useI18n()
|
||||
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -14,7 +16,7 @@ export default function TemplateDetailPage() {
|
||||
useEffect(() => {
|
||||
api.getTemplate(Number(id))
|
||||
.then(setTemplate)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load'))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : t('errors.loadFailed')))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
@@ -26,7 +28,7 @@ export default function TemplateDetailPage() {
|
||||
})
|
||||
setTemplate(updated)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Update failed')
|
||||
alert(err instanceof Error ? err.message : t('errors.updateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,11 +40,11 @@ export default function TemplateDetailPage() {
|
||||
template.original_filename,
|
||||
)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Download failed')
|
||||
alert(err instanceof Error ? err.message : t('errors.downloadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="container"><p>Loading...</p></div>
|
||||
if (loading) return <div className="container"><p>{t('common.loading')}</p></div>
|
||||
if (!template) return <div className="container"><div className="error">{error}</div></div>
|
||||
|
||||
const canManage = template.is_owner || user?.role === 'admin'
|
||||
@@ -53,38 +55,38 @@ export default function TemplateDetailPage() {
|
||||
<h1>{template.name}</h1>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Link to={`/templates/${template.id}/fill`} className="btn btn-primary">
|
||||
Use Template
|
||||
{t('templates.useTemplate')}
|
||||
</Link>
|
||||
<button className="btn btn-secondary" onClick={handleDownloadSource}>
|
||||
Download Source
|
||||
{t('templates.downloadSource')}
|
||||
</button>
|
||||
{canManage && (
|
||||
<button className="btn btn-secondary" onClick={handleTogglePublic}>
|
||||
{template.is_public ? 'Make Private' : 'Make Public'}
|
||||
{template.is_public ? t('visibility.makePrivate') : t('visibility.makePublic')}
|
||||
</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>{t('common.file')}:</strong> {template.original_filename}</p>
|
||||
<p><strong>{t('common.owner')}:</strong> {template.owner_username ?? `#${template.owner_id}`}</p>
|
||||
<p>
|
||||
<strong>Visibility:</strong>{' '}
|
||||
{template.is_public ? 'Public (visible to all users)' : 'Private'}
|
||||
<strong>{t('templates.visibility')}:</strong>{' '}
|
||||
{template.is_public ? t('visibility.publicFull') : t('visibility.privateFull')}
|
||||
</p>
|
||||
{template.description && <p>{template.description}</p>}
|
||||
<p><strong>Created:</strong> {new Date(template.created_at).toLocaleString()}</p>
|
||||
<p><strong>{t('common.created')}:</strong> {formatDateTime(template.created_at)}</p>
|
||||
|
||||
<h2>Variables ({template.variables.length})</h2>
|
||||
<h2>{t('templates.variablesCount', { count: 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>
|
||||
<th>{t('templates.label')}</th>
|
||||
<th>{t('common.name')}</th>
|
||||
<th>{t('templates.type')}</th>
|
||||
<th>{t('templates.required')}</th>
|
||||
<th>{t('templates.styleParams')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -93,11 +95,11 @@ export default function TemplateDetailPage() {
|
||||
<td>{v.label}</td>
|
||||
<td><code>{v.name}</code></td>
|
||||
<td>{v.field_type}</td>
|
||||
<td>{v.is_required ? 'Yes' : 'No'}</td>
|
||||
<td>{v.is_required ? t('common.yes') : t('common.no')}</td>
|
||||
<td className="style-hint">
|
||||
{v.style_params
|
||||
? JSON.stringify(v.style_params, null, 0).slice(0, 80) + '...'
|
||||
: '—'}
|
||||
: t('common.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Link } from 'react-router-dom'
|
||||
import { api, downloadWithAuth } from '../api'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { TemplateListItem } from '../types'
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const { user } = useAuth()
|
||||
const { t, formatDate } = useI18n()
|
||||
const [templates, setTemplates] = useState<TemplateListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -16,7 +18,7 @@ export default function TemplatesPage() {
|
||||
try {
|
||||
setTemplates(await api.listTemplates())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load templates')
|
||||
setError(err instanceof Error ? err.message : t('errors.loadTemplatesFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -33,71 +35,69 @@ export default function TemplatesPage() {
|
||||
setTemplates((list) => list.filter((x) => x.id !== deleteTarget.id))
|
||||
setDeleteTarget(null)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Delete failed')
|
||||
alert(err instanceof Error ? err.message : t('errors.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePublic = async (t: TemplateListItem) => {
|
||||
const handleTogglePublic = async (item: TemplateListItem) => {
|
||||
try {
|
||||
const updated = await api.updateTemplate(t.id, { is_public: !t.is_public })
|
||||
const updated = await api.updateTemplate(item.id, { is_public: !item.is_public })
|
||||
setTemplates((list) =>
|
||||
list.map((x) =>
|
||||
x.id === t.id
|
||||
? { ...x, is_public: updated.is_public }
|
||||
: x,
|
||||
x.id === item.id ? { ...x, is_public: updated.is_public } : x,
|
||||
),
|
||||
)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Update failed')
|
||||
alert(err instanceof Error ? err.message : t('errors.updateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadSource = async (t: TemplateListItem) => {
|
||||
const handleDownloadSource = async (item: TemplateListItem) => {
|
||||
try {
|
||||
await downloadWithAuth(
|
||||
api.getTemplateSourceUrl(t.id),
|
||||
t.original_filename,
|
||||
)
|
||||
await downloadWithAuth(api.getTemplateSourceUrl(item.id), item.original_filename)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Download failed')
|
||||
alert(err instanceof Error ? err.message : t('errors.downloadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const canManage = (t: TemplateListItem) =>
|
||||
t.is_owner || user?.role === 'admin'
|
||||
const canManage = (item: TemplateListItem) =>
|
||||
item.is_owner || user?.role === 'admin'
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
title="Delete template"
|
||||
title={t('templates.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? `Delete template "${deleteTarget.name}"? This action cannot be undone.`
|
||||
? t('templates.deleteMessage', {
|
||||
name: deleteTarget.name,
|
||||
cannotUndo: t('common.cannotUndo'),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<div className="page-header">
|
||||
<h1>Templates</h1>
|
||||
<h1>{t('templates.title')}</h1>
|
||||
<Link to="/templates/new" className="btn btn-primary">
|
||||
+ Upload Template
|
||||
{t('templates.upload')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
<p>{t('common.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>
|
||||
<p>{t('templates.empty')}</p>
|
||||
<Link to="/templates/new" className="btn btn-primary" style={{ marginTop: 16 }}>
|
||||
Upload Template
|
||||
{t('templates.uploadShort')}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
@@ -105,63 +105,63 @@ export default function TemplatesPage() {
|
||||
<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>
|
||||
<th>{t('common.name')}</th>
|
||||
<th>{t('common.file')}</th>
|
||||
<th>{t('common.owner')}</th>
|
||||
<th>{t('templates.visibility')}</th>
|
||||
<th>{t('templates.variables')}</th>
|
||||
<th>{t('common.created')}</th>
|
||||
<th>{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((t) => (
|
||||
<tr key={t.id}>
|
||||
{templates.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<strong>{t.name}</strong>
|
||||
{t.description && (
|
||||
<div className="style-hint">{t.description}</div>
|
||||
<strong>{item.name}</strong>
|
||||
{item.description && (
|
||||
<div className="style-hint">{item.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{t.original_filename}</td>
|
||||
<td>{t.owner_username ?? `#${t.owner_id}`}</td>
|
||||
<td>{item.original_filename}</td>
|
||||
<td>{item.owner_username ?? `#${item.owner_id}`}</td>
|
||||
<td>
|
||||
{t.is_public ? (
|
||||
{item.is_public ? (
|
||||
<span className="badge" style={{ background: '#dbeafe', color: '#1d4ed8' }}>
|
||||
public
|
||||
{t('visibility.public')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge badge-user">private</span>
|
||||
<span className="badge badge-user">{t('visibility.private')}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{t.variable_count}</td>
|
||||
<td>{new Date(t.created_at).toLocaleDateString()}</td>
|
||||
<td>{item.variable_count}</td>
|
||||
<td>{formatDate(item.created_at)}</td>
|
||||
<td style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Link to={`/templates/${t.id}/fill`} className="btn btn-primary btn-sm">
|
||||
Fill
|
||||
<Link to={`/templates/${item.id}/fill`} className="btn btn-primary btn-sm">
|
||||
{t('templates.fill')}
|
||||
</Link>
|
||||
<Link to={`/templates/${t.id}`} className="btn btn-secondary btn-sm">
|
||||
View
|
||||
<Link to={`/templates/${item.id}`} className="btn btn-secondary btn-sm">
|
||||
{t('common.view')}
|
||||
</Link>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => handleDownloadSource(t)}
|
||||
onClick={() => handleDownloadSource(item)}
|
||||
>
|
||||
Source
|
||||
{t('templates.source')}
|
||||
</button>
|
||||
{canManage(t) && (
|
||||
{canManage(item) && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => handleTogglePublic(t)}
|
||||
onClick={() => handleTogglePublic(item)}
|
||||
>
|
||||
{t.is_public ? 'Make Private' : 'Make Public'}
|
||||
{item.is_public ? t('visibility.makePrivate') : t('visibility.makePublic')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => setDeleteTarget(t)}
|
||||
onClick={() => setDeleteTarget(item)}
|
||||
>
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Navigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useI18n } from '../context/I18nContext'
|
||||
import type { User } from '../types'
|
||||
|
||||
type PendingConfirm = {
|
||||
@@ -15,6 +16,7 @@ type PendingConfirm = {
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const { t, formatDate } = useI18n()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -39,7 +41,7 @@ export default function UsersPage() {
|
||||
try {
|
||||
setUsers(await api.listUsers())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load users')
|
||||
setError(err instanceof Error ? err.message : t('errors.loadUsersFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -58,7 +60,7 @@ export default function UsersPage() {
|
||||
setShowForm(false)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create user')
|
||||
setError(err instanceof Error ? err.message : t('errors.createUserFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +89,7 @@ export default function UsersPage() {
|
||||
setEditingUser(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Update failed')
|
||||
setError(err instanceof Error ? err.message : t('errors.updateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +100,11 @@ export default function UsersPage() {
|
||||
if (editForm.is_active !== editingUser.is_active) {
|
||||
const activating = editForm.is_active
|
||||
setPendingConfirm({
|
||||
title: activating ? 'Activate user' : 'Deactivate user',
|
||||
title: activating ? t('users.activateTitle') : t('users.deactivateTitle'),
|
||||
message: activating
|
||||
? `Activate user "${editingUser.username}"? They will be able to sign in again.`
|
||||
: `Deactivate user "${editingUser.username}"? They will no longer be able to sign in.`,
|
||||
confirmLabel: activating ? 'Activate' : 'Deactivate',
|
||||
? t('users.activateMessage', { username: editingUser.username })
|
||||
: t('users.deactivateMessage', { username: editingUser.username }),
|
||||
confirmLabel: activating ? t('users.activate') : t('users.deactivate'),
|
||||
danger: !activating,
|
||||
onConfirm: submitEdit,
|
||||
})
|
||||
@@ -115,11 +117,11 @@ export default function UsersPage() {
|
||||
const handleToggleActive = (u: User) => {
|
||||
const activating = !u.is_active
|
||||
setPendingConfirm({
|
||||
title: activating ? 'Activate user' : 'Deactivate user',
|
||||
title: activating ? t('users.activateTitle') : t('users.deactivateTitle'),
|
||||
message: activating
|
||||
? `Activate user "${u.username}"? They will be able to sign in again.`
|
||||
: `Deactivate user "${u.username}"? They will no longer be able to sign in.`,
|
||||
confirmLabel: activating ? 'Activate' : 'Deactivate',
|
||||
? t('users.activateMessage', { username: u.username })
|
||||
: t('users.deactivateMessage', { username: u.username }),
|
||||
confirmLabel: activating ? t('users.activate') : t('users.deactivate'),
|
||||
danger: !activating,
|
||||
onConfirm: async () => {
|
||||
await api.updateUser(u.id, { is_active: !u.is_active })
|
||||
@@ -130,9 +132,12 @@ export default function UsersPage() {
|
||||
|
||||
const handleDelete = (u: User) => {
|
||||
setPendingConfirm({
|
||||
title: 'Delete user',
|
||||
message: `Delete user "${u.username}"? This action cannot be undone.`,
|
||||
confirmLabel: 'Delete',
|
||||
title: t('users.deleteTitle'),
|
||||
message: t('users.deleteMessage', {
|
||||
username: u.username,
|
||||
cannotUndo: t('common.cannotUndo'),
|
||||
}),
|
||||
confirmLabel: t('common.delete'),
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
await api.deleteUser(u.id)
|
||||
@@ -148,7 +153,7 @@ export default function UsersPage() {
|
||||
try {
|
||||
await action()
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Action failed')
|
||||
alert(err instanceof Error ? err.message : t('errors.actionFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,9 +174,9 @@ export default function UsersPage() {
|
||||
/>
|
||||
|
||||
<div className="page-header">
|
||||
<h1>User Management</h1>
|
||||
<h1>{t('users.title')}</h1>
|
||||
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
||||
{showForm ? 'Cancel' : '+ Add User'}
|
||||
{showForm ? t('common.cancel') : t('users.addUser')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -179,10 +184,10 @@ export default function UsersPage() {
|
||||
|
||||
{showForm && (
|
||||
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
|
||||
<h2 style={{ marginTop: 0 }}>Create User</h2>
|
||||
<h2 style={{ marginTop: 0 }}>{t('users.createUser')}</h2>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<label>{t('common.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
@@ -191,7 +196,7 @@ export default function UsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<label>{t('common.username')}</label>
|
||||
<input
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
@@ -200,7 +205,7 @@ export default function UsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<label>{t('common.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
@@ -210,26 +215,28 @@ export default function UsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Role</label>
|
||||
<label>{t('common.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>
|
||||
<option value="user">{t('roles.user')}</option>
|
||||
<option value="admin">{t('roles.admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">Create</button>
|
||||
<button type="submit" className="btn btn-primary">{t('common.create')}</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingUser && (
|
||||
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
|
||||
<h2 style={{ marginTop: 0 }}>Edit User: {editingUser.username}</h2>
|
||||
<h2 style={{ marginTop: 0 }}>
|
||||
{t('users.editUser', { username: editingUser.username })}
|
||||
</h2>
|
||||
<form onSubmit={handleEdit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<label>{t('common.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
@@ -238,7 +245,7 @@ export default function UsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<label>{t('common.username')}</label>
|
||||
<input
|
||||
value={editForm.username}
|
||||
onChange={(e) => setEditForm({ ...editForm, username: e.target.value })}
|
||||
@@ -247,7 +254,7 @@ export default function UsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>New Password (leave empty to keep)</label>
|
||||
<label>{t('users.newPasswordHint')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={editForm.password}
|
||||
@@ -256,7 +263,7 @@ export default function UsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Role</label>
|
||||
<label>{t('common.role')}</label>
|
||||
<select
|
||||
value={editForm.role}
|
||||
onChange={(e) =>
|
||||
@@ -264,8 +271,8 @@ export default function UsersPage() {
|
||||
}
|
||||
disabled={editingUser.id === currentUser?.id}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">{t('roles.user')}</option>
|
||||
<option value="admin">{t('roles.admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -279,17 +286,17 @@ export default function UsersPage() {
|
||||
disabled={editingUser.id === currentUser?.id}
|
||||
style={{ width: 'auto', marginRight: 8 }}
|
||||
/>
|
||||
Active
|
||||
{t('common.active')}
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary">Save</button>
|
||||
<button type="submit" className="btn btn-primary">{t('common.save')}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setEditingUser(null)}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -297,18 +304,18 @@ export default function UsersPage() {
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
<p>{t('common.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>
|
||||
<th>{t('common.username')}</th>
|
||||
<th>{t('common.email')}</th>
|
||||
<th>{t('common.role')}</th>
|
||||
<th>{t('users.status')}</th>
|
||||
<th>{t('common.created')}</th>
|
||||
<th>{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -317,11 +324,11 @@ export default function UsersPage() {
|
||||
<td>
|
||||
<strong>{u.username}</strong>
|
||||
{u.id === currentUser?.id && (
|
||||
<span className="style-hint"> (you)</span>
|
||||
<span className="style-hint"> {t('common.you')}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{u.email}</td>
|
||||
<td>{u.role}</td>
|
||||
<td>{t(`roles.${u.role}`)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge badge-${u.is_active ? 'user' : 'admin'}`}
|
||||
@@ -331,30 +338,30 @@ export default function UsersPage() {
|
||||
: { background: '#fee2e2', color: '#dc2626' }
|
||||
}
|
||||
>
|
||||
{u.is_active ? 'active' : 'inactive'}
|
||||
{u.is_active ? t('status.active') : t('status.inactive')}
|
||||
</span>
|
||||
</td>
|
||||
<td>{new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td>{formatDate(u.created_at)}</td>
|
||||
<td style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => openEdit(u)}
|
||||
>
|
||||
Edit
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => handleToggleActive(u)}
|
||||
disabled={u.id === currentUser?.id}
|
||||
>
|
||||
{u.is_active ? 'Deactivate' : 'Activate'}
|
||||
{u.is_active ? t('users.deactivate') : t('users.activate')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDelete(u)}
|
||||
disabled={u.id === currentUser?.id}
|
||||
>
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/ConfirmDialog.tsx","./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/LogsPage.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"}
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/AuthShell.tsx","./src/components/ConfirmDialog.tsx","./src/components/LanguageSwitcher.tsx","./src/components/Navbar.tsx","./src/components/TableRowEditor.tsx","./src/components/VariableField.tsx","./src/context/AuthContext.tsx","./src/context/I18nContext.tsx","./src/i18n/index.ts","./src/i18n/locales/en.ts","./src/i18n/locales/ru.ts","./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/LogsPage.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"}
|
||||
Reference in New Issue
Block a user