Feature - add app localization

This commit is contained in:
2026-06-15 10:00:35 +03:00
parent a1f523628e
commit 7698cb09b4
27 changed files with 1043 additions and 321 deletions

View File

@@ -1,6 +1,7 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import Navbar from './components/Navbar'
import { AuthProvider, useAuth } from './context/AuthContext'
import { I18nProvider, useI18n } from './context/I18nContext'
import CreateTemplatePage from './pages/CreateTemplatePage'
import DocumentDetailPage from './pages/DocumentDetailPage'
import DocumentsPage from './pages/DocumentsPage'
@@ -18,7 +19,8 @@ import LogsPage from './pages/LogsPage'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
if (loading) return <div className="container"><p>Loading...</p></div>
const { t } = useI18n()
if (loading) return <div className="container"><p>{t('common.loading')}</p></div>
if (!user) return <Navigate to="/login" replace />
return <>{children}</>
}
@@ -53,9 +55,11 @@ function AppRoutes() {
export default function App() {
return (
<BrowserRouter>
<I18nProvider>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</I18nProvider>
</BrowserRouter>
)
}

View 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>
)
}

View File

@@ -1,3 +1,5 @@
import { useI18n } from '../context/I18nContext'
interface ConfirmDialogProps {
open: boolean
title: string
@@ -13,12 +15,14 @@ export default function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
confirmLabel,
cancelLabel,
danger = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { t } = useI18n()
if (!open) return null
return (
@@ -36,14 +40,14 @@ export default function ConfirmDialog({
<p style={{ color: 'var(--muted)', marginBottom: 24 }}>{message}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-secondary" onClick={onCancel}>
{cancelLabel}
{cancelLabel ?? t('common.cancel')}
</button>
<button
type="button"
className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}
onClick={onConfirm}
>
{confirmLabel}
{confirmLabel ?? t('common.confirm')}
</button>
</div>
</div>

View 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>
)
}

View File

@@ -1,8 +1,11 @@
import { Link, useLocation } from 'react-router-dom'
import LanguageSwitcher from './LanguageSwitcher'
import { useAuth } from '../context/AuthContext'
import { useI18n } from '../context/I18nContext'
export default function Navbar() {
const { user, logout } = useAuth()
const { t } = useI18n()
const location = useLocation()
const isActive = (path: string) =>
@@ -11,33 +14,34 @@ export default function Navbar() {
return (
<nav className="navbar">
<Link to="/" className="navbar-brand">
Document Template Editor
{t('nav.brand')}
</Link>
<div className="navbar-links">
<Link to="/templates" className={isActive('/templates') ? 'active' : ''}>
Templates
{t('nav.templates')}
</Link>
<Link to="/documents" className={isActive('/documents') ? 'active' : ''}>
Documents
{t('nav.documents')}
</Link>
{user?.role === 'admin' && (
<>
<Link to="/users" className={isActive('/users') ? 'active' : ''}>
Users
{t('nav.users')}
</Link>
<Link to="/logs" className={isActive('/logs') ? 'active' : ''}>
Logs
{t('nav.logs')}
</Link>
</>
)}
{user && (
<>
<LanguageSwitcher compact />
<span>
{user.username}{' '}
<span className={`badge badge-${user.role}`}>{user.role}</span>
<span className={`badge badge-${user.role}`}>{t(`roles.${user.role}`)}</span>
</span>
<button className="btn btn-secondary btn-sm" onClick={logout}>
Logout
{t('nav.logout')}
</button>
</>
)}

View File

@@ -1,4 +1,5 @@
import type { TemplateVariable } from '../types'
import { useI18n } from '../context/I18nContext'
import VariableField from './VariableField'
interface TableRowEditorProps {
@@ -14,6 +15,8 @@ export default function TableRowEditor({
rows,
onChange,
}: TableRowEditorProps) {
const { t } = useI18n()
const addRow = () => {
const newRow: Record<string, unknown> = {}
childVariables.forEach((v) => {
@@ -39,24 +42,27 @@ export default function TableRowEditor({
return (
<div className="form-group">
<label>{tableVariable.label} (repeating table rows)</label>
<label>
{tableVariable.label} {t('tableRow.repeatingRows')}
</label>
{tableStyle && (
<div className="style-hint">
Table styles preserved from template ({tableStyle.row_styles?.length ?? 0} reference
rows)
{t('tableRow.stylesPreserved', {
count: tableStyle.row_styles?.length ?? 0,
})}
</div>
)}
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="table-row-editor">
<div className="table-row-editor-header">
<strong>Row {rowIndex + 1}</strong>
<strong>{t('tableRow.row', { index: rowIndex + 1 })}</strong>
<button
type="button"
className="btn btn-danger btn-sm"
onClick={() => removeRow(rowIndex)}
>
Remove
{t('tableRow.remove')}
</button>
</div>
<div className="table-row-fields">
@@ -73,7 +79,7 @@ export default function TableRowEditor({
))}
<button type="button" className="btn btn-secondary" onClick={addRow}>
+ Add Row
{t('tableRow.addRow')}
</button>
</div>
)

View File

@@ -1,4 +1,5 @@
import type { TemplateVariable } from '../types'
import { useI18n } from '../context/I18nContext'
interface VariableFieldProps {
variable: TemplateVariable
@@ -7,12 +8,17 @@ interface VariableFieldProps {
}
export default function VariableField({ variable, value, onChange }: VariableFieldProps) {
const { t } = useI18n()
const styleHint = variable.style_params
? [
variable.style_params.font_name && `Font: ${variable.style_params.font_name}`,
variable.style_params.font_size && `Size: ${variable.style_params.font_size}pt`,
variable.style_params.bold && 'Bold',
variable.style_params.alignment && `Align: ${variable.style_params.alignment}`,
variable.style_params.font_name &&
t('variableField.font', { name: variable.style_params.font_name }),
variable.style_params.font_size &&
t('variableField.size', { size: variable.style_params.font_size }),
variable.style_params.bold && t('variableField.bold'),
variable.style_params.alignment &&
t('variableField.align', { align: variable.style_params.alignment }),
]
.filter(Boolean)
.join(' · ')
@@ -65,7 +71,9 @@ export default function VariableField({ variable, value, onChange }: VariableFie
{variable.is_required && ' *'}
</label>
{renderInput()}
{styleHint && <div className="style-hint">Style: {styleHint}</div>}
{styleHint && (
<div className="style-hint">{t('variableField.style', { hint: styleHint })}</div>
)}
</div>
)
}

View 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
}

View 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'
}

View 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

View 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}}',
},
}

View File

@@ -309,6 +309,7 @@ textarea {
}
.auth-page {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
@@ -379,6 +380,36 @@ textarea {
color: var(--success);
}
.lang-switcher {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
}
.lang-switcher-label {
color: var(--muted);
}
.lang-switcher select {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: white;
cursor: pointer;
}
.lang-switcher-compact select {
padding: 4px 8px;
font-size: 0.85rem;
}
.auth-lang-switcher {
position: absolute;
top: 16px;
right: 16px;
}
.dialog-overlay {
position: fixed;
inset: 0;

View File

@@ -1,10 +1,13 @@
import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { api } from '../api'
import AuthShell from '../components/AuthShell'
import { useI18n } from '../context/I18nContext'
export default function ActivatePage() {
const navigate = useNavigate()
const location = useLocation()
const { t } = useI18n()
const initialEmail = (location.state as { email?: string } | null)?.email ?? ''
const [email, setEmail] = useState(initialEmail)
@@ -23,7 +26,7 @@ export default function ActivatePage() {
setSuccess(result.message)
setTimeout(() => navigate('/login'), 1500)
} catch (err) {
setError(err instanceof Error ? err.message : 'Activation failed')
setError(err instanceof Error ? err.message : t('errors.activationFailed'))
} finally {
setLoading(false)
}
@@ -36,22 +39,22 @@ export default function ActivatePage() {
const result = await api.resendActivation(email)
setSuccess(result.message)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to resend code')
setError(err instanceof Error ? err.message : t('errors.resendFailed'))
}
}
return (
<div className="auth-page">
<AuthShell>
<div className="card auth-card">
<h1>Activate Account</h1>
<p>Enter the activation code sent to your email.</p>
<h1>{t('auth.activateTitle')}</h1>
<p>{t('auth.activateHint')}</p>
{error && <div className="error">{error}</div>}
{success && <div className="success">{success}</div>}
<form onSubmit={handleActivate}>
<div className="form-group">
<label>Email</label>
<label>{t('common.email')}</label>
<input
type="email"
value={email}
@@ -60,16 +63,16 @@ export default function ActivatePage() {
/>
</div>
<div className="form-group">
<label>Activation Code</label>
<label>{t('auth.activationCode')}</label>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
required
placeholder="6-digit code"
placeholder={t('auth.activationCodePlaceholder')}
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Please wait...' : 'Activate'}
{loading ? t('common.pleaseWait') : t('auth.activate')}
</button>
</form>
@@ -80,13 +83,13 @@ export default function ActivatePage() {
onClick={handleResend}
disabled={!email}
>
Resend Code
{t('auth.resendCode')}
</button>
<p style={{ marginTop: 16 }}>
<Link to="/login">Back to login</Link>
<Link to="/login">{t('auth.backToLogin')}</Link>
</p>
</div>
</div>
</AuthShell>
)
}

View File

@@ -1,9 +1,11 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { useI18n } from '../context/I18nContext'
export default function CreateTemplatePage() {
const navigate = useNavigate()
const { t } = useI18n()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [file, setFile] = useState<File | null>(null)
@@ -14,7 +16,7 @@ export default function CreateTemplatePage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!file) {
setError('Please select a .docx file')
setError(t('errors.selectDocx'))
return
}
setError('')
@@ -28,7 +30,7 @@ export default function CreateTemplatePage() {
)
navigate(`/templates/${template.id}/fill`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed')
setError(err instanceof Error ? err.message : t('errors.uploadFailed'))
} finally {
setLoading(false)
}
@@ -37,13 +39,13 @@ export default function CreateTemplatePage() {
return (
<div className="container">
<div className="page-header">
<h1>Upload Template</h1>
<h1>{t('templates.uploadTitle')}</h1>
</div>
<div className="steps">
<div className="step active">1. Upload .docx</div>
<div className="step">2. Fill Variables</div>
<div className="step">3. Preview & Export</div>
<div className="step active">{t('templates.stepUpload')}</div>
<div className="step">{t('templates.stepFill')}</div>
<div className="step">{t('templates.stepPreview')}</div>
</div>
<div className="card" style={{ maxWidth: 600 }}>
@@ -51,11 +53,11 @@ export default function CreateTemplatePage() {
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Template Name *</label>
<label>{t('templates.templateName')}</label>
<input value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="form-group">
<label>Description</label>
<label>{t('templates.description')}</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
@@ -63,7 +65,7 @@ export default function CreateTemplatePage() {
/>
</div>
<div className="form-group">
<label>.docx File with Jinja2 Variables *</label>
<label>{t('templates.docxFile')}</label>
<input
type="file"
accept=".docx"
@@ -71,8 +73,7 @@ export default function CreateTemplatePage() {
required
/>
<div className="style-hint" style={{ marginTop: 8 }}>
Use {'{{ variable }}'} for text fields. For repeating table rows use{' '}
{'{%tr for item in items %}'} ... {'{%tr endfor %}'}.
{t('templates.docxHint')}
</div>
</div>
<div className="form-group">
@@ -83,19 +84,19 @@ export default function CreateTemplatePage() {
onChange={(e) => setIsPublic(e.target.checked)}
style={{ width: 'auto', marginRight: 8 }}
/>
Make template public (visible to all users)
{t('templates.makePublic')}
</label>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Parsing...' : 'Upload & Continue'}
{loading ? t('templates.parsing') : t('templates.uploadContinue')}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate('/templates')}
>
Cancel
{t('common.cancel')}
</button>
</div>
</form>

View File

@@ -3,12 +3,14 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, downloadWithAuth } from '../api'
import ConfirmDialog from '../components/ConfirmDialog'
import { useAuth } from '../context/AuthContext'
import { useI18n } from '../context/I18nContext'
import type { DocumentTemplate, FilledDocument } from '../types'
export default function DocumentDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { user } = useAuth()
const { t, formatDateTime } = useI18n()
const [document, setDocument] = useState<FilledDocument | null>(null)
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
@@ -21,8 +23,8 @@ export default function DocumentDetailPage() {
try {
const doc = await api.getDocument(Number(id))
setDocument(doc)
const t = await api.getTemplate(doc.template_id)
setTemplate(t)
const loadedTemplate = await api.getTemplate(doc.template_id)
setTemplate(loadedTemplate)
const preview = await api.previewDocument({
template_id: doc.template_id,
name: doc.name,
@@ -30,7 +32,7 @@ export default function DocumentDetailPage() {
})
setPreviewHtml(preview.html)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load')
setError(err instanceof Error ? err.message : t('errors.loadFailed'))
} finally {
setLoading(false)
}
@@ -46,7 +48,7 @@ export default function DocumentDetailPage() {
`${document.name}.docx`,
)
} catch (err) {
alert(err instanceof Error ? err.message : 'Export failed')
alert(err instanceof Error ? err.message : t('errors.exportFailed'))
}
}
@@ -58,7 +60,7 @@ export default function DocumentDetailPage() {
`${document.name}.pdf`,
)
} catch (err) {
alert(err instanceof Error ? err.message : 'PDF export failed. Install LibreOffice for PDF support.')
alert(err instanceof Error ? err.message : t('errors.exportPdfFailed'))
}
}
@@ -68,23 +70,26 @@ export default function DocumentDetailPage() {
await api.deleteDocument(document.id)
navigate('/documents')
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed')
alert(err instanceof Error ? err.message : t('errors.deleteFailed'))
}
}
const canManage =
user?.role === 'admin' || document?.owner_id === user?.id
if (loading) return <div className="container"><p>Loading...</p></div>
if (loading) return <div className="container"><p>{t('common.loading')}</p></div>
if (!document) return <div className="container"><div className="error">{error}</div></div>
return (
<div className="container">
<ConfirmDialog
open={showDeleteConfirm}
title="Delete document"
message={`Delete document "${document.name}"? This action cannot be undone.`}
confirmLabel="Delete"
title={t('documents.deleteTitle')}
message={t('documents.deleteMessage', {
name: document.name,
cannotUndo: t('common.cannotUndo'),
})}
confirmLabel={t('common.delete')}
danger
onConfirm={() => void handleDelete()}
onCancel={() => setShowDeleteConfirm(false)}
@@ -95,30 +100,31 @@ export default function DocumentDetailPage() {
<h1>{document.name}</h1>
{template && (
<div className="style-hint">
From template: {template.name}
{document.owner_username && ` · Owner: ${document.owner_username}`}
{' · '}Saved {new Date(document.created_at).toLocaleString()}
{t('documents.fromTemplate', { name: template.name })}
{document.owner_username && ` · ${t('documents.ownerLine', { name: document.owner_username })}`}
{' · '}
{t('documents.saved', { date: formatDateTime(document.created_at) })}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={handleExportDocx}>
Export DOCX
{t('documents.exportDocx')}
</button>
<button className="btn btn-secondary" onClick={handleExportPdf}>
Export PDF
{t('documents.exportPdf')}
</button>
<Link to={`/documents/${document.id}/edit`} className="btn btn-secondary">
Edit
{t('common.edit')}
</Link>
{template && (
<Link to={`/templates/${template.id}/fill`} className="btn btn-secondary">
Reuse Template
{t('documents.reuseTemplate')}
</Link>
)}
{canManage && (
<button className="btn btn-danger" onClick={() => setShowDeleteConfirm(true)}>
Delete
{t('common.delete')}
</button>
)}
</div>
@@ -128,19 +134,19 @@ export default function DocumentDetailPage() {
<div className="grid-2">
<div className="card">
<h2 style={{ marginTop: 0 }}>Preview</h2>
<h2 style={{ marginTop: 0 }}>{t('documents.preview')}</h2>
{previewHtml ? (
<div
className="doc-preview"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
) : (
<p>Preview unavailable</p>
<p>{t('documents.previewUnavailable')}</p>
)}
</div>
<div className="card">
<h2 style={{ marginTop: 0 }}>Field Data</h2>
<h2 style={{ marginTop: 0 }}>{t('documents.fieldData')}</h2>
<pre style={{
background: '#f8fafc',
padding: 16,

View File

@@ -3,10 +3,12 @@ import { Link } from 'react-router-dom'
import { api } from '../api'
import ConfirmDialog from '../components/ConfirmDialog'
import { useAuth } from '../context/AuthContext'
import { useI18n } from '../context/I18nContext'
import type { FilledDocument } from '../types'
export default function DocumentsPage() {
const { user } = useAuth()
const { t, formatDateTime } = useI18n()
const [documents, setDocuments] = useState<FilledDocument[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -15,7 +17,7 @@ export default function DocumentsPage() {
const load = () => {
api.listDocuments()
.then(setDocuments)
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load'))
.catch((err) => setError(err instanceof Error ? err.message : t('errors.loadFailed')))
.finally(() => setLoading(false))
}
@@ -30,7 +32,7 @@ export default function DocumentsPage() {
setDocuments((list) => list.filter((d) => d.id !== deleteTarget.id))
setDeleteTarget(null)
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed')
alert(err instanceof Error ? err.message : t('errors.deleteFailed'))
}
}
@@ -40,43 +42,46 @@ export default function DocumentsPage() {
<div className="container">
<ConfirmDialog
open={deleteTarget !== null}
title="Delete document"
title={t('documents.deleteTitle')}
message={
deleteTarget
? `Delete document "${deleteTarget.name}"? This action cannot be undone.`
? t('documents.deleteMessage', {
name: deleteTarget.name,
cannotUndo: t('common.cannotUndo'),
})
: ''
}
confirmLabel="Delete"
confirmLabel={t('common.delete')}
danger
onConfirm={() => void handleDelete()}
onCancel={() => setDeleteTarget(null)}
/>
<div className="page-header">
<h1>Documents</h1>
<h1>{t('documents.title')}</h1>
<Link to="/templates" className="btn btn-primary">
Create from Template
{t('documents.createFromTemplate')}
</Link>
</div>
{error && <div className="error">{error}</div>}
{loading ? (
<p>Loading...</p>
<p>{t('common.loading')}</p>
) : documents.length === 0 ? (
<div className="card empty-state">
<p>No filled documents yet. Choose a template and fill in the variables.</p>
<p>{t('documents.empty')}</p>
</div>
) : (
<div className="card">
<table className="table">
<thead>
<tr>
<th>Name</th>
{isAdmin && <th>Owner</th>}
<th>Template ID</th>
<th>Created</th>
<th>Actions</th>
<th>{t('common.name')}</th>
{isAdmin && <th>{t('common.owner')}</th>}
<th>{t('documents.templateId')}</th>
<th>{t('common.created')}</th>
<th>{t('common.actions')}</th>
</tr>
</thead>
<tbody>
@@ -85,20 +90,20 @@ export default function DocumentsPage() {
<td><strong>{d.name}</strong></td>
{isAdmin && <td>{d.owner_username ?? `#${d.owner_id}`}</td>}
<td>{d.template_id}</td>
<td>{new Date(d.created_at).toLocaleString()}</td>
<td>{formatDateTime(d.created_at)}</td>
<td style={{ display: 'flex', gap: 8 }}>
<Link to={`/documents/${d.id}`} className="btn btn-primary btn-sm">
View
{t('common.view')}
</Link>
<Link to={`/documents/${d.id}/edit`} className="btn btn-secondary btn-sm">
Edit
{t('common.edit')}
</Link>
{(isAdmin || d.owner_id === user?.id) && (
<button
className="btn btn-danger btn-sm"
onClick={() => setDeleteTarget(d)}
>
Delete
{t('common.delete')}
</button>
)}
</td>

View File

@@ -3,11 +3,13 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import TableRowEditor from '../components/TableRowEditor'
import VariableField from '../components/VariableField'
import { useI18n } from '../context/I18nContext'
import type { DocumentTemplate } from '../types'
export default function EditDocumentPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { t } = useI18n()
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
const [fieldData, setFieldData] = useState<Record<string, unknown>>({})
const [documentName, setDocumentName] = useState('')
@@ -21,12 +23,12 @@ export default function EditDocumentPage() {
const load = async () => {
try {
const doc = await api.getDocument(Number(id))
const t = await api.getTemplate(doc.template_id)
setTemplate(t)
const loadedTemplate = await api.getTemplate(doc.template_id)
setTemplate(loadedTemplate)
setDocumentName(doc.name)
setFieldData(doc.field_data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load document')
setError(err instanceof Error ? err.message : t('errors.loadDocumentFailed'))
} finally {
setLoading(false)
}
@@ -64,7 +66,7 @@ export default function EditDocumentPage() {
setPreviewHtml(result.html)
setStep(3)
} catch (err) {
setError(err instanceof Error ? err.message : 'Preview failed')
setError(err instanceof Error ? err.message : t('errors.previewFailed'))
} finally {
setSubmitting(false)
}
@@ -81,23 +83,23 @@ export default function EditDocumentPage() {
})
navigate(`/documents/${doc.id}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
setError(err instanceof Error ? err.message : t('errors.saveFailed'))
} finally {
setSubmitting(false)
}
}
if (loading) return <div className="container"><p>Loading...</p></div>
if (!template) return <div className="container"><div className="error">{error || 'Not found'}</div></div>
if (loading) return <div className="container"><p>{t('common.loading')}</p></div>
if (!template) return <div className="container"><div className="error">{error || t('common.notFound')}</div></div>
return (
<div className="container">
<div className="page-header">
<div>
<h1>Edit Document</h1>
<div className="style-hint">Template: {template.name}</div>
<h1>{t('documents.editTitle')}</h1>
<div className="style-hint">{t('documents.templateLine', { name: template.name })}</div>
</div>
<Link to={`/documents/${id}`} className="btn btn-secondary">Back</Link>
<Link to={`/documents/${id}`} className="btn btn-secondary">{t('common.back')}</Link>
</div>
{error && <div className="error">{error}</div>}
@@ -105,7 +107,7 @@ export default function EditDocumentPage() {
{step === 2 && (
<div className="card" style={{ maxWidth: 720 }}>
<div className="form-group">
<label>Document Name *</label>
<label>{t('templates.documentName')}</label>
<input
value={documentName}
onChange={(e) => setDocumentName(e.target.value)}
@@ -146,7 +148,7 @@ export default function EditDocumentPage() {
onClick={handlePreview}
disabled={submitting}
>
{submitting ? 'Rendering...' : 'Preview'}
{submitting ? t('templates.rendering') : t('documents.previewBtn')}
</button>
</div>
</div>
@@ -162,14 +164,14 @@ export default function EditDocumentPage() {
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn-secondary" onClick={() => setStep(2)}>
Edit Fields
{t('templates.editFields')}
</button>
<button
className="btn btn-primary"
onClick={handleSave}
disabled={submitting}
>
{submitting ? 'Saving...' : 'Save Changes'}
{submitting ? t('templates.saving') : t('documents.saveChanges')}
</button>
</div>
</div>

View File

@@ -3,11 +3,13 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import TableRowEditor from '../components/TableRowEditor'
import VariableField from '../components/VariableField'
import { useI18n } from '../context/I18nContext'
import type { DocumentTemplate } from '../types'
export default function FillTemplatePage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { t } = useI18n()
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
const [fieldData, setFieldData] = useState<Record<string, unknown>>({})
const [documentName, setDocumentName] = useState('')
@@ -20,12 +22,12 @@ export default function FillTemplatePage() {
useEffect(() => {
const load = async () => {
try {
const t = await api.getTemplate(Number(id))
setTemplate(t)
setDocumentName(`${t.name} - ${new Date().toLocaleDateString()}`)
const loaded = await api.getTemplate(Number(id))
setTemplate(loaded)
setDocumentName(`${loaded.name} - ${new Date().toLocaleDateString()}`)
const initial: Record<string, unknown> = {}
t.variables.forEach((v) => {
loaded.variables.forEach((v) => {
if (v.field_type === 'table_row') {
initial[v.name] = []
} else if (!v.parent_variable) {
@@ -34,7 +36,7 @@ export default function FillTemplatePage() {
})
setFieldData(initial)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load template')
setError(err instanceof Error ? err.message : t('errors.loadTemplateFailed'))
} finally {
setLoading(false)
}
@@ -72,7 +74,7 @@ export default function FillTemplatePage() {
setPreviewHtml(result.html)
setStep(3)
} catch (err) {
setError(err instanceof Error ? err.message : 'Preview failed')
setError(err instanceof Error ? err.message : t('errors.previewFailed'))
} finally {
setSubmitting(false)
}
@@ -90,29 +92,31 @@ export default function FillTemplatePage() {
})
navigate(`/documents/${doc.id}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
setError(err instanceof Error ? err.message : t('errors.saveFailed'))
} finally {
setSubmitting(false)
}
}
if (loading) return <div className="container"><p>Loading...</p></div>
if (!template) return <div className="container"><div className="error">{error || 'Not found'}</div></div>
if (loading) return <div className="container"><p>{t('common.loading')}</p></div>
if (!template) return <div className="container"><div className="error">{error || t('common.notFound')}</div></div>
return (
<div className="container">
<div className="page-header">
<div>
<h1>Fill: {template.name}</h1>
<div className="style-hint">Reusable template · {template.variables.length} variables detected</div>
<h1>{t('templates.fillTitle', { name: template.name })}</h1>
<div className="style-hint">
{t('templates.fillHint', { count: template.variables.length })}
</div>
<Link to="/templates" className="btn btn-secondary">Back</Link>
</div>
<Link to="/templates" className="btn btn-secondary">{t('common.back')}</Link>
</div>
<div className="steps">
<div className="step done">1. Upload .docx</div>
<div className={`step ${step === 2 ? 'active' : step > 2 ? 'done' : ''}`}>2. Fill Variables</div>
<div className={`step ${step === 3 ? 'active' : ''}`}>3. Preview & Export</div>
<div className="step done">{t('templates.stepUpload')}</div>
<div className={`step ${step === 2 ? 'active' : step > 2 ? 'done' : ''}`}>{t('templates.stepFill')}</div>
<div className={`step ${step === 3 ? 'active' : ''}`}>{t('templates.stepPreview')}</div>
</div>
{error && <div className="error">{error}</div>}
@@ -120,10 +124,10 @@ export default function FillTemplatePage() {
{step === 2 && (
<div className="grid-2">
<div className="card">
<h2 style={{ marginTop: 0 }}>Document Fields</h2>
<h2 style={{ marginTop: 0 }}>{t('templates.documentFields')}</h2>
<div className="form-group">
<label>Document Name *</label>
<label>{t('templates.documentName')}</label>
<input
value={documentName}
onChange={(e) => setDocumentName(e.target.value)}
@@ -164,22 +168,22 @@ export default function FillTemplatePage() {
onClick={handlePreview}
disabled={submitting}
>
{submitting ? 'Rendering...' : 'Preview Document'}
{submitting ? t('templates.rendering') : t('templates.previewDocument')}
</button>
</div>
</div>
<div className="card">
<h2 style={{ marginTop: 0 }}>Template Info</h2>
<p><strong>File:</strong> {template.original_filename}</p>
<h2 style={{ marginTop: 0 }}>{t('templates.templateInfo')}</h2>
<p><strong>{t('common.file')}:</strong> {template.original_filename}</p>
{template.description && <p>{template.description}</p>}
<h3>Detected Variables</h3>
<h3>{t('templates.detectedVariables')}</h3>
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Style</th>
<th>{t('common.name')}</th>
<th>{t('templates.type')}</th>
<th>{t('templates.style')}</th>
</tr>
</thead>
<tbody>
@@ -188,7 +192,7 @@ export default function FillTemplatePage() {
<td>{v.name}</td>
<td>{v.field_type}</td>
<td className="style-hint">
{v.style_params?.font_name ?? '—'}
{v.style_params?.font_name ?? t('common.empty')}
{v.style_params?.alignment ? ` / ${v.style_params.alignment}` : ''}
</td>
</tr>
@@ -209,14 +213,14 @@ export default function FillTemplatePage() {
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn-secondary" onClick={() => setStep(2)}>
Edit Fields
{t('templates.editFields')}
</button>
<button
className="btn btn-primary"
onClick={handleSave}
disabled={submitting}
>
{submitting ? 'Saving...' : 'Save & Export'}
{submitting ? t('templates.saving') : t('templates.saveExport')}
</button>
</div>
</div>

View File

@@ -1,8 +1,11 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
import AuthShell from '../components/AuthShell'
import { useI18n } from '../context/I18nContext'
export default function ForgotPasswordPage() {
const { t } = useI18n()
const [email, setEmail] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
@@ -17,24 +20,24 @@ export default function ForgotPasswordPage() {
const result = await api.forgotPassword(email)
setSuccess(result.message)
} catch (err) {
setError(err instanceof Error ? err.message : 'Request failed')
setError(err instanceof Error ? err.message : t('errors.requestFailed'))
} finally {
setLoading(false)
}
}
return (
<div className="auth-page">
<AuthShell>
<div className="card auth-card">
<h1>Forgot Password</h1>
<p>Enter your email and we will send a reset code.</p>
<h1>{t('auth.forgotTitle')}</h1>
<p>{t('auth.forgotHint')}</p>
{error && <div className="error">{error}</div>}
{success && <div className="success">{success}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Email</label>
<label>{t('common.email')}</label>
<input
type="email"
value={email}
@@ -43,17 +46,17 @@ export default function ForgotPasswordPage() {
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Please wait...' : 'Send Reset Code'}
{loading ? t('common.pleaseWait') : t('auth.sendResetCode')}
</button>
</form>
<p style={{ marginTop: 16 }}>
Have a code? <Link to="/reset-password">Reset password</Link>
{t('auth.haveCode')} <Link to="/reset-password">{t('auth.resetPasswordLink')}</Link>
</p>
<p>
<Link to="/login">Back to login</Link>
<Link to="/login">{t('auth.backToLogin')}</Link>
</p>
</div>
</div>
</AuthShell>
)
}

View File

@@ -1,9 +1,12 @@
import { useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import AuthShell from '../components/AuthShell'
import { useAuth } from '../context/AuthContext'
import { useI18n } from '../context/I18nContext'
export default function LoginPage() {
const { user, login } = useAuth()
const { t } = useI18n()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
@@ -18,23 +21,23 @@ export default function LoginPage() {
try {
await login(username, password)
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
setError(err instanceof Error ? err.message : t('errors.loginFailed'))
} finally {
setLoading(false)
}
}
return (
<div className="auth-page">
<AuthShell>
<div className="card auth-card">
<h1>Document Template Editor</h1>
<p>Upload Jinja2 .docx templates, fill variables, preview and export.</p>
<h1>{t('nav.brand')}</h1>
<p>{t('auth.tagline')}</p>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Username</label>
<label>{t('common.username')}</label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -42,7 +45,7 @@ export default function LoginPage() {
/>
</div>
<div className="form-group">
<label>Password</label>
<label>{t('common.password')}</label>
<input
type="password"
value={password}
@@ -51,22 +54,22 @@ export default function LoginPage() {
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Please wait...' : 'Login'}
{loading ? t('common.pleaseWait') : t('auth.login')}
</button>
</form>
<div style={{ marginTop: 20, fontSize: '0.9rem' }}>
<p style={{ margin: '8px 0' }}>
<Link to="/register">Create account</Link>
<Link to="/register">{t('auth.createAccount')}</Link>
</p>
<p style={{ margin: '8px 0' }}>
<Link to="/activate">Activate account</Link>
<Link to="/activate">{t('auth.activateAccount')}</Link>
</p>
<p style={{ margin: '8px 0' }}>
<Link to="/forgot-password">Forgot password?</Link>
<Link to="/forgot-password">{t('auth.forgotPassword')}</Link>
</p>
</div>
</div>
</div>
</AuthShell>
)
}

View File

@@ -2,20 +2,25 @@ import { useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../context/AuthContext'
import { useI18n } from '../context/I18nContext'
import type { AuditLog } from '../types'
function formatAction(action: string): string {
return action.replace(/\./g, ' · ').replace(/_/g, ' ')
}
function formatDetails(details: Record<string, unknown> | null): string {
if (!details) return '—'
function formatDetails(
details: Record<string, unknown> | null,
emptyLabel: string,
): string {
if (!details) return emptyLabel
const text = JSON.stringify(details, null, 0)
return text.length > 120 ? `${text.slice(0, 120)}` : text
}
export default function LogsPage() {
const { user: currentUser } = useAuth()
const { t, formatDateTime } = useI18n()
const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -27,7 +32,7 @@ export default function LogsPage() {
try {
setLogs(await api.listLogs({ action: action || undefined }))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load logs')
setError(err instanceof Error ? err.message : t('errors.loadLogsFailed'))
} finally {
setLoading(false)
}
@@ -49,21 +54,21 @@ export default function LogsPage() {
return (
<div className="container">
<div className="page-header">
<h1>Activity Logs</h1>
<h1>{t('logs.title')}</h1>
</div>
<form onSubmit={handleFilter} className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="form-group" style={{ marginBottom: 0, flex: '1 1 240px' }}>
<label>Filter by action</label>
<label>{t('logs.filterByAction')}</label>
<input
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
placeholder="e.g. user.delete, template.create"
placeholder={t('logs.filterPlaceholder')}
/>
</div>
<button type="submit" className="btn btn-primary">
Apply
{t('common.apply')}
</button>
<button
type="button"
@@ -73,7 +78,7 @@ export default function LogsPage() {
load()
}}
>
Clear
{t('common.clear')}
</button>
</div>
</form>
@@ -81,48 +86,48 @@ export default function LogsPage() {
{error && <div className="error">{error}</div>}
{loading ? (
<p>Loading...</p>
<p>{t('common.loading')}</p>
) : (
<div className="card" style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th>Time</th>
<th>User</th>
<th>Action</th>
<th>Resource</th>
<th>Details</th>
<th>IP</th>
<th>{t('logs.time')}</th>
<th>{t('logs.user')}</th>
<th>{t('logs.action')}</th>
<th>{t('logs.resource')}</th>
<th>{t('logs.details')}</th>
<th>{t('logs.ip')}</th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr>
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--muted)' }}>
No log entries found
{t('logs.empty')}
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id}>
<td style={{ whiteSpace: 'nowrap' }}>
{new Date(log.created_at).toLocaleString()}
{formatDateTime(log.created_at)}
</td>
<td>{log.username || '—'}</td>
<td>{log.username || t('common.empty')}</td>
<td>
<code className="log-action">{formatAction(log.action)}</code>
</td>
<td>
{log.resource_type
? `${log.resource_type}${log.resource_id != null ? ` #${log.resource_id}` : ''}`
: '—'}
: t('common.empty')}
</td>
<td>
<span className="style-hint" title={JSON.stringify(log.details, null, 2)}>
{formatDetails(log.details)}
{formatDetails(log.details, t('common.empty'))}
</span>
</td>
<td>{log.ip_address || '—'}</td>
<td>{log.ip_address || t('common.empty')}</td>
</tr>
))
)}

View File

@@ -1,10 +1,13 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import AuthShell from '../components/AuthShell'
import { useAuth } from '../context/AuthContext'
import { useI18n } from '../context/I18nContext'
export default function RegisterPage() {
const { register } = useAuth()
const navigate = useNavigate()
const { t } = useI18n()
const [email, setEmail] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
@@ -19,7 +22,7 @@ export default function RegisterPage() {
setSuccess('')
if (password !== confirmPassword) {
setError('Passwords do not match')
setError(t('errors.passwordsMismatch'))
return
}
@@ -31,24 +34,24 @@ export default function RegisterPage() {
navigate('/activate', { state: { email: result.email } })
}, 1500)
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed')
setError(err instanceof Error ? err.message : t('errors.registrationFailed'))
} finally {
setLoading(false)
}
}
return (
<div className="auth-page">
<AuthShell>
<div className="card auth-card">
<h1>Create Account</h1>
<p>Register to use document templates. You will receive an activation code by email.</p>
<h1>{t('auth.registerTitle')}</h1>
<p>{t('auth.registerHint')}</p>
{error && <div className="error">{error}</div>}
{success && <div className="success">{success}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Email</label>
<label>{t('common.email')}</label>
<input
type="email"
value={email}
@@ -57,7 +60,7 @@ export default function RegisterPage() {
/>
</div>
<div className="form-group">
<label>Username</label>
<label>{t('common.username')}</label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -66,7 +69,7 @@ export default function RegisterPage() {
/>
</div>
<div className="form-group">
<label>Password</label>
<label>{t('common.password')}</label>
<input
type="password"
value={password}
@@ -76,7 +79,7 @@ export default function RegisterPage() {
/>
</div>
<div className="form-group">
<label>Confirm Password</label>
<label>{t('common.confirmPassword')}</label>
<input
type="password"
value={confirmPassword}
@@ -86,14 +89,14 @@ export default function RegisterPage() {
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Please wait...' : 'Register'}
{loading ? t('common.pleaseWait') : t('auth.register')}
</button>
</form>
<p style={{ marginTop: 16 }}>
Already have an account? <Link to="/login">Login</Link>
{t('auth.alreadyHaveAccount')} <Link to="/login">{t('auth.login')}</Link>
</p>
</div>
</div>
</AuthShell>
)
}

View File

@@ -1,9 +1,12 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { api } from '../api'
import AuthShell from '../components/AuthShell'
import { useI18n } from '../context/I18nContext'
export default function ResetPasswordPage() {
const navigate = useNavigate()
const { t } = useI18n()
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const [password, setPassword] = useState('')
@@ -18,7 +21,7 @@ export default function ResetPasswordPage() {
setSuccess('')
if (password !== confirmPassword) {
setError('Passwords do not match')
setError(t('errors.passwordsMismatch'))
return
}
@@ -33,24 +36,24 @@ export default function ResetPasswordPage() {
setSuccess(result.message)
setTimeout(() => navigate('/login'), 1500)
} catch (err) {
setError(err instanceof Error ? err.message : 'Reset failed')
setError(err instanceof Error ? err.message : t('errors.resetFailed'))
} finally {
setLoading(false)
}
}
return (
<div className="auth-page">
<AuthShell>
<div className="card auth-card">
<h1>Reset Password</h1>
<p>Enter the code from your email and choose a new password.</p>
<h1>{t('auth.resetTitle')}</h1>
<p>{t('auth.resetHint')}</p>
{error && <div className="error">{error}</div>}
{success && <div className="success">{success}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Email</label>
<label>{t('common.email')}</label>
<input
type="email"
value={email}
@@ -59,7 +62,7 @@ export default function ResetPasswordPage() {
/>
</div>
<div className="form-group">
<label>Reset Code</label>
<label>{t('auth.resetCode')}</label>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
@@ -67,7 +70,7 @@ export default function ResetPasswordPage() {
/>
</div>
<div className="form-group">
<label>New Password</label>
<label>{t('auth.newPassword')}</label>
<input
type="password"
value={password}
@@ -77,7 +80,7 @@ export default function ResetPasswordPage() {
/>
</div>
<div className="form-group">
<label>Confirm Password</label>
<label>{t('common.confirmPassword')}</label>
<input
type="password"
value={confirmPassword}
@@ -87,14 +90,14 @@ export default function ResetPasswordPage() {
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Please wait...' : 'Update Password'}
{loading ? t('common.pleaseWait') : t('auth.updatePassword')}
</button>
</form>
<p style={{ marginTop: 16 }}>
<Link to="/login">Back to login</Link>
<Link to="/login">{t('auth.backToLogin')}</Link>
</p>
</div>
</div>
</AuthShell>
)
}

View File

@@ -2,11 +2,13 @@ import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { api, downloadWithAuth } from '../api'
import { useAuth } from '../context/AuthContext'
import { useI18n } from '../context/I18nContext'
import type { DocumentTemplate } from '../types'
export default function TemplateDetailPage() {
const { id } = useParams<{ id: string }>()
const { user } = useAuth()
const { t, formatDateTime } = useI18n()
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
@@ -14,7 +16,7 @@ export default function TemplateDetailPage() {
useEffect(() => {
api.getTemplate(Number(id))
.then(setTemplate)
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load'))
.catch((err) => setError(err instanceof Error ? err.message : t('errors.loadFailed')))
.finally(() => setLoading(false))
}, [id])
@@ -26,7 +28,7 @@ export default function TemplateDetailPage() {
})
setTemplate(updated)
} catch (err) {
alert(err instanceof Error ? err.message : 'Update failed')
alert(err instanceof Error ? err.message : t('errors.updateFailed'))
}
}
@@ -38,11 +40,11 @@ export default function TemplateDetailPage() {
template.original_filename,
)
} catch (err) {
alert(err instanceof Error ? err.message : 'Download failed')
alert(err instanceof Error ? err.message : t('errors.downloadFailed'))
}
}
if (loading) return <div className="container"><p>Loading...</p></div>
if (loading) return <div className="container"><p>{t('common.loading')}</p></div>
if (!template) return <div className="container"><div className="error">{error}</div></div>
const canManage = template.is_owner || user?.role === 'admin'
@@ -53,38 +55,38 @@ export default function TemplateDetailPage() {
<h1>{template.name}</h1>
<div style={{ display: 'flex', gap: 8 }}>
<Link to={`/templates/${template.id}/fill`} className="btn btn-primary">
Use Template
{t('templates.useTemplate')}
</Link>
<button className="btn btn-secondary" onClick={handleDownloadSource}>
Download Source
{t('templates.downloadSource')}
</button>
{canManage && (
<button className="btn btn-secondary" onClick={handleTogglePublic}>
{template.is_public ? 'Make Private' : 'Make Public'}
{template.is_public ? t('visibility.makePrivate') : t('visibility.makePublic')}
</button>
)}
</div>
</div>
<div className="card">
<p><strong>File:</strong> {template.original_filename}</p>
<p><strong>Owner:</strong> {template.owner_username ?? `#${template.owner_id}`}</p>
<p><strong>{t('common.file')}:</strong> {template.original_filename}</p>
<p><strong>{t('common.owner')}:</strong> {template.owner_username ?? `#${template.owner_id}`}</p>
<p>
<strong>Visibility:</strong>{' '}
{template.is_public ? 'Public (visible to all users)' : 'Private'}
<strong>{t('templates.visibility')}:</strong>{' '}
{template.is_public ? t('visibility.publicFull') : t('visibility.privateFull')}
</p>
{template.description && <p>{template.description}</p>}
<p><strong>Created:</strong> {new Date(template.created_at).toLocaleString()}</p>
<p><strong>{t('common.created')}:</strong> {formatDateTime(template.created_at)}</p>
<h2>Variables ({template.variables.length})</h2>
<h2>{t('templates.variablesCount', { count: template.variables.length })}</h2>
<table className="table">
<thead>
<tr>
<th>Label</th>
<th>Name</th>
<th>Type</th>
<th>Required</th>
<th>Style Parameters</th>
<th>{t('templates.label')}</th>
<th>{t('common.name')}</th>
<th>{t('templates.type')}</th>
<th>{t('templates.required')}</th>
<th>{t('templates.styleParams')}</th>
</tr>
</thead>
<tbody>
@@ -93,11 +95,11 @@ export default function TemplateDetailPage() {
<td>{v.label}</td>
<td><code>{v.name}</code></td>
<td>{v.field_type}</td>
<td>{v.is_required ? 'Yes' : 'No'}</td>
<td>{v.is_required ? t('common.yes') : t('common.no')}</td>
<td className="style-hint">
{v.style_params
? JSON.stringify(v.style_params, null, 0).slice(0, 80) + '...'
: '—'}
: t('common.empty')}
</td>
</tr>
))}

View File

@@ -3,10 +3,12 @@ import { Link } from 'react-router-dom'
import { api, downloadWithAuth } from '../api'
import ConfirmDialog from '../components/ConfirmDialog'
import { useAuth } from '../context/AuthContext'
import { useI18n } from '../context/I18nContext'
import type { TemplateListItem } from '../types'
export default function TemplatesPage() {
const { user } = useAuth()
const { t, formatDate } = useI18n()
const [templates, setTemplates] = useState<TemplateListItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -16,7 +18,7 @@ export default function TemplatesPage() {
try {
setTemplates(await api.listTemplates())
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load templates')
setError(err instanceof Error ? err.message : t('errors.loadTemplatesFailed'))
} finally {
setLoading(false)
}
@@ -33,71 +35,69 @@ export default function TemplatesPage() {
setTemplates((list) => list.filter((x) => x.id !== deleteTarget.id))
setDeleteTarget(null)
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed')
alert(err instanceof Error ? err.message : t('errors.deleteFailed'))
}
}
const handleTogglePublic = async (t: TemplateListItem) => {
const handleTogglePublic = async (item: TemplateListItem) => {
try {
const updated = await api.updateTemplate(t.id, { is_public: !t.is_public })
const updated = await api.updateTemplate(item.id, { is_public: !item.is_public })
setTemplates((list) =>
list.map((x) =>
x.id === t.id
? { ...x, is_public: updated.is_public }
: x,
x.id === item.id ? { ...x, is_public: updated.is_public } : x,
),
)
} catch (err) {
alert(err instanceof Error ? err.message : 'Update failed')
alert(err instanceof Error ? err.message : t('errors.updateFailed'))
}
}
const handleDownloadSource = async (t: TemplateListItem) => {
const handleDownloadSource = async (item: TemplateListItem) => {
try {
await downloadWithAuth(
api.getTemplateSourceUrl(t.id),
t.original_filename,
)
await downloadWithAuth(api.getTemplateSourceUrl(item.id), item.original_filename)
} catch (err) {
alert(err instanceof Error ? err.message : 'Download failed')
alert(err instanceof Error ? err.message : t('errors.downloadFailed'))
}
}
const canManage = (t: TemplateListItem) =>
t.is_owner || user?.role === 'admin'
const canManage = (item: TemplateListItem) =>
item.is_owner || user?.role === 'admin'
return (
<div className="container">
<ConfirmDialog
open={deleteTarget !== null}
title="Delete template"
title={t('templates.deleteTitle')}
message={
deleteTarget
? `Delete template "${deleteTarget.name}"? This action cannot be undone.`
? t('templates.deleteMessage', {
name: deleteTarget.name,
cannotUndo: t('common.cannotUndo'),
})
: ''
}
confirmLabel="Delete"
confirmLabel={t('common.delete')}
danger
onConfirm={() => void handleDelete()}
onCancel={() => setDeleteTarget(null)}
/>
<div className="page-header">
<h1>Templates</h1>
<h1>{t('templates.title')}</h1>
<Link to="/templates/new" className="btn btn-primary">
+ Upload Template
{t('templates.upload')}
</Link>
</div>
{error && <div className="error">{error}</div>}
{loading ? (
<p>Loading...</p>
<p>{t('common.loading')}</p>
) : templates.length === 0 ? (
<div className="card empty-state">
<p>No templates yet. Upload a .docx file with Jinja2 variables to get started.</p>
<p>{t('templates.empty')}</p>
<Link to="/templates/new" className="btn btn-primary" style={{ marginTop: 16 }}>
Upload Template
{t('templates.uploadShort')}
</Link>
</div>
) : (
@@ -105,63 +105,63 @@ export default function TemplatesPage() {
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>File</th>
<th>Owner</th>
<th>Visibility</th>
<th>Variables</th>
<th>Created</th>
<th>Actions</th>
<th>{t('common.name')}</th>
<th>{t('common.file')}</th>
<th>{t('common.owner')}</th>
<th>{t('templates.visibility')}</th>
<th>{t('templates.variables')}</th>
<th>{t('common.created')}</th>
<th>{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{templates.map((t) => (
<tr key={t.id}>
{templates.map((item) => (
<tr key={item.id}>
<td>
<strong>{t.name}</strong>
{t.description && (
<div className="style-hint">{t.description}</div>
<strong>{item.name}</strong>
{item.description && (
<div className="style-hint">{item.description}</div>
)}
</td>
<td>{t.original_filename}</td>
<td>{t.owner_username ?? `#${t.owner_id}`}</td>
<td>{item.original_filename}</td>
<td>{item.owner_username ?? `#${item.owner_id}`}</td>
<td>
{t.is_public ? (
{item.is_public ? (
<span className="badge" style={{ background: '#dbeafe', color: '#1d4ed8' }}>
public
{t('visibility.public')}
</span>
) : (
<span className="badge badge-user">private</span>
<span className="badge badge-user">{t('visibility.private')}</span>
)}
</td>
<td>{t.variable_count}</td>
<td>{new Date(t.created_at).toLocaleDateString()}</td>
<td>{item.variable_count}</td>
<td>{formatDate(item.created_at)}</td>
<td style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Link to={`/templates/${t.id}/fill`} className="btn btn-primary btn-sm">
Fill
<Link to={`/templates/${item.id}/fill`} className="btn btn-primary btn-sm">
{t('templates.fill')}
</Link>
<Link to={`/templates/${t.id}`} className="btn btn-secondary btn-sm">
View
<Link to={`/templates/${item.id}`} className="btn btn-secondary btn-sm">
{t('common.view')}
</Link>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleDownloadSource(t)}
onClick={() => handleDownloadSource(item)}
>
Source
{t('templates.source')}
</button>
{canManage(t) && (
{canManage(item) && (
<>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleTogglePublic(t)}
onClick={() => handleTogglePublic(item)}
>
{t.is_public ? 'Make Private' : 'Make Public'}
{item.is_public ? t('visibility.makePrivate') : t('visibility.makePublic')}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => setDeleteTarget(t)}
onClick={() => setDeleteTarget(item)}
>
Delete
{t('common.delete')}
</button>
</>
)}

View File

@@ -3,6 +3,7 @@ import { Navigate } from 'react-router-dom'
import { api } from '../api'
import ConfirmDialog from '../components/ConfirmDialog'
import { useAuth } from '../context/AuthContext'
import { useI18n } from '../context/I18nContext'
import type { User } from '../types'
type PendingConfirm = {
@@ -15,6 +16,7 @@ type PendingConfirm = {
export default function UsersPage() {
const { user: currentUser } = useAuth()
const { t, formatDate } = useI18n()
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -39,7 +41,7 @@ export default function UsersPage() {
try {
setUsers(await api.listUsers())
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load users')
setError(err instanceof Error ? err.message : t('errors.loadUsersFailed'))
} finally {
setLoading(false)
}
@@ -58,7 +60,7 @@ export default function UsersPage() {
setShowForm(false)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create user')
setError(err instanceof Error ? err.message : t('errors.createUserFailed'))
}
}
@@ -87,7 +89,7 @@ export default function UsersPage() {
setEditingUser(null)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Update failed')
setError(err instanceof Error ? err.message : t('errors.updateFailed'))
}
}
@@ -98,11 +100,11 @@ export default function UsersPage() {
if (editForm.is_active !== editingUser.is_active) {
const activating = editForm.is_active
setPendingConfirm({
title: activating ? 'Activate user' : 'Deactivate user',
title: activating ? t('users.activateTitle') : t('users.deactivateTitle'),
message: activating
? `Activate user "${editingUser.username}"? They will be able to sign in again.`
: `Deactivate user "${editingUser.username}"? They will no longer be able to sign in.`,
confirmLabel: activating ? 'Activate' : 'Deactivate',
? t('users.activateMessage', { username: editingUser.username })
: t('users.deactivateMessage', { username: editingUser.username }),
confirmLabel: activating ? t('users.activate') : t('users.deactivate'),
danger: !activating,
onConfirm: submitEdit,
})
@@ -115,11 +117,11 @@ export default function UsersPage() {
const handleToggleActive = (u: User) => {
const activating = !u.is_active
setPendingConfirm({
title: activating ? 'Activate user' : 'Deactivate user',
title: activating ? t('users.activateTitle') : t('users.deactivateTitle'),
message: activating
? `Activate user "${u.username}"? They will be able to sign in again.`
: `Deactivate user "${u.username}"? They will no longer be able to sign in.`,
confirmLabel: activating ? 'Activate' : 'Deactivate',
? t('users.activateMessage', { username: u.username })
: t('users.deactivateMessage', { username: u.username }),
confirmLabel: activating ? t('users.activate') : t('users.deactivate'),
danger: !activating,
onConfirm: async () => {
await api.updateUser(u.id, { is_active: !u.is_active })
@@ -130,9 +132,12 @@ export default function UsersPage() {
const handleDelete = (u: User) => {
setPendingConfirm({
title: 'Delete user',
message: `Delete user "${u.username}"? This action cannot be undone.`,
confirmLabel: 'Delete',
title: t('users.deleteTitle'),
message: t('users.deleteMessage', {
username: u.username,
cannotUndo: t('common.cannotUndo'),
}),
confirmLabel: t('common.delete'),
danger: true,
onConfirm: async () => {
await api.deleteUser(u.id)
@@ -148,7 +153,7 @@ export default function UsersPage() {
try {
await action()
} catch (err) {
alert(err instanceof Error ? err.message : 'Action failed')
alert(err instanceof Error ? err.message : t('errors.actionFailed'))
}
}
@@ -169,9 +174,9 @@ export default function UsersPage() {
/>
<div className="page-header">
<h1>User Management</h1>
<h1>{t('users.title')}</h1>
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ Add User'}
{showForm ? t('common.cancel') : t('users.addUser')}
</button>
</div>
@@ -179,10 +184,10 @@ export default function UsersPage() {
{showForm && (
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
<h2 style={{ marginTop: 0 }}>Create User</h2>
<h2 style={{ marginTop: 0 }}>{t('users.createUser')}</h2>
<form onSubmit={handleCreate}>
<div className="form-group">
<label>Email</label>
<label>{t('common.email')}</label>
<input
type="email"
value={form.email}
@@ -191,7 +196,7 @@ export default function UsersPage() {
/>
</div>
<div className="form-group">
<label>Username</label>
<label>{t('common.username')}</label>
<input
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
@@ -200,7 +205,7 @@ export default function UsersPage() {
/>
</div>
<div className="form-group">
<label>Password</label>
<label>{t('common.password')}</label>
<input
type="password"
value={form.password}
@@ -210,26 +215,28 @@ export default function UsersPage() {
/>
</div>
<div className="form-group">
<label>Role</label>
<label>{t('common.role')}</label>
<select
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as 'user' | 'admin' })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="user">{t('roles.user')}</option>
<option value="admin">{t('roles.admin')}</option>
</select>
</div>
<button type="submit" className="btn btn-primary">Create</button>
<button type="submit" className="btn btn-primary">{t('common.create')}</button>
</form>
</div>
)}
{editingUser && (
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
<h2 style={{ marginTop: 0 }}>Edit User: {editingUser.username}</h2>
<h2 style={{ marginTop: 0 }}>
{t('users.editUser', { username: editingUser.username })}
</h2>
<form onSubmit={handleEdit}>
<div className="form-group">
<label>Email</label>
<label>{t('common.email')}</label>
<input
type="email"
value={editForm.email}
@@ -238,7 +245,7 @@ export default function UsersPage() {
/>
</div>
<div className="form-group">
<label>Username</label>
<label>{t('common.username')}</label>
<input
value={editForm.username}
onChange={(e) => setEditForm({ ...editForm, username: e.target.value })}
@@ -247,7 +254,7 @@ export default function UsersPage() {
/>
</div>
<div className="form-group">
<label>New Password (leave empty to keep)</label>
<label>{t('users.newPasswordHint')}</label>
<input
type="password"
value={editForm.password}
@@ -256,7 +263,7 @@ export default function UsersPage() {
/>
</div>
<div className="form-group">
<label>Role</label>
<label>{t('common.role')}</label>
<select
value={editForm.role}
onChange={(e) =>
@@ -264,8 +271,8 @@ export default function UsersPage() {
}
disabled={editingUser.id === currentUser?.id}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="user">{t('roles.user')}</option>
<option value="admin">{t('roles.admin')}</option>
</select>
</div>
<div className="form-group">
@@ -279,17 +286,17 @@ export default function UsersPage() {
disabled={editingUser.id === currentUser?.id}
style={{ width: 'auto', marginRight: 8 }}
/>
Active
{t('common.active')}
</label>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary">Save</button>
<button type="submit" className="btn btn-primary">{t('common.save')}</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setEditingUser(null)}
>
Cancel
{t('common.cancel')}
</button>
</div>
</form>
@@ -297,18 +304,18 @@ export default function UsersPage() {
)}
{loading ? (
<p>Loading...</p>
<p>{t('common.loading')}</p>
) : (
<div className="card">
<table className="table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
<th>{t('common.username')}</th>
<th>{t('common.email')}</th>
<th>{t('common.role')}</th>
<th>{t('users.status')}</th>
<th>{t('common.created')}</th>
<th>{t('common.actions')}</th>
</tr>
</thead>
<tbody>
@@ -317,11 +324,11 @@ export default function UsersPage() {
<td>
<strong>{u.username}</strong>
{u.id === currentUser?.id && (
<span className="style-hint"> (you)</span>
<span className="style-hint"> {t('common.you')}</span>
)}
</td>
<td>{u.email}</td>
<td>{u.role}</td>
<td>{t(`roles.${u.role}`)}</td>
<td>
<span
className={`badge badge-${u.is_active ? 'user' : 'admin'}`}
@@ -331,30 +338,30 @@ export default function UsersPage() {
: { background: '#fee2e2', color: '#dc2626' }
}
>
{u.is_active ? 'active' : 'inactive'}
{u.is_active ? t('status.active') : t('status.inactive')}
</span>
</td>
<td>{new Date(u.created_at).toLocaleDateString()}</td>
<td>{formatDate(u.created_at)}</td>
<td style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
className="btn btn-primary btn-sm"
onClick={() => openEdit(u)}
>
Edit
{t('common.edit')}
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleToggleActive(u)}
disabled={u.id === currentUser?.id}
>
{u.is_active ? 'Deactivate' : 'Activate'}
{u.is_active ? t('users.deactivate') : t('users.activate')}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDelete(u)}
disabled={u.id === currentUser?.id}
>
Delete
{t('common.delete')}
</button>
</td>
</tr>

View File

@@ -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"}