mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 07:01:11 +00:00
- 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:
@@ -16,6 +16,14 @@ export function useAuth() {
|
|||||||
return useContext(AuthContext)
|
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 {
|
export function __resetBootstrapInflightForTests(): void {
|
||||||
bootstrapInflight = null
|
bootstrapInflight = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +1,727 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo, type ReactNode } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { api, endpoints } from '../../api'
|
import { api, endpoints } from '../../api'
|
||||||
|
import { useAuth } from '../../auth'
|
||||||
|
import { LANG_STORAGE_KEY } from '../../i18n'
|
||||||
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
|
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() {
|
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 [system, setSystem] = useState<SystemSettings | null>(null)
|
||||||
|
const [systemInitial, setSystemInitial] = useState<SystemSettings | null>(null)
|
||||||
const [dirs, setDirs] = useState<DirectorySettings | null>(null)
|
const [dirs, setDirs] = useState<DirectorySettings | null>(null)
|
||||||
|
const [dirsInitial, setDirsInitial] = useState<DirectorySettings | null>(null)
|
||||||
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
||||||
const [saving, setSaving] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(setSystem).catch(() => {})
|
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(s => {
|
||||||
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(setDirs).catch(() => {})
|
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(() => {})
|
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const saveSystem = async () => {
|
const tenantDirty = useMemo(() => dirtyTenant(system, systemInitial), [system, systemInitial])
|
||||||
if (!system) return
|
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)
|
setSaving(true)
|
||||||
await api.put(endpoints.annotations.settingsSystem(), system)
|
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)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saveDirs = async () => {
|
const cancel = () => {
|
||||||
if (!dirs) return
|
setSystem(systemInitial)
|
||||||
setSaving(true)
|
setDirs(dirsInitial)
|
||||||
await api.put(endpoints.annotations.settingsDirectories(), dirs)
|
setSaveError(null)
|
||||||
setSaving(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleDefault = async (a: Aircraft) => {
|
const handleToggleDefault = async (a: Aircraft) => {
|
||||||
|
try {
|
||||||
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
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))
|
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) => {
|
||||||
<div>
|
await i18n.changeLanguage(next)
|
||||||
<label className="text-az-muted text-xs block mb-0.5">{label}</label>
|
try { localStorage.setItem(LANG_STORAGE_KEY, next) } catch { /* private mode etc. */ }
|
||||||
<input
|
}
|
||||||
type={type}
|
|
||||||
value={value ?? ''}
|
const handleSignOutEverywhere = async () => {
|
||||||
onChange={e => onChange(e.target.value)}
|
await logout()
|
||||||
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"
|
navigate('/login')
|
||||||
/>
|
}
|
||||||
</div>
|
|
||||||
)
|
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 (
|
return (
|
||||||
<div className="flex h-full overflow-y-auto p-4 gap-6">
|
<main className="settings-page h-full flex flex-col" style={{ background: 'var(--surface-0)' }}>
|
||||||
{/* Tenant config */}
|
<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="w-[300px] shrink-0">
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.tenant')}</h2>
|
<div className="flex items-center justify-between mb-2">
|
||||||
{system && (
|
<h2 className="sect-head m-0">{t('settings.tenant')}</h2>
|
||||||
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2">
|
<span className="micro">01</span>
|
||||||
{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>
|
||||||
)}
|
<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>
|
||||||
|
|
||||||
{/* Directories */}
|
<div className="w-[340px] shrink-0">
|
||||||
<div className="w-[300px] shrink-0">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.directories')}</h2>
|
<h2 className="sect-head m-0">{t('settings.directories')}</h2>
|
||||||
{dirs && (
|
<span className="micro">02</span>
|
||||||
<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>
|
||||||
)}
|
<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>
|
||||||
|
|
||||||
{/* Aircrafts */}
|
<div className="flex-1 min-w-[420px]">
|
||||||
<div className="flex-1 max-w-sm">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.aircrafts')}</h2>
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-az-panel border border-az-border rounded p-2 space-y-1">
|
<h2 className="sect-head m-0">{t('settings.aircrafts')}</h2>
|
||||||
{aircrafts.map(a => (
|
<span className="micro">03</span>
|
||||||
<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="mono" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
||||||
<span className="flex-1">{a.model}</span>
|
· {aircrafts.length} {t('settings.aircraftsRegistered')}
|
||||||
<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>
|
</span>
|
||||||
<button onClick={() => handleToggleDefault(a)} className={`text-sm ${a.isDefault ? 'text-az-orange' : 'text-az-muted hover:text-az-orange'}`}>
|
</div>
|
||||||
★
|
<button className="btn btn-primary" type="button" onClick={openAircraftModal}>
|
||||||
|
<span style={{ fontSize: 14, lineHeight: 1 }}>+</span>
|
||||||
|
<span>{t('settings.addAircraft')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</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>
|
</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>
|
||||||
|
<FieldLabel label={label} hint={hint} />
|
||||||
|
<input
|
||||||
|
className="inp"
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
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>
|
||||||
|
<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
@@ -189,7 +189,39 @@
|
|||||||
"tenant": "Tenant Configuration",
|
"tenant": "Tenant Configuration",
|
||||||
"directories": "Directories",
|
"directories": "Directories",
|
||||||
"aircrafts": "Aircrafts",
|
"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": {
|
"common": {
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
|||||||
+13
-1
@@ -3,9 +3,21 @@ import { initReactI18next } from 'react-i18next'
|
|||||||
import en from './en.json'
|
import en from './en.json'
|
||||||
import ua from './ua.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({
|
i18n.use(initReactI18next).init({
|
||||||
resources: { en: { translation: en }, ua: { translation: ua } },
|
resources: { en: { translation: en }, ua: { translation: ua } },
|
||||||
lng: 'en',
|
lng: readPersistedLanguage(),
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false },
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export { default } from './i18n'
|
export { default, LANG_STORAGE_KEY } from './i18n'
|
||||||
|
|||||||
+33
-1
@@ -189,7 +189,39 @@
|
|||||||
"tenant": "Конфігурація",
|
"tenant": "Конфігурація",
|
||||||
"directories": "Директорії",
|
"directories": "Директорії",
|
||||||
"aircrafts": "Літальні апарати",
|
"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": {
|
"common": {
|
||||||
"confirm": "Підтвердити",
|
"confirm": "Підтвердити",
|
||||||
|
|||||||
+79
-1
@@ -209,6 +209,15 @@ body {
|
|||||||
border-color: var(--border-hair);
|
border-color: var(--border-hair);
|
||||||
}
|
}
|
||||||
.btn-ghost:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-raised); }
|
.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 {
|
.btn-danger {
|
||||||
background: var(--accent-red);
|
background: var(--accent-red);
|
||||||
color: #0A0D10;
|
color: #0A0D10;
|
||||||
@@ -336,10 +345,79 @@ header .ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent
|
|||||||
border-radius: 2px;
|
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 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); }
|
.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 */
|
/* Pulse for live dot */
|
||||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||||
.live { animation: pulse 1.6s ease-in-out infinite; }
|
.live { animation: pulse 1.6s ease-in-out infinite; }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|||||||
import { http, HttpResponse } from 'msw'
|
import { http, HttpResponse } from 'msw'
|
||||||
import { server } from './msw/server'
|
import { server } from './msw/server'
|
||||||
import { jsonResponse } from './msw/helpers'
|
import { jsonResponse } from './msw/helpers'
|
||||||
import { renderWithProviders, screen, waitFor, userEvent, within } from './helpers/render'
|
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { SettingsPage } from '../src/features/settings'
|
import { SettingsPage } from '../src/features/settings'
|
||||||
import { seedAircraft } from './fixtures/seed_aircraft'
|
import { seedAircraft } from './fixtures/seed_aircraft'
|
||||||
@@ -18,16 +18,9 @@ import type { SystemSettings, DirectorySettings } from '../src/types'
|
|||||||
// AC-3 (NFT-PERF-09) — Deadline: wall-clock from PUT response/error
|
// AC-3 (NFT-PERF-09) — Deadline: wall-clock from PUT response/error
|
||||||
// to error visibility ≤ 2 s.
|
// to error visibility ≤ 2 s.
|
||||||
//
|
//
|
||||||
// Production today (`SettingsPage.saveSystem` / `saveDirs`) does
|
// v2 SettingsPage wraps `save()` in try/catch/finally and renders an inline
|
||||||
// setSaving(true); await api.put(...); setSaving(false)
|
// role="alert" in the sticky footer when the PUT rejects. The three contract
|
||||||
// with no try/finally and no error region in the JSX. Both AC-1 and AC-2 are
|
// tests below assert that wiring directly.
|
||||||
// drift today: the button stays disabled forever and no alert appears. The
|
|
||||||
// AC-3 deadline assertion is also vacuously failing (no DOM element to find).
|
|
||||||
// We mark the contract assertions `it.fails()` and pin the current drift with
|
|
||||||
// control tests, so:
|
|
||||||
// - The drift is documented in the test suite.
|
|
||||||
// - The contract tests will start passing the moment SettingsPage wires
|
|
||||||
// try/finally + an error region — no edits to this file required.
|
|
||||||
|
|
||||||
const SYSTEM_SEED: SystemSettings = {
|
const SYSTEM_SEED: SystemSettings = {
|
||||||
id: 'sys-1',
|
id: 'sys-1',
|
||||||
@@ -84,80 +77,43 @@ function rigSettingsEnv(failure: SettingsFailure): SettingsRig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SettingsPage renders two "Save" buttons (one per panel) once both GETs
|
* SettingsPage (v2) renders a single sticky-footer "Save Changes" button that
|
||||||
* resolve. We always exercise the *system* panel — its handler (`saveSystem`)
|
* persists whichever panels are dirty in parallel. The footer button is the
|
||||||
* has the same try-finally drift as `saveDirs`, and scoping the query to
|
* only Save affordance; per-panel Save buttons no longer exist. We must mark
|
||||||
* "Tenant Configuration" makes the selector unambiguous regardless of which
|
* the Tenant panel as dirty by editing a field before the footer button
|
||||||
* GET resolves first.
|
* becomes enabled — selecting the Military Unit input by accessible name and
|
||||||
|
* typing a single character is enough to flip the dirty flag.
|
||||||
*/
|
*/
|
||||||
async function findSystemSaveButton(): Promise<HTMLElement> {
|
async function findSystemSaveButton(): Promise<HTMLElement> {
|
||||||
const systemHeading = await screen.findByRole('heading', { name: /Tenant Configuration/i })
|
// Wait until the data has loaded (heading is present immediately, but the
|
||||||
const panel = systemHeading.parentElement as HTMLElement
|
// input is rendered only after the GET resolves).
|
||||||
return within(panel).getByRole('button', { name: /^Save$/i })
|
await screen.findByRole('heading', { name: /Tenant Configuration/i })
|
||||||
|
return screen.getByRole('button', { name: /^Save Changes$/i })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeTenantDirty(): Promise<void> {
|
||||||
|
const militaryUnit = await screen.findByLabelText(/Military Unit/i)
|
||||||
|
await userEvent.type(militaryUnit, '!')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderAndClickSave(): Promise<void> {
|
async function renderAndClickSave(): Promise<void> {
|
||||||
renderWithProviders(<SettingsPage />)
|
renderWithProviders(<SettingsPage />)
|
||||||
|
await makeTenantDirty()
|
||||||
const saveButton = await findSystemSaveButton()
|
const saveButton = await findSystemSaveButton()
|
||||||
await userEvent.click(saveButton)
|
await userEvent.click(saveButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AZ-477 — Settings save resilience + 2 s error budget', () => {
|
describe('AZ-477 — Settings save resilience + 2 s error budget', () => {
|
||||||
// Production today has no try/catch around the settings-save api.put().
|
|
||||||
// When MSW returns 500 (or HttpResponse.error()), the rejected promise
|
|
||||||
// becomes an unhandled rejection at the process level and Vitest fails
|
|
||||||
// the run with exit code 1 — even though every test assertion passes.
|
|
||||||
// This handler swallows the *expected* rejection pattern only, so any
|
|
||||||
// unexpected unhandled rejection still surfaces as a hard failure.
|
|
||||||
// The drift itself is asserted by the it.fails() contract tests above
|
|
||||||
// ("Save button stays disabled" / "no DOM error region").
|
|
||||||
let suppressedRejections: unknown[] = []
|
|
||||||
const onUnhandled = (reason: unknown): void => {
|
|
||||||
const msg =
|
|
||||||
reason instanceof Error
|
|
||||||
? reason.message
|
|
||||||
: typeof reason === 'string'
|
|
||||||
? reason
|
|
||||||
: ''
|
|
||||||
if (
|
|
||||||
msg.startsWith('500: upstream failure') ||
|
|
||||||
msg.startsWith('Failed to fetch') ||
|
|
||||||
msg === 'Network error' ||
|
|
||||||
msg.includes('network error')
|
|
||||||
) {
|
|
||||||
suppressedRejections.push(reason)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Re-throw — surface unexpected rejections to the test runner.
|
|
||||||
throw reason
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
seedBearer()
|
seedBearer()
|
||||||
suppressedRejections = []
|
|
||||||
process.on('unhandledRejection', onUnhandled)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
clearBearer()
|
clearBearer()
|
||||||
process.off('unhandledRejection', onUnhandled)
|
|
||||||
// Sanity: every test in this file expects exactly one swallowed
|
|
||||||
// rejection (the settings PUT). If a test triggers more — or zero — the
|
|
||||||
// drift assumption changed and the harness should flag it.
|
|
||||||
if (suppressedRejections.length > 1) {
|
|
||||||
throw new Error(
|
|
||||||
`AZ-477 harness: expected at most 1 suppressed rejection, got ${suppressedRejections.length}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery', () => {
|
describe('AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery', () => {
|
||||||
it.fails(
|
it('PUT 500 → Save button is no longer disabled within 2 s', async () => {
|
||||||
'PUT 500 → Save button is no longer disabled within 2 s',
|
|
||||||
async () => {
|
|
||||||
// Drift: saveSystem awaits api.put() outside a try/finally; on a
|
|
||||||
// rejected promise the trailing `setSaving(false)` is never reached
|
|
||||||
// and the button stays disabled forever.
|
|
||||||
rigSettingsEnv({ kind: 'http', status: 500 })
|
rigSettingsEnv({ kind: 'http', status: 500 })
|
||||||
await renderAndClickSave()
|
await renderAndClickSave()
|
||||||
const saveButton = await findSystemSaveButton()
|
const saveButton = await findSystemSaveButton()
|
||||||
@@ -165,41 +121,20 @@ describe('AZ-477 — Settings save resilience + 2 s error budget', () => {
|
|||||||
() => expect(saveButton).not.toBeDisabled(),
|
() => expect(saveButton).not.toBeDisabled(),
|
||||||
{ timeout: 2000 },
|
{ timeout: 2000 },
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
it.fails(
|
it('PUT 500 → an in-DOM error region (role="alert") appears within 2 s', async () => {
|
||||||
'PUT 500 → an in-DOM error region (role="alert") appears within 2 s',
|
|
||||||
async () => {
|
|
||||||
// Drift: SettingsPage renders no error region. Will pass once a
|
|
||||||
// toast / inline alert is wired into the save handler.
|
|
||||||
rigSettingsEnv({ kind: 'http', status: 500 })
|
rigSettingsEnv({ kind: 'http', status: 500 })
|
||||||
await renderAndClickSave()
|
await renderAndClickSave()
|
||||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||||
// Message shape: production task picks the i18n key; the test only
|
// Message shape: production task picks the i18n key; the test only
|
||||||
// asserts that *some* user-visible error text is present.
|
// asserts that *some* user-visible error text is present.
|
||||||
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
it('control: today the Save button stays disabled after a 500 (current drift)', async () => {
|
|
||||||
// Pins the silent-failure drift: button remains in `disabled` state
|
|
||||||
// because setSaving(false) is unreachable.
|
|
||||||
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
|
|
||||||
await renderAndClickSave()
|
|
||||||
await waitFor(() => expect(rig.systemPuts).toBe(1))
|
|
||||||
// Wait briefly past the response; the button must stay disabled
|
|
||||||
// (drift: setSaving(false) is unreachable past the rejected await).
|
|
||||||
await new Promise((r) => setTimeout(r, 100))
|
|
||||||
const saveButton = await findSystemSaveButton()
|
|
||||||
expect(saveButton).toBeDisabled()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AC-2 (FT-N-14 / NFT-RES-06) — network drop', () => {
|
describe('AC-2 (FT-N-14 / NFT-RES-06) — network drop', () => {
|
||||||
it.fails(
|
it('network error → Save button is no longer disabled within 2 s', async () => {
|
||||||
'network error → Save button is no longer disabled within 2 s',
|
|
||||||
async () => {
|
|
||||||
rigSettingsEnv({ kind: 'network' })
|
rigSettingsEnv({ kind: 'network' })
|
||||||
await renderAndClickSave()
|
await renderAndClickSave()
|
||||||
const saveButton = await findSystemSaveButton()
|
const saveButton = await findSystemSaveButton()
|
||||||
@@ -207,29 +142,18 @@ describe('AZ-477 — Settings save resilience + 2 s error budget', () => {
|
|||||||
() => expect(saveButton).not.toBeDisabled(),
|
() => expect(saveButton).not.toBeDisabled(),
|
||||||
{ timeout: 2000 },
|
{ timeout: 2000 },
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
it.fails(
|
it('network error → an in-DOM error region (role="alert") appears within 2 s', async () => {
|
||||||
'network error → an in-DOM error region (role="alert") appears within 2 s',
|
|
||||||
async () => {
|
|
||||||
rigSettingsEnv({ kind: 'network' })
|
rigSettingsEnv({ kind: 'network' })
|
||||||
await renderAndClickSave()
|
await renderAndClickSave()
|
||||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||||
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AC-3 (NFT-PERF-09) — deadline ≤ 2 s', () => {
|
describe('AC-3 (NFT-PERF-09) — deadline ≤ 2 s', () => {
|
||||||
it.fails(
|
it('500 → DOM error region visible within 2000 ms of the response', async () => {
|
||||||
'500 → DOM error region visible within 2000 ms of the response',
|
|
||||||
async () => {
|
|
||||||
// The deadline is measured from the moment the 500 response is
|
|
||||||
// returned by MSW (rig.responseAt.value) to the moment role="alert"
|
|
||||||
// is found. Today the alert never appears; the assertion is set so
|
|
||||||
// it will pass the moment the alert is wired AND comes up under the
|
|
||||||
// 2-second budget.
|
|
||||||
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
|
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
|
||||||
await renderAndClickSave()
|
await renderAndClickSave()
|
||||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2500 })
|
const alertEl = await screen.findByRole('alert', {}, { timeout: 2500 })
|
||||||
@@ -240,7 +164,6 @@ describe('AZ-477 — Settings save resilience + 2 s error budget', () => {
|
|||||||
expect(elapsed).toBeGreaterThanOrEqual(0)
|
expect(elapsed).toBeGreaterThanOrEqual(0)
|
||||||
expect(elapsed).toBeLessThanOrEqual(2000)
|
expect(elapsed).toBeLessThanOrEqual(2000)
|
||||||
expect(alertEl).toBeInTheDocument()
|
expect(alertEl).toBeInTheDocument()
|
||||||
},
|
})
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user