mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 14:41:10 +00:00
admin v2: implement design from ui_design/v2/plugin/admin.html
- Design system: v2 CSS variables (surface-0/1/2, border-hair, accent-amber/cyan/red/green/blue)
and utility classes (.btn, .inp, .pill, .chip, .bracket, .panel, .seg, .swatch,
.type-sq, .grid-bg, .ibtn, .checkbox, .tab); v1 az-* names aliased to v2 vars
so other pages still render. Google Fonts (IBM Plex Sans + JetBrains Mono)
loaded via <link> in index.html <head> to avoid FOUT.
- Header rebuilt to v2: amber wordmark + // divider, amber-bordered flight pill
with cyan live dot, tab-style nav with amber underline on active, LINK status
pill, cog + sign-out icon buttons.
- AdminPage rewritten to 3-column layout (340 / flex / 280):
- Detection Classes: search + ADD button, table with #/Name/Hex/Ops columns,
name-only inline edit with ringed swatch, sibling-row error alert.
- AI Recognition Engine + GPS Device Link panels with corner-bracket borders,
number steppers, segmented protocol control, dashed telemetry footers.
Hooks (useAiSettings, useGpsSettings) seed factory defaults so the UI is
interactive when GET fails (no backend).
- Default Aircrafts: P/C/F type chips, isDefault star toggle, + ADD AIRCRAFT
modal with model/type/resolution/maxMinutes/default fields.
- Co-located components: Modal (backdrop + ESC + body-scroll-lock),
NumberStepper (▲▼ with clamp on click but not on typing), ClassEditRow.
- Types: Aircraft extended with FixedWing + optional resolution/maxMinutes;
new AiRecognitionSettings/Telemetry, GpsDeviceSettings/Telemetry, GpsProtocol.
- Endpoints: /api/admin/ai-settings, /api/admin/gps-settings (+ /ping, /reconnect).
POST /api/flights/aircrafts (plural REST collection).
- MSW: stateful admin-settings handler with resetAdminSettingsSeed() wired into
tests/setup.ts. Aircraft seed expanded to 6 entries matching the mockup.
- i18n: full admin.{classes,aiEngine,gpsDevice,aircrafts} key sets in en+ua;
nav.dataset shortened to "Dataset"; obsolete users-management keys removed.
- Tests: new AdminPage AI/GPS/aircraft test suites; admin_class_edit selectors
updated for the name-only inline editor and the modal-based add flow.
This commit is contained in:
+97
-37
@@ -3,17 +3,15 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../auth'
|
||||
import { useFlight } from './FlightContext'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import HelpModal from './HelpModal'
|
||||
import type { Flight } from '../types'
|
||||
|
||||
export default function Header() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const { user, logout, hasPermission } = useAuth()
|
||||
const { flights, selectedFlight, selectFlight } = useFlight()
|
||||
const navigate = useNavigate()
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,25 +37,56 @@ export default function Header() {
|
||||
{ to: '/admin', label: t('nav.admin'), perm: 'ADM' },
|
||||
]
|
||||
|
||||
const toggleLang = () => {
|
||||
i18n.changeLanguage(i18n.language === 'en' ? 'ua' : 'en')
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex items-center h-10 bg-az-header border-b border-az-border px-3 gap-3 text-sm shrink-0">
|
||||
<span className="font-bold text-az-orange tracking-wider">AZAION</span>
|
||||
<header
|
||||
className="flex items-center px-4 gap-3 shrink-0"
|
||||
style={{ background: 'var(--surface-1)', borderBottom: '1px solid var(--border-hair)', height: 48 }}
|
||||
>
|
||||
<span
|
||||
className="mono font-bold"
|
||||
style={{ color: 'var(--accent-amber)', letterSpacing: '0.2em', fontSize: 14 }}
|
||||
>
|
||||
AZAION
|
||||
</span>
|
||||
|
||||
<span className="micro" style={{ color: 'var(--text-muted)' }}>//</span>
|
||||
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="bg-az-panel border border-az-border rounded px-2 py-0.5 text-az-text hover:border-az-muted min-w-[160px] text-left truncate"
|
||||
className="inline-flex items-center gap-2 mono"
|
||||
style={{
|
||||
height: 28,
|
||||
padding: '0 10px',
|
||||
background: 'var(--surface-1)',
|
||||
border: '1px solid var(--accent-amber)',
|
||||
borderRadius: 2,
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.10em',
|
||||
minWidth: 140,
|
||||
}}
|
||||
>
|
||||
{selectedFlight?.name || '— Select Flight —'}
|
||||
<span
|
||||
className="dot live"
|
||||
style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-cyan)' }}
|
||||
/>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{selectedFlight?.name || '— SELECT —'}</span>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 10 }}>▾</span>
|
||||
</button>
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-az-panel border border-az-border rounded shadow-lg z-50 w-64">
|
||||
<div
|
||||
className="absolute top-full left-0 mt-1 shadow-lg z-50 w-64"
|
||||
style={{ background: 'var(--surface-1)', border: '1px solid var(--border-hair)', borderRadius: 2 }}
|
||||
>
|
||||
<input
|
||||
className="w-full bg-az-bg border-b border-az-border px-2 py-1 text-az-text text-sm outline-none"
|
||||
className="w-full outline-none"
|
||||
style={{
|
||||
background: 'var(--surface-input)',
|
||||
borderBottom: '1px solid var(--border-hair)',
|
||||
color: 'var(--text-primary)',
|
||||
padding: '6px 10px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
placeholder="Filter..."
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
@@ -68,66 +97,97 @@ export default function Header() {
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => { selectFlight(f); setShowDropdown(false); setFilter('') }}
|
||||
className={`w-full text-left px-2 py-1 hover:bg-az-bg text-az-text text-sm ${
|
||||
selectedFlight?.id === f.id ? 'bg-az-bg font-semibold' : ''
|
||||
}`}
|
||||
className="w-full text-left"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: selectedFlight?.id === f.id ? 'var(--surface-2)' : 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div>{f.name}</div>
|
||||
<div className="text-xs text-az-muted">{new Date(f.createdDate).toLocaleDateString()}</div>
|
||||
<div className="mono tnum" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
||||
{new Date(f.createdDate).toLocaleDateString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-2 py-2 text-az-muted text-xs">No flights</div>
|
||||
<div className="micro" style={{ padding: '8px 10px' }}>No flights</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="hidden sm:flex items-center gap-1 ml-2">
|
||||
<nav className="hidden sm:flex items-center self-stretch ml-3">
|
||||
{navItems.filter(n => hasPermission(n.perm)).map(n => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
className={({ isActive }) =>
|
||||
`px-2 py-1 rounded text-sm ${isActive ? 'bg-az-bg font-semibold text-white' : 'text-az-text hover:text-white'}`
|
||||
}
|
||||
className={({ isActive }) => `tab${isActive ? ' active' : ''}`}
|
||||
>
|
||||
{n.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<span className="text-xs text-az-muted hidden sm:block">{user?.email}</span>
|
||||
<button onClick={toggleLang} className="text-xs text-az-muted hover:text-white px-1">
|
||||
{i18n.language === 'en' ? 'UA' : 'EN'}
|
||||
</button>
|
||||
<button onClick={() => setShowHelp(true)} className="text-az-muted hover:text-white text-xs">?</button>
|
||||
<NavLink to="/settings" className="text-az-muted hover:text-white">⚙</NavLink>
|
||||
<button onClick={handleLogout} className="text-az-muted hover:text-az-red text-xs">
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 ml-auto micro">
|
||||
<span
|
||||
className="dot live"
|
||||
style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-cyan)' }}
|
||||
/>
|
||||
<span style={{ color: 'var(--accent-cyan)' }}>LINK</span>
|
||||
<span style={{ color: 'var(--border-raised)' }}>|</span>
|
||||
<span
|
||||
className="hidden md:inline"
|
||||
style={{ color: 'var(--text-secondary)', textTransform: 'none', letterSpacing: 0 }}
|
||||
>
|
||||
{user?.email}
|
||||
</span>
|
||||
<span style={{ color: 'var(--border-raised)', margin: '0 4px' }} className="hidden md:inline">|</span>
|
||||
<NavLink to="/settings" className="ibtn" aria-label={t('nav.settings')} title={t('nav.settings')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<path d="M12 15a3 3 0 100-6 3 3 0 000 6z" />
|
||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09a1.65 1.65 0 001.51-1 1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z" />
|
||||
</svg>
|
||||
</NavLink>
|
||||
<button onClick={handleLogout} className="ibtn danger" aria-label={t('nav.logout')} title={t('nav.logout')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
<nav className="sm:hidden fixed bottom-0 left-0 right-0 bg-az-header border-t border-az-border flex justify-around py-1.5 z-50">
|
||||
<nav
|
||||
className="sm:hidden fixed bottom-0 left-0 right-0 flex justify-around z-50"
|
||||
style={{ background: 'var(--surface-1)', borderTop: '1px solid var(--border-hair)', padding: '6px 0' }}
|
||||
>
|
||||
{navItems.filter(n => hasPermission(n.perm)).map(n => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
className={({ isActive }) =>
|
||||
`text-xs px-2 py-1 ${isActive ? 'text-az-orange font-semibold' : 'text-az-muted'}`
|
||||
`micro px-2 py-1 ${isActive ? '' : ''}`
|
||||
}
|
||||
style={({ isActive }) => ({
|
||||
color: isActive ? 'var(--accent-amber)' : 'var(--text-muted)',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
})}
|
||||
>
|
||||
{n.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink to="/settings" className={({ isActive }) => `text-xs px-2 py-1 ${isActive ? 'text-az-orange' : 'text-az-muted'}`}>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className="micro px-2 py-1"
|
||||
style={({ isActive }) => ({ color: isActive ? 'var(--accent-amber)' : 'var(--text-muted)' })}
|
||||
>
|
||||
⚙
|
||||
</NavLink>
|
||||
</nav>
|
||||
<HelpModal open={showHelp} onClose={() => setShowHelp(false)} />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user