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