settings v2: implement design
ci/woodpecker/push/build-arm Pipeline failed

- Rewrite SettingsPage to 5-panel v2 layout: Tenant, Directories,
  Aircrafts, Language, Session — corner-bracket panels, sticky footer
  pinned to viewport bottom (Cancel + Save Changes), live dirty-state
  indicator.
- Wire try/catch/finally + role="alert" in save handler so AZ-477's
  three it.fails contract tests flip to passing; remove the obsolete
  v1-drift control test and its unhandledRejection harness.
- Add EN/UA language toggle; persist to localStorage('azaion.lang')
  and read on i18n init. Export LANG_STORAGE_KEY from src/i18n.
- Add Add-Aircraft flow (reuses admin Modal) and view-only star
  default toggle.
- Extend the v2 design system with .btn-danger-ghost, .star,
  .path-wrap/.browse classes. Scope settings.html-spec button
  proportions (padding 7px 14px, weight 400, letter-spacing 0.10em,
  line-height 1.5) under .settings-page so the admin spec is unaffected.
- Restore module-scoped bootstrapInflight declaration in
  src/auth/AuthContext.tsx (deleted in 2a62415 while references
  remained — every test using tests/setup.ts was throwing
  ReferenceError).
This commit is contained in:
Armen Rohalov
2026-05-26 00:25:27 +03:00
parent 5c3c06aad8
commit cfffb4bdd7
8 changed files with 922 additions and 216 deletions
+8
View File
@@ -16,6 +16,14 @@ export function useAuth() {
return useContext(AuthContext)
}
// React 18+ StrictMode double-invokes effects in dev (mount → cleanup → mount),
// and the backend rotates the refresh cookie on every successful POST. Two
// concurrent bootstraps would race the rotation and leave the second one with
// a stale cookie. The module-scoped in-flight promise lets the second mount
// await the first's network round-trip instead of duplicating it. Risk 4 in
// AZ-510 spec.
let bootstrapInflight: Promise<AuthUser | null> | null = null
export function __resetBootstrapInflightForTests(): void {
bootstrapInflight = null
}
+693 -72
View File
@@ -1,106 +1,727 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { api, endpoints } from '../../api'
import { useAuth } from '../../auth'
import { LANG_STORAGE_KEY } from '../../i18n'
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
import { Modal } from '../admin/Modal'
type Lang = 'en' | 'ua'
const I18N_BUNDLE_VERSION = 'v2.4.1'
const DASH = '—'
type AircraftDraft = {
model: string
type: Aircraft['type']
resolution: string
maxMinutes: number
isDefault: boolean
}
const NEW_AIRCRAFT_DEFAULTS: AircraftDraft = {
model: '', type: 'Copter', resolution: '4K', maxMinutes: 30, isDefault: false,
}
const AIRCRAFT_TYPES = ['Plane', 'Copter', 'FixedWing'] as const
const RESOLUTIONS = ['HD', '1080P', '4K', '6K'] as const
const TYPE_LEGEND_KEY: Record<Aircraft['type'], 'legendPlane' | 'legendCopter' | 'legendFixedW'> = {
Plane: 'legendPlane', Copter: 'legendCopter', FixedWing: 'legendFixedW',
}
const TYPE_CHIP_COLOR: Record<Aircraft['type'], string> = {
Plane: 'var(--accent-blue)',
Copter: 'var(--accent-green)',
FixedWing: 'var(--accent-amber)',
}
const TYPE_CHIP_BORDER: Record<Aircraft['type'], string> = {
Plane: 'rgba(78,158,255,0.45)',
Copter: 'rgba(61,220,132,0.45)',
FixedWing: 'rgba(255,157,61,0.45)',
}
function FolderIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.4l1.6 2H19.5A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-11Z" />
</svg>
)
}
function SignOutIcon() {
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
)
}
function CheckIcon() {
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
<polyline points="20 6 9 17 4 12" />
</svg>
)
}
function dirtyTenant(a: SystemSettings | null, b: SystemSettings | null): boolean {
if (!a || !b) return false
return (
a.militaryUnit !== b.militaryUnit ||
a.name !== b.name ||
a.defaultCameraWidth !== b.defaultCameraWidth ||
a.defaultCameraFoV !== b.defaultCameraFoV
)
}
function dirtyDirs(a: DirectorySettings | null, b: DirectorySettings | null): boolean {
if (!a || !b) return false
return (
a.imagesDir !== b.imagesDir ||
a.labelsDir !== b.labelsDir ||
a.thumbnailsDir !== b.thumbnailsDir
)
}
export default function SettingsPage() {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const { user, logout } = useAuth()
const navigate = useNavigate()
const [system, setSystem] = useState<SystemSettings | null>(null)
const [systemInitial, setSystemInitial] = useState<SystemSettings | null>(null)
const [dirs, setDirs] = useState<DirectorySettings | null>(null)
const [dirsInitial, setDirsInitial] = useState<DirectorySettings | null>(null)
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const lang: Lang = i18n.language === 'ua' ? 'ua' : 'en'
const [aircraftModalOpen, setAircraftModalOpen] = useState(false)
const [aircraftDraft, setAircraftDraft] = useState<AircraftDraft>(NEW_AIRCRAFT_DEFAULTS)
const [aircraftSaving, setAircraftSaving] = useState(false)
const [aircraftError, setAircraftError] = useState<'modelRequired' | 'saveFailed' | null>(null)
useEffect(() => {
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(setSystem).catch(() => {})
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(setDirs).catch(() => {})
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(s => {
setSystem(s)
setSystemInitial(s)
}).catch(() => {})
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(d => {
setDirs(d)
setDirsInitial(d)
}).catch(() => {})
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
}, [])
const saveSystem = async () => {
if (!system) return
const tenantDirty = useMemo(() => dirtyTenant(system, systemInitial), [system, systemInitial])
const dirsDirty = useMemo(() => dirtyDirs(dirs, dirsInitial), [dirs, dirsInitial])
const anyDirty = tenantDirty || dirsDirty
const dirtyLabel = useMemo(() => {
if (tenantDirty && dirsDirty) return `${t('settings.unitTenant')} · ${t('settings.unitDirectories')}`
if (tenantDirty) return t('settings.unitTenant')
if (dirsDirty) return t('settings.unitDirectories')
return ''
}, [tenantDirty, dirsDirty, t])
const save = async () => {
setSaving(true)
await api.put(endpoints.annotations.settingsSystem(), system)
setSaving(false)
setSaveError(null)
try {
const tasks: Promise<unknown>[] = []
if (tenantDirty && system) tasks.push(api.put(endpoints.annotations.settingsSystem(), system))
if (dirsDirty && dirs) tasks.push(api.put(endpoints.annotations.settingsDirectories(), dirs))
await Promise.all(tasks)
if (system) setSystemInitial(system)
if (dirs) setDirsInitial(dirs)
} catch {
setSaveError(t('settings.saveError'))
} finally {
setSaving(false)
}
}
const saveDirs = async () => {
if (!dirs) return
setSaving(true)
await api.put(endpoints.annotations.settingsDirectories(), dirs)
setSaving(false)
const cancel = () => {
setSystem(systemInitial)
setDirs(dirsInitial)
setSaveError(null)
}
const handleToggleDefault = async (a: Aircraft) => {
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
try {
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
} catch {
// best-effort — keep UI consistent on failure
}
}
const field = (label: string, value: string | number | null | undefined, onChange: (v: string) => void, type = 'text') => (
const changeLanguage = async (next: Lang) => {
await i18n.changeLanguage(next)
try { localStorage.setItem(LANG_STORAGE_KEY, next) } catch { /* private mode etc. */ }
}
const handleSignOutEverywhere = async () => {
await logout()
navigate('/login')
}
const openAircraftModal = () => {
setAircraftDraft(NEW_AIRCRAFT_DEFAULTS)
setAircraftError(null)
setAircraftModalOpen(true)
}
const closeAircraftModal = () => {
if (aircraftSaving) return
setAircraftModalOpen(false)
}
const saveAircraft = async () => {
if (!aircraftDraft.model.trim()) { setAircraftError('modelRequired'); return }
setAircraftError(null)
setAircraftSaving(true)
try {
const created = await api.post<Aircraft>(endpoints.flights.aircrafts(), aircraftDraft)
setAircrafts(prev => {
if (created.isDefault) return [...prev.map(p => ({ ...p, isDefault: false })), created]
return [...prev, created]
})
setAircraftModalOpen(false)
} catch {
setAircraftError('saveFailed')
} finally {
setAircraftSaving(false)
}
}
return (
<main className="settings-page h-full flex flex-col" style={{ background: 'var(--surface-0)' }}>
<div className="flex-1 overflow-y-auto px-6 pt-5 pb-6 flex flex-col gap-5">
<section className="flex gap-5 items-start flex-wrap">
<div className="w-[300px] shrink-0">
<div className="flex items-center justify-between mb-2">
<h2 className="sect-head m-0">{t('settings.tenant')}</h2>
<span className="micro">01</span>
</div>
<BracketPanel className="p-4">
<div className="space-y-3">
<FieldText
label={t('settings.militaryUnit')}
hint={t('settings.required')}
value={system?.militaryUnit ?? ''}
onChange={v => setSystem(p => p ? { ...p, militaryUnit: v } : p)}
/>
<FieldText
label={t('settings.unitName')}
value={system?.name ?? ''}
onChange={v => setSystem(p => p ? { ...p, name: v } : p)}
/>
<div className="grid grid-cols-2 gap-3">
<FieldNumber
label={t('settings.camWidth')}
hint="PX"
suffix="px"
value={system?.defaultCameraWidth ?? 0}
onChange={v => setSystem(p => p ? { ...p, defaultCameraWidth: v } : p)}
/>
<FieldNumber
label={t('settings.camFoV')}
hint="DEG"
suffix="°"
step="0.1"
value={system?.defaultCameraFoV ?? 0}
onChange={v => setSystem(p => p ? { ...p, defaultCameraFoV: v } : p)}
/>
</div>
</div>
</BracketPanel>
</div>
<div className="w-[340px] shrink-0">
<div className="flex items-center justify-between mb-2">
<h2 className="sect-head m-0">{t('settings.directories')}</h2>
<span className="micro">02</span>
</div>
<BracketPanel className="p-4">
<div className="space-y-3">
<PathField
label={t('settings.imagesDir')}
statusLabel={t('settings.mounted')}
statusColor="var(--accent-green)"
browseLabel={t('settings.browse')}
value={dirs?.imagesDir ?? ''}
onChange={v => setDirs(p => p ? { ...p, imagesDir: v } : p)}
/>
<PathField
label={t('settings.labelsDir')}
statusLabel={t('settings.mounted')}
statusColor="var(--accent-green)"
browseLabel={t('settings.browse')}
value={dirs?.labelsDir ?? ''}
onChange={v => setDirs(p => p ? { ...p, labelsDir: v } : p)}
/>
<PathField
label={t('settings.thumbnailsDir')}
statusLabel={t('settings.cache')}
statusColor="var(--accent-amber)"
browseLabel={t('settings.browse')}
value={dirs?.thumbnailsDir ?? ''}
onChange={v => setDirs(p => p ? { ...p, thumbnailsDir: v } : p)}
/>
<div
className="mt-3 pt-3 flex items-center justify-between"
style={{ borderTop: '1px solid var(--border-hair)' }}
>
<span className="micro">{t('settings.storageFree')}</span>
<span className="mono tnum" style={{ fontSize: 11, color: 'var(--text-primary)' }}>{DASH}</span>
</div>
</div>
</BracketPanel>
</div>
<div className="flex-1 min-w-[420px]">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<h2 className="sect-head m-0">{t('settings.aircrafts')}</h2>
<span className="micro">03</span>
<span className="mono" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
· {aircrafts.length} {t('settings.aircraftsRegistered')}
</span>
</div>
<button className="btn btn-primary" type="button" onClick={openAircraftModal}>
<span style={{ fontSize: 14, lineHeight: 1 }}>+</span>
<span>{t('settings.addAircraft')}</span>
</button>
</div>
<BracketPanel className="overflow-hidden">
<table className="w-full" style={{ borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--surface-1)' }}>
<th className="text-left micro" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-hair)', width: '44%', fontWeight: 500 }}>
{t('settings.colModel')}
</th>
<th className="text-left micro" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-hair)', fontWeight: 500 }}>
{t('settings.colType')}
</th>
<th className="text-center micro" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-hair)', width: 96, fontWeight: 500 }}>
{t('settings.colDefault')}
</th>
</tr>
</thead>
<tbody>
{aircrafts.map((a, idx) => (
<tr key={a.id} className="row-hover" style={{ borderBottom: idx === aircrafts.length - 1 ? 0 : '1px solid var(--border-hair)' }}>
<td className="mono" style={{ padding: '0 14px', height: 38, fontSize: 12, color: 'var(--text-primary)' }}>{a.model}</td>
<td style={{ padding: '0 14px', height: 38 }}>
<AircraftTypeChip type={a.type} label={t(`admin.aircrafts.${TYPE_LEGEND_KEY[a.type]}`)} />
</td>
<td className="text-center" style={{ padding: '0 14px', height: 38 }}>
<StarButton
active={a.isDefault}
onClick={() => void handleToggleDefault(a)}
aria-label={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')}
title={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')}
/>
</td>
</tr>
))}
{aircrafts.length === 0 && (
<tr>
<td colSpan={3} className="micro text-center" style={{ padding: '24px 14px' }}>{DASH}</td>
</tr>
)}
</tbody>
</table>
</BracketPanel>
</div>
</section>
<section className="flex gap-5 items-start flex-wrap">
<div className="flex-1 min-w-[420px]">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<h2 className="sect-head m-0">{t('settings.language')}</h2>
<span className="micro">04</span>
</div>
<span className="micro">
{t('settings.locale')} · <span style={{ color: 'var(--text-primary)' }}>{lang === 'ua' ? 'UK-UA' : 'EN-US'}</span>
</span>
</div>
<BracketPanel className="p-4">
<div className="flex items-center gap-6 flex-wrap">
<div className="seg" role="group" aria-label={t('settings.language')}>
<button
type="button"
onClick={() => void changeLanguage('en')}
className={`seg-btn${lang === 'en' ? ' active' : ''}`}
aria-pressed={lang === 'en'}
>
EN
</button>
<button
type="button"
onClick={() => void changeLanguage('ua')}
className={`seg-btn${lang === 'ua' ? ' active' : ''}`}
aria-pressed={lang === 'ua'}
>
UA
</button>
</div>
<div className="flex flex-col">
<span className="micro">{t('settings.languageHint')}</span>
<span className="mono" style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}>
{t('settings.languageNote')}
</span>
</div>
<div className="ml-auto flex items-center gap-2 mono" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
<span
className="dot live"
style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-green)' }}
/>
<span>
{t('settings.languageBundle')}{' '}
<span className="tnum" style={{ color: 'var(--text-secondary)' }}>{I18N_BUNDLE_VERSION}</span>
</span>
</div>
</div>
</BracketPanel>
</div>
<div className="w-[380px] shrink-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<h2 className="sect-head m-0">{t('settings.session')}</h2>
<span className="micro">05</span>
</div>
<span className="micro" style={{ color: 'var(--accent-cyan)' }}>{t('settings.sessionActive')}</span>
</div>
<BracketPanel className="p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col min-w-0">
<span className="micro">{t('settings.lastLogin')}</span>
<span className="mono tnum" style={{ fontSize: 12, color: 'var(--text-primary)', marginTop: 4 }}>
{DASH}
</span>
<span
className="mono truncate"
style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2 }}
>
{user?.email ?? DASH}
</span>
</div>
<button
type="button"
onClick={() => void handleSignOutEverywhere()}
className="btn btn-danger-ghost shrink-0"
>
<SignOutIcon />
{t('settings.signOutEverywhere')}
</button>
</div>
</BracketPanel>
</div>
</section>
</div>
<div
className="shrink-0 px-6 pb-6"
style={{
background: 'linear-gradient(180deg, rgba(10,13,16,0) 0%, var(--surface-0) 50%)',
paddingTop: 16,
}}
>
<div
className="flex items-center gap-4 pt-4"
style={{ borderTop: '1px solid var(--border-hair)' }}
>
<div className="flex items-center gap-2 mono uppercase" style={{ fontSize: 10, color: 'var(--text-muted)', letterSpacing: '0.14em' }}>
{anyDirty ? (
<>
<span
className="dot live"
style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-cyan)' }}
/>
<span>
{t('settings.unsavedChanges')}{' '}
<span style={{ color: 'var(--accent-amber)' }}>{dirtyLabel}</span>
</span>
</>
) : null}
</div>
{saveError && (
<div role="alert" className="micro" style={{ color: 'var(--accent-red)', textTransform: 'none', letterSpacing: 0 }}>
{saveError}
</div>
)}
<div className="ml-auto flex items-center gap-3">
<button
type="button"
className="btn btn-ghost"
onClick={cancel}
disabled={saving || !anyDirty}
>
{t('settings.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => void save()}
disabled={saving || !anyDirty}
>
<CheckIcon />
{t('settings.saveChanges')}
</button>
</div>
</div>
</div>
<Modal
open={aircraftModalOpen}
title={t('admin.aircrafts.addTitle')}
onClose={closeAircraftModal}
closeLabel={t('admin.classes.cancel')}
footer={
<>
<button
type="button"
className="btn btn-ghost"
onClick={closeAircraftModal}
disabled={aircraftSaving}
>
{t('admin.classes.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => void saveAircraft()}
disabled={aircraftSaving}
>
{t('admin.aircrafts.addTitle')}
</button>
</>
}
>
<div>
<label className="micro block mb-1">{t('admin.aircrafts.fieldModel')}</label>
<input
autoFocus
className="inp inp-mono"
value={aircraftDraft.model}
onChange={e => setAircraftDraft(p => ({ ...p, model: e.target.value }))}
placeholder="DJI Mavic 3"
aria-label={t('admin.aircrafts.fieldModel')}
/>
</div>
<div>
<label className="micro block mb-1">{t('admin.aircrafts.fieldType')}</label>
<div className="seg" role="group" aria-label={t('admin.aircrafts.fieldType')}>
{AIRCRAFT_TYPES.map(typ => (
<button
key={typ}
type="button"
onClick={() => setAircraftDraft(p => ({ ...p, type: typ }))}
className={`seg-btn${aircraftDraft.type === typ ? ' active' : ''}`}
aria-pressed={aircraftDraft.type === typ}
>
{t(`admin.aircrafts.${TYPE_LEGEND_KEY[typ]}`)}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="micro block mb-1">{t('admin.aircrafts.fieldResolution')}</label>
<select
className="inp inp-mono"
value={aircraftDraft.resolution}
onChange={e => setAircraftDraft(p => ({ ...p, resolution: e.target.value }))}
aria-label={t('admin.aircrafts.fieldResolution')}
>
{RESOLUTIONS.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="micro block mb-1">{t('admin.aircrafts.fieldMaxMinutes')}</label>
<input
type="number"
className="inp inp-mono"
value={aircraftDraft.maxMinutes}
onChange={e => setAircraftDraft(p => ({ ...p, maxMinutes: Number(e.target.value) }))}
style={{ textAlign: 'right' }}
aria-label={t('admin.aircrafts.fieldMaxMinutes')}
/>
</div>
</div>
<label className="checkbox-row">
<input
type="checkbox"
className="checkbox"
checked={aircraftDraft.isDefault}
onChange={e => setAircraftDraft(p => ({ ...p, isDefault: e.target.checked }))}
/>
<span>{t('admin.aircrafts.fieldDefault')}</span>
</label>
{aircraftError && (
<div role="alert" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
{t(`admin.aircrafts.${aircraftError}`)}
</div>
)}
</Modal>
</main>
)
}
// ===== Sub-components =====
function BracketPanel({ className, children }: { className?: string; children: ReactNode }) {
return (
<div className={className ? `bracket panel ${className}` : 'bracket panel'}>
<span className="br" />
{children}
</div>
)
}
function FieldLabel({ label, hint, hintColor }: { label: string; hint?: string; hintColor?: string }) {
return (
<div className="flex items-center justify-between mb-1.5">
<label className="micro">{label}</label>
{hint && (
<span className="mono" style={{ fontSize: 9, color: hintColor ?? 'var(--text-muted)' }}>{hint}</span>
)}
</div>
)
}
function FieldText({
label, hint, value, onChange,
}: {
label: string
hint?: string
value: string
onChange: (v: string) => void
}) {
return (
<div>
<label className="text-az-muted text-xs block mb-0.5">{label}</label>
<FieldLabel label={label} hint={hint} />
<input
type={type}
value={value ?? ''}
className="inp"
type="text"
value={value}
onChange={e => onChange(e.target.value)}
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none focus:border-az-orange"
aria-label={label}
/>
</div>
)
}
function FieldNumber({
label, hint, suffix, value, onChange, step,
}: {
label: string
hint?: string
suffix: string
value: number
onChange: (v: number) => void
step?: string
}) {
return (
<div className="flex h-full overflow-y-auto p-4 gap-6">
{/* Tenant config */}
<div className="w-[300px] shrink-0">
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.tenant')}</h2>
{system && (
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2">
{field('Military Unit', system.militaryUnit, v => setSystem(p => p ? { ...p, militaryUnit: v } : p))}
{field('Name', system.name, v => setSystem(p => p ? { ...p, name: v } : p))}
{field('Default Camera Width', system.defaultCameraWidth, v => setSystem(p => p ? { ...p, defaultCameraWidth: parseInt(v) || 0 } : p), 'number')}
{field('Default Camera FoV', system.defaultCameraFoV, v => setSystem(p => p ? { ...p, defaultCameraFoV: parseFloat(v) || 0 } : p), 'number')}
<button onClick={saveSystem} disabled={saving} className="bg-az-orange text-white text-xs px-3 py-1 rounded disabled:opacity-50">
{t('settings.save')}
</button>
</div>
)}
</div>
{/* Directories */}
<div className="w-[300px] shrink-0">
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.directories')}</h2>
{dirs && (
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2">
{field('Videos Dir', dirs.videosDir, v => setDirs(p => p ? { ...p, videosDir: v } : p))}
{field('Images Dir', dirs.imagesDir, v => setDirs(p => p ? { ...p, imagesDir: v } : p))}
{field('Labels Dir', dirs.labelsDir, v => setDirs(p => p ? { ...p, labelsDir: v } : p))}
{field('Results Dir', dirs.resultsDir, v => setDirs(p => p ? { ...p, resultsDir: v } : p))}
{field('Thumbnails Dir', dirs.thumbnailsDir, v => setDirs(p => p ? { ...p, thumbnailsDir: v } : p))}
{field('GPS Sat Dir', dirs.gpsSatDir, v => setDirs(p => p ? { ...p, gpsSatDir: v } : p))}
{field('GPS Route Dir', dirs.gpsRouteDir, v => setDirs(p => p ? { ...p, gpsRouteDir: v } : p))}
<button onClick={saveDirs} disabled={saving} className="bg-az-orange text-white text-xs px-3 py-1 rounded disabled:opacity-50">
{t('settings.save')}
</button>
</div>
)}
</div>
{/* Aircrafts */}
<div className="flex-1 max-w-sm">
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.aircrafts')}</h2>
<div className="bg-az-panel border border-az-border rounded p-2 space-y-1">
{aircrafts.map(a => (
<div key={a.id} className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-az-bg text-xs text-az-text">
<span className="flex-1">{a.model}</span>
<span className={`px-1 rounded text-[10px] ${a.type === 'Plane' ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
{a.type}
</span>
<button onClick={() => handleToggleDefault(a)} className={`text-sm ${a.isDefault ? 'text-az-orange' : 'text-az-muted hover:text-az-orange'}`}>
</button>
</div>
))}
</div>
<div>
<FieldLabel label={label} hint={hint} />
<div className="relative">
<input
className="inp inp-mono"
type="number"
step={step}
value={value}
onChange={e => onChange(step ? parseFloat(e.target.value) || 0 : parseInt(e.target.value) || 0)}
aria-label={label}
style={{ paddingRight: 36 }}
/>
<span
className="mono"
style={{
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
fontSize: 11, color: 'var(--text-muted)', pointerEvents: 'none',
}}
>
{suffix}
</span>
</div>
</div>
)
}
function PathField({
label, statusLabel, statusColor, browseLabel, value, onChange,
}: {
label: string
statusLabel: string
statusColor: string
browseLabel: string
value: string
onChange: (v: string) => void
}) {
return (
<div>
<FieldLabel label={label} hint={statusLabel} hintColor={statusColor} />
<div className="path-wrap">
<span className="path-icon"><FolderIcon /></span>
<input
className="inp inp-mono"
type="text"
value={value}
onChange={e => onChange(e.target.value)}
aria-label={label}
/>
<button type="button" aria-label={browseLabel} className="browse">
{browseLabel}
</button>
</div>
</div>
)
}
function AircraftTypeChip({ type, label }: { type: Aircraft['type']; label: string }) {
const color = TYPE_CHIP_COLOR[type]
return (
<span
className="inline-flex items-center gap-1.5 mono uppercase"
style={{
fontSize: 10, letterSpacing: '0.12em',
padding: '2px 8px', borderRadius: 2,
border: `1px solid ${TYPE_CHIP_BORDER[type]}`,
color, background: 'transparent',
}}
>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: color, display: 'inline-block' }} />
{label}
</span>
)
}
function StarButton({
active, onClick, ...rest
}: {
active: boolean
onClick: () => void
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
onClick={onClick}
className={active ? 'star active' : 'star'}
aria-pressed={active}
{...rest}
>
{active ? '★' : '☆'}
</button>
)
}
+33 -1
View File
@@ -189,7 +189,39 @@
"tenant": "Tenant Configuration",
"directories": "Directories",
"aircrafts": "Aircrafts",
"save": "Save"
"save": "Save",
"militaryUnit": "Military Unit",
"unitName": "Name",
"camWidth": "Cam Width",
"camFoV": "Cam FoV",
"required": "REQ",
"imagesDir": "Images Dir",
"labelsDir": "Labels Dir",
"thumbnailsDir": "Thumbnails Dir",
"mounted": "MOUNTED",
"cache": "CACHE",
"browse": "Browse",
"storageFree": "Storage Free",
"aircraftsRegistered": "REGISTERED",
"addAircraft": "Add Aircraft",
"colModel": "Model",
"colType": "Type",
"colDefault": "Default",
"language": "Language",
"languageHint": "Affects all UI text",
"languageNote": "Detection class names also use the localized field from seed data.",
"languageBundle": "i18n BUNDLE",
"locale": "Locale",
"session": "Session",
"sessionActive": "ACTIVE",
"lastLogin": "Last Login",
"signOutEverywhere": "Sign out everywhere",
"cancel": "Cancel",
"saveChanges": "Save Changes",
"saveError": "Save failed. Please try again.",
"unsavedChanges": "Unsaved changes detected in",
"unitTenant": "TENANT",
"unitDirectories": "DIRECTORIES"
},
"common": {
"confirm": "Confirm",
+13 -1
View File
@@ -3,9 +3,21 @@ import { initReactI18next } from 'react-i18next'
import en from './en.json'
import ua from './ua.json'
export const LANG_STORAGE_KEY = 'azaion.lang'
function readPersistedLanguage(): 'en' | 'ua' {
// Safari private mode throws on localStorage access — fall back to 'en'.
try {
const persisted = localStorage.getItem(LANG_STORAGE_KEY)
return persisted === 'ua' || persisted === 'en' ? persisted : 'en'
} catch {
return 'en'
}
}
i18n.use(initReactI18next).init({
resources: { en: { translation: en }, ua: { translation: ua } },
lng: 'en',
lng: readPersistedLanguage(),
fallbackLng: 'en',
interpolation: { escapeValue: false },
})
+1 -1
View File
@@ -1 +1 @@
export { default } from './i18n'
export { default, LANG_STORAGE_KEY } from './i18n'
+33 -1
View File
@@ -189,7 +189,39 @@
"tenant": "Конфігурація",
"directories": "Директорії",
"aircrafts": "Літальні апарати",
"save": "Зберегти"
"save": "Зберегти",
"militaryUnit": "Військова частина",
"unitName": "Назва",
"camWidth": "Ширина кадру",
"camFoV": "Кут огляду",
"required": "ОБ.",
"imagesDir": "Директорія зображень",
"labelsDir": "Директорія міток",
"thumbnailsDir": "Директорія мініатюр",
"mounted": "ПІД'ЄДНАНО",
"cache": "КЕШ",
"browse": "Огляд",
"storageFree": "Вільно",
"aircraftsRegistered": "ЗАРЕЄСТРОВАНО",
"addAircraft": "Додати апарат",
"colModel": "Модель",
"colType": "Тип",
"colDefault": "За замовч.",
"language": "Мова",
"languageHint": "Впливає на весь UI",
"languageNote": "Назви класів детекцій теж беруться з локалізованого поля seed-даних.",
"languageBundle": "i18n БАНДЛ",
"locale": "Локаль",
"session": "Сесія",
"sessionActive": "АКТИВНА",
"lastLogin": "Останній вхід",
"signOutEverywhere": "Вийти всюди",
"cancel": "Скасувати",
"saveChanges": "Зберегти зміни",
"saveError": "Не вдалося зберегти. Спробуйте ще раз.",
"unsavedChanges": "Незбережені зміни в",
"unitTenant": "КОНФІГУРАЦІЇ",
"unitDirectories": "ДИРЕКТОРІЯХ"
},
"common": {
"confirm": "Підтвердити",
+79 -1
View File
@@ -209,6 +209,15 @@ body {
border-color: var(--border-hair);
}
.btn-ghost:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-raised); }
.btn-danger-ghost {
background: transparent;
color: var(--accent-red);
border-color: rgba(255,71,86,0.5);
}
.btn-danger-ghost:hover:not(:disabled) {
background: rgba(255,71,86,0.08);
border-color: var(--accent-red);
}
.btn-danger {
background: var(--accent-red);
color: #0A0D10;
@@ -336,10 +345,79 @@ header .ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent
border-radius: 2px;
}
/* Settings v2 — settings.html mock specs larger buttons than admin.html mock.
Scope to the Settings page only so Admin keeps its tighter spec.
line-height: 1.5 matches the mock's body inheritance (its .btn doesn't use
the font shorthand, so it inherits body's line-height instead of "normal"). */
.settings-page .btn {
height: auto;
padding: 7px 14px;
font-weight: 400;
line-height: 1.5;
letter-spacing: 0.10em;
gap: 8px;
}
.settings-page .seg-btn {
height: auto;
padding: 7px 18px;
font-size: 11px;
line-height: 1.5;
letter-spacing: 0.14em;
font-weight: 400;
}
.settings-page .seg-btn.active { font-weight: 600; border-right: 0; }
.settings-page .seg-btn + .seg-btn { border-left: 1px solid var(--border-hair); }
.settings-page .btn-primary:hover:not(:disabled) { filter: brightness(1.05); }
/* Star button */
.star { color: var(--accent-amber); }
.star {
background: transparent;
border: 0;
cursor: pointer;
color: var(--text-muted);
font-size: 18px;
line-height: 1;
padding: 4px;
transition: color .12s, transform .12s;
}
.star:hover { color: var(--accent-amber); }
.star.active { color: var(--accent-amber); }
.star-off { color: var(--text-muted); }
/* Path input with Browse button (Settings v2 directories panel). */
.path-wrap { position: relative; display: flex; align-items: center; }
.path-wrap .path-icon {
position: absolute;
left: 10px;
color: var(--text-muted);
display: flex;
align-items: center;
pointer-events: none;
}
.path-wrap .browse {
position: absolute;
right: 4px;
top: 4px;
height: 24px;
padding: 0 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
background: transparent;
border: 1px solid var(--border-hair);
border-radius: 2px;
cursor: pointer;
transition: color .12s, border-color .12s, background .12s;
}
.path-wrap .browse:hover {
color: var(--accent-amber);
border-color: var(--accent-amber);
background: rgba(255,157,61,0.06);
}
.path-wrap > input.inp { padding-left: 30px; padding-right: 70px; }
/* Pulse for live dot */
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
.live { animation: pulse 1.6s ease-in-out infinite; }