mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 13:41:12 +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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user