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