mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 07:01:11 +00:00
Compare commits
8 Commits
401f43d845
...
f754afff46
| Author | SHA1 | Date | |
|---|---|---|---|
| f754afff46 | |||
| cfffb4bdd7 | |||
| 5c3c06aad8 | |||
| 434854bf3c | |||
| a943b508f6 | |||
| 8e90e24f5a | |||
| 2a62415f0c | |||
| eb1e8a8581 |
@@ -39,6 +39,7 @@ alwaysApply: true
|
||||
- When you think you are done with changes, run the full test suite. Every failure in tests that cover code you modified or that depend on code you modified is a **blocking gate**. For pre-existing failures in unrelated areas, report them to the user but do not block on them. Never silently ignore or skip a failure without reporting it. On any blocking failure, stop and ask the user to choose one of:
|
||||
- **Investigate and fix** the failing test or source code
|
||||
- **Remove the test** if it is obsolete or no longer relevant
|
||||
- **Iterative-skill exception**: when an iterative loop skill is active (e.g. autodev / `implement/SKILL.md` batch loop, `refactor/SKILL.md` batch loop), the skill governs full-suite cadence — typically focused tests per task/batch and a single full-suite gate at the very end of the implementation phase, NOT after each batch. "Done with changes" means done with the entire implementation phase the skill is running, not done with one batch. Do not run the full suite per batch unless the skill explicitly says to.
|
||||
- Do not rename any databases or tables or table columns without confirmation. Avoid such renaming if possible.
|
||||
|
||||
- Make sure we don't commit binaries, create and keep .gitignore up to date and delete binaries after you are done with the task
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
description: "Use chunked writes (Write + StrReplace marker pattern) for large generated files, especially after a monolithic Write fails"
|
||||
alwaysApply: true
|
||||
---
|
||||
# Large File Writes — Chunk on Failure
|
||||
|
||||
When a `Write` call to a single file fails (timeout, payload limit, "Invalid arguments", or any tool error) and the intended content is large (>~500 lines or >~50 KB), do NOT retry the same monolithic Write. Switch to chunked writes:
|
||||
|
||||
1. **First Write** — create the file with header + table of contents (if applicable) + an explicit append marker, e.g.
|
||||
|
||||
```
|
||||
<!-- INSERTION_POINT do-not-remove-until-final-chunk -->
|
||||
```
|
||||
|
||||
2. **Each subsequent chunk** — use `StrReplace` to replace the marker with `<new content>\n<marker>` so the marker stays at the end. This is idempotent: if a chunk fails, retry it without losing earlier chunks.
|
||||
|
||||
3. **Final chunk** — `StrReplace` removes the marker.
|
||||
|
||||
## Why
|
||||
|
||||
- Tool argument size limits and transient failures hit large monolithic writes hardest. Retrying the same large payload typically fails for the same reason.
|
||||
- Chunked writes are recoverable per chunk. The earlier chunks are durable on disk.
|
||||
- A unique marker is greppable, visible in diffs, and stops accidental insertion in the wrong place.
|
||||
|
||||
## Triggers
|
||||
|
||||
- Generated documentation that aggregates per-component content (epics, design docs, multi-section architecture summaries, traceability dumps).
|
||||
- Large fixture or test-data files written from a template.
|
||||
- Any single-file artifact you can pre-estimate at >~500 lines.
|
||||
|
||||
## Do NOT chunk
|
||||
|
||||
- Files under ~200 lines — a single `Write` is faster, clearer, and easier to review.
|
||||
- Source code files where appending breaks module structure (functions, classes, imports). Split into multiple files instead.
|
||||
- Files where ordering of sections is computed late and inserting in the middle is required — use a single `Write` once the full content is known.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Retrying the same failed monolithic `Write` more than once. Twice is the limit; on the second failure, switch strategies.
|
||||
- Using `Shell` with heredoc (`cat <<EOF`) or `echo >>` to append — these bypass the editor diff view and break the StrReplace contract for the next chunk.
|
||||
- Embedding the marker so deep inside structured content that a chunk's `StrReplace` becomes ambiguous. Place the marker on its own line at the very end of the file.
|
||||
@@ -14,11 +14,14 @@ alwaysApply: true
|
||||
- Issue types: Epic, Story, Task, Bug, Subtask
|
||||
|
||||
## Tracker Availability Gate
|
||||
- If Jira MCP returns **Unauthorized**, **errored**, **connection refused**, or any non-success response: **STOP** tracker operations and notify the user via the Choose A/B/C/D format documented in `.cursor/skills/autodev/protocols.md`.
|
||||
- If Jira MCP returns **Unauthorized**, **errored**, **connection refused**, **timeout**, a non-2xx status code, an empty body, or any response shape that does not clearly confirm the requested change: **STOP IMMEDIATELY** — no automatic retry, no silent continuation. Surface the full raw error/response to the user verbatim and notify via the Choose A/B/C/D format documented in `.cursor/skills/autodev/protocols.md`.
|
||||
- A minimal `{"success": true}` body with no echoed issue state is NOT a confirmed transition. When a transition's success matters (status moves, ticket creation, blocking link), follow it with a read-back call (`getJiraIssue` or equivalent) and confirm the new state matches what you asked for. If the read-back disagrees → STOP and ASK.
|
||||
- Do NOT loop "retry up to N times before asking". One call, one verification. On failure, the user decides whether to retry.
|
||||
- The user may choose to:
|
||||
- **Retry authentication** — preferred; the tracker remains the source of truth.
|
||||
- **Retry the same operation** — once, after the user authorizes it. If it fails again, surface both responses.
|
||||
- **Retry authentication** — preferred when the failure looks like an auth/credentials problem; the tracker remains the source of truth.
|
||||
- **Continue in `tracker: local` mode** — only when the user explicitly accepts this option. In that mode all tasks keep numeric prefixes and a `Tracker: pending` marker is written into each task header. The state file records `tracker: local`. The mode is NOT silent — the user has been asked and has acknowledged the trade-off.
|
||||
- Do NOT auto-fall-back to `tracker: local` without a user decision. Do not pretend a write succeeded. If the user is unreachable (e.g., non-interactive run), stop and wait.
|
||||
- Do NOT auto-fall-back to `tracker: local` without a user decision. Do not pretend a write succeeded. Do not paper over an opaque response by moving on. If the user is unreachable (e.g., non-interactive run), stop and wait.
|
||||
- When the tracker becomes available again, any `Tracker: pending` tasks should be synced — this is done at the start of the next `/autodev` invocation via the Leftovers Mechanism below.
|
||||
|
||||
## Leftovers Mechanism (non-user-input blockers only)
|
||||
|
||||
@@ -67,8 +67,9 @@ B3. Read state — `_docs/_autodev_state.md` (if it exists).
|
||||
B4. Read File Index — `state.md`, `protocols.md`, and the active flow file.
|
||||
|
||||
### Resolve (once per invocation, after Bootstrap)
|
||||
R1. Reconcile state — verify state file against `_docs/` contents; on disagreement, trust the folders
|
||||
and update the state file (rules: `state.md` → "State File Rules" #4).
|
||||
R1. Reconcile state — verify state file against `_docs/` contents; probe `<workspace-root>/../docs`
|
||||
(parent suite `docs/` — see `state.md` → "State File Rules" #4); on disagreement,
|
||||
trust the folders and update the state file (rules: `state.md` → "State File Rules" #4).
|
||||
After this step, `state.step` / `state.status` are authoritative.
|
||||
R2. Resolve flow — see §Flow Resolution above.
|
||||
R3. Resolve current step — when a state file exists, `state.step` drives detection.
|
||||
|
||||
@@ -5,7 +5,8 @@ Workflow for **meta-repositories** — repos that aggregate multiple components
|
||||
This flow differs fundamentally from `greenfield` and `existing-code`:
|
||||
|
||||
- **No problem/research/plan phases** — meta-repos don't build features, they coordinate existing ones
|
||||
- **No test spec / implement / run tests** — the meta-repo has no code to test
|
||||
- **No test spec / run tests** — the meta-repo has no code to test
|
||||
- **`implement` is scoped to suite-level work only** — cross-repo concerns, repo/folder renames, suite-root infra additions (e.g., `.gitmodules`, `_infra/`, suite `e2e/`). Per-component implementation lives in each component's own workspace `/autodev` cycle. The meta-repo's implement step (Step 3.5) executes only when `_docs/tasks/todo/` is non-empty AND the user explicitly opts in; placement is **before** the sync skills so subsequent Doc/E2E/CICD sync propagates the post-implementation state.
|
||||
- **No `_docs/00_problem/` artifacts** — documentation target is `_docs/*.md` unified docs, not per-feature `_docs/NN_feature/` folders
|
||||
- **Primary artifact is `_docs/_repo-config.yaml`** — generated by `monorepo-discover`, read by every other step
|
||||
|
||||
@@ -17,6 +18,7 @@ This flow differs fundamentally from `greenfield` and `existing-code`:
|
||||
| 2 | Config Review | (human checkpoint, no sub-skill) | — |
|
||||
| 2.5 | Glossary & Architecture Vision | (inline, no sub-skill) | Steps 1–5 |
|
||||
| 3 | Status | monorepo-status/SKILL.md | Sections 1–5 |
|
||||
| 3.5 | Suite Implement | implement/SKILL.md (suite-level invocation context) | Steps 1–14 + 16 (Step 14.5 + Step 15 skipped); conditional on `_docs/tasks/todo/` non-empty AND user opt-in |
|
||||
| 4 | Document Sync | monorepo-document/SKILL.md | Phase 1–7 (conditional on doc drift) |
|
||||
| 4.5 | Integration Test Sync | monorepo-e2e/SKILL.md | Phase 1–6 (conditional on suite-e2e drift; skipped if `suite_e2e:` block absent in config) |
|
||||
| 5 | CICD Sync | monorepo-cicd/SKILL.md | Phase 1–7 (conditional on CI drift) |
|
||||
@@ -184,11 +186,16 @@ The status report identifies:
|
||||
- Registry/config mismatches
|
||||
- Unresolved questions
|
||||
|
||||
Based on the report, auto-chain branches:
|
||||
Based on the report, auto-chain branches in this evaluation order (first match wins):
|
||||
|
||||
- If **doc drift** found → auto-chain to **Step 4 (Document Sync)**
|
||||
- Else if **CI drift** (only) found → auto-chain to **Step 5 (CICD Sync)**
|
||||
- Else if **registry mismatch** found (new components not in config) → present Choose format:
|
||||
1. **Registry mismatch** (new components not in config, or config component not in registry) → present the Choose format below FIRST. After the user resolves it (A: refresh discover, B: onboard, C: continue with mismatch acknowledged), proceed to the next rule. This rule has priority because a stale config would mislead Step 3.5's ownership-envelope synthesis and any sync skill's component scope.
|
||||
2. **Pre-routing gate (Step 3.5 detection)** — check `_docs/tasks/todo/` for suite-level task files (`*.md` excluding files starting with `_`). If ≥1 task is present, auto-chain to **Step 3.5 (Suite Implement)**. After Step 3.5 returns (regardless of A/B outcome), the post-implement re-status applies rules 3–6 below to the post-implementation state.
|
||||
3. If **doc drift** found → auto-chain to **Step 4 (Document Sync)**
|
||||
4. Else if **CI drift** (only) found → auto-chain to **Step 5 (CICD Sync)**
|
||||
5. Else if **suite-e2e drift** (only) found → auto-chain to **Step 4.5 (Integration Test Sync)** (only when `suite_e2e:` block exists in config)
|
||||
6. Else → **workflow done for this cycle**.
|
||||
|
||||
**Registry mismatch Choose format** (rule 1):
|
||||
|
||||
```
|
||||
══════════════════════════════════════
|
||||
@@ -205,7 +212,134 @@ Based on the report, auto-chain branches:
|
||||
══════════════════════════════════════
|
||||
```
|
||||
|
||||
- Else → **workflow done for this cycle**. Report "No drift. Meta-repo is in sync." Loop waits for next invocation.
|
||||
When rule 6 fires (no drift, no todo tasks), report "No drift. Meta-repo is in sync." and end the cycle. Loop waits for next invocation.
|
||||
|
||||
---
|
||||
|
||||
**Step 3.5 — Suite Implement**
|
||||
|
||||
Condition (folder fallback): `_docs/tasks/todo/` exists AND contains ≥1 file matching `*.md` excluding files starting with `_` (e.g., `_dependencies_table.md` is excluded by convention).
|
||||
|
||||
State-driven: reached by auto-chain from Step 3 when the pre-routing gate detected todo tasks. Inserted **before** the sync skills (Step 4 / 4.5 / 5) by deliberate design: implementing renames + cross-repo edits first means the subsequent sync skills propagate the actual landed state rather than the pre-change state, avoiding a second cycle to fix downstream drift.
|
||||
|
||||
**Skip condition**: `_docs/tasks/todo/` is empty, missing, or contains only `_*` files. In that case Step 3.5 is skipped entirely and the cycle proceeds with Step 3's existing drift-based routing.
|
||||
|
||||
**Goal**: Execute suite-level implementation tasks — cross-repo concerns (e.g., `autopilot` + `ui` + suite `e2e/` cutover in a coordinated change-set), folder renames (e.g., `git mv flights missions` + `.gitmodules` edit + `_infra/` path refs), and suite-root infrastructure additions (e.g., `_infra/dev/docker-compose.dev.yml`). Per-component implementation work stays in each component's own workspace `/autodev` cycle.
|
||||
|
||||
**Why this exists**: the meta-repo's existing sync skills (`monorepo-document`, `monorepo-cicd`, `monorepo-e2e`) only **propagate** changes that already landed. They cannot **execute** a task spec. Without Step 3.5, suite-level tickets like AZ-543 (B4 repo rename) or AZ-506 (new dev compose) have no flow path forward — they require operator action outside autodev.
|
||||
|
||||
**Inputs**:
|
||||
|
||||
- `_docs/tasks/todo/*.md` (excluding `_*`) — task specs in the existing format (`Task` / `Component` / `Dependencies` / `Acceptance criteria` headers)
|
||||
- `_docs/_repo-config.yaml` — `components[].path` list, used to compute the suite-level OWNED envelope (workspace root EXCLUDING any path under a component's folder)
|
||||
- `_docs/tasks/_dependencies_table.md` — synthesized by this step if missing (see Procedure)
|
||||
- `_docs/tasks/_suite_module_layout.md` — synthesized by this step if missing (see Procedure)
|
||||
|
||||
**Procedure**:
|
||||
|
||||
1. **Detection (already done by Step 3 pre-routing gate)**. List task files in `_docs/tasks/todo/` (excluding `_*`). If 0 → skip Step 3.5. If ≥1 → continue.
|
||||
|
||||
2. **Present Choose**:
|
||||
|
||||
```
|
||||
══════════════════════════════════════
|
||||
DECISION REQUIRED: <N> suite-level task(s) in _docs/tasks/todo/
|
||||
══════════════════════════════════════
|
||||
Task(s) detected:
|
||||
- AZ-XXX: <title> (deps: <list or "—">)
|
||||
- AZ-YYY: <title> (deps: <list or "—">)
|
||||
...
|
||||
|
||||
A) Run implement skill on these task(s) now (then continue to Doc / E2E / CICD sync)
|
||||
B) Skip implement this cycle — continue to Doc / E2E / CICD sync without executing tasks
|
||||
C) Pause — review the tasks before deciding (end session, no state changes)
|
||||
══════════════════════════════════════
|
||||
Recommendation: A — running implement BEFORE syncs means subsequent
|
||||
sync skills propagate the post-implementation state.
|
||||
B is appropriate when tasks are blocked on user input
|
||||
or external coordination. C when the tasks themselves
|
||||
need owner clarification before execution.
|
||||
══════════════════════════════════════
|
||||
```
|
||||
|
||||
3. **On user A — Pre-flight**:
|
||||
|
||||
a. **Working tree clean check**. Run `git status --porcelain`. If non-empty, surface to the user with a Choose A/B/C identical to the implement skill's prerequisite gate (commit/stash manually; agent commits as `chore: WIP pre-implement`; abort).
|
||||
|
||||
b. **Synthesize `_docs/tasks/_dependencies_table.md`** if missing. Parse each in-scope task's `Dependencies:` field. Write a minimal table of the form:
|
||||
|
||||
```markdown
|
||||
# Suite-Level Task Dependencies
|
||||
|
||||
| Task ID | Depends on | Notes |
|
||||
|---------|------------|-------|
|
||||
| AZ-XXX | (none) | — |
|
||||
| AZ-YYY | AZ-XXX | — |
|
||||
```
|
||||
|
||||
If a task lists a dependency that is neither in `todo/` nor `done/`, log a warning in the synthesized file but do not block — implement skill's Step 1 (Parse) will surface the issue if it actually blocks execution.
|
||||
|
||||
c. **Synthesize `_docs/tasks/_suite_module_layout.md`** if missing. Default content:
|
||||
|
||||
```markdown
|
||||
# Suite-Level Module Layout (synthetic)
|
||||
|
||||
Generated by autodev meta-repo Step 3.5. The suite root has no per-feature decomposition; ownership is defined at the component-boundary level only.
|
||||
|
||||
## Per-Component Mapping
|
||||
|
||||
| Component | Owns | Imports from |
|
||||
|-----------|----------------------------------|--------------|
|
||||
| suite | (workspace root) excluding any path listed under `_repo-config.yaml.components[].path` | (read-only) every component's primary doc + `_docs/*.md` |
|
||||
|
||||
Suite-level tasks operate on: `.gitmodules`, `_infra/**`, `_docs/**` (excluding `_docs/tasks/_*` regenerated files), root `README.md`, `e2e/**` (suite e2e harness only).
|
||||
|
||||
Forbidden paths for suite-level tasks: `<component>/**` for every component listed in `_repo-config.yaml.components[].path` — those edits live in the component's own workspace `/autodev` cycle.
|
||||
```
|
||||
|
||||
d. **Prepare invocation context**:
|
||||
|
||||
```
|
||||
suite_level: true
|
||||
TASKS_DIR: _docs/tasks/
|
||||
module_layout_path: _docs/tasks/_suite_module_layout.md
|
||||
```
|
||||
|
||||
4. **Invoke implement skill**. Read and execute `.cursor/skills/implement/SKILL.md` with the prepared context. The skill's "Suite-level invocation context" subsection (added in tandem with this flow change) honors the three flags above and skips:
|
||||
|
||||
- Step 14.5 (cumulative code review) — no `architecture_compliance_baseline.md` exists at the suite level; cross-task drift is captured by the next `monorepo-status` cycle instead.
|
||||
- Step 15 (Product Implementation Completeness Gate) — the gate's inputs (`_docs/02_document/architecture.md`, `system-flows.md`, `components/*/description.md`) do not exist in the meta-repo artifact layout. Suite tasks are infrastructure / coordination work, not feature implementation.
|
||||
|
||||
All other implement skill steps (1–14, 16) execute unchanged. Tracker integration (Step 5: In Progress, Step 12: In Testing) runs normally.
|
||||
|
||||
5. **Post-implement re-status**. After the implement skill completes (last batch committed, all originally-todo tasks moved to `_docs/tasks/done/`), silently re-run Step 3's drift detection logic — do NOT re-render the full Status report; just re-evaluate the drift signals against the post-implementation tree. Then auto-chain per the post-implementation drift findings:
|
||||
|
||||
- Doc drift → Step 4 (Document Sync)
|
||||
- Suite-e2e drift only → Step 4.5
|
||||
- CI drift only → Step 5
|
||||
- No drift → cycle complete
|
||||
|
||||
Note: the post-implement re-status is exactly why Step 3.5 is placed before sync. A repo rename will typically introduce doc + CI drift; the next invocation of Step 4 / Step 5 catches it on the same cycle.
|
||||
|
||||
6. **On user B (skip)** → mark Step 3.5 `skipped` in state file. Apply Step 3's original drift-based routing (compute from the pre-Step-3.5 Status report).
|
||||
|
||||
7. **On user C (pause)** → end session. Update state to `step: 3.5, status: in_progress, sub_step: {phase: 0, name: awaiting-task-review, detail: "<N> tasks pending review"}`. Tell the user to invoke `/autodev` again after deciding. **Do NOT modify any files** — pre-flight has not run yet.
|
||||
|
||||
**Self-verification** (executed before invoking implement):
|
||||
|
||||
- [ ] Working tree is clean (or user explicitly chose B in the WIP-stash sub-Choose)
|
||||
- [ ] `_docs/tasks/_dependencies_table.md` exists (synthesized if it didn't)
|
||||
- [ ] `_docs/tasks/_suite_module_layout.md` exists (synthesized if it didn't)
|
||||
- [ ] All in-scope task files have a `Component:` field (skip + report any that don't — don't guess ownership)
|
||||
- [ ] Tracker availability gate satisfied per `protocols.md` (or `tracker: local` previously chosen)
|
||||
|
||||
**Failure handling**:
|
||||
|
||||
- If implement returns FAILED → standard Failure Handling (`protocols.md`): retry up to 3 times, then escalate.
|
||||
- If implement is interrupted mid-batch → next invocation re-detects via the implement skill's resumability protocol (read latest `_docs/03_implementation/suite_batch_*.md`). Step 3.5 itself is reentrant: on re-entry, if `todo/` still has tasks, it presents the Choose again with the remaining set.
|
||||
- **Half-applied state risk** (acknowledged): if implement is interrupted between commits, the working tree is clean at the last commit boundary but the in-flight batch is lost. The user is responsible for inspecting and re-invoking. This is intentional — automated rollback of suite-level renames + `.gitmodules` edits is more dangerous than a human-driven recovery.
|
||||
|
||||
**Idempotency**: if `_docs/tasks/todo/` becomes empty after this step (all tasks moved to `done/`), the next `/autodev` invocation skips Step 3.5 entirely and proceeds with normal Status → sync flow.
|
||||
|
||||
---
|
||||
|
||||
@@ -287,11 +421,16 @@ After onboarding completes, the config is updated. Auto-chain back to **Step 3 (
|
||||
| Config Review (2, user picked A, confirmed_by_user: true) | Auto-chain → Glossary & Architecture Vision (2.5) |
|
||||
| Config Review (2, user picked B) | **Session boundary** — end session, await re-invocation |
|
||||
| Glossary & Architecture Vision (2.5) | Auto-chain → Status (3) |
|
||||
| Status (3, doc drift) | Auto-chain → Document Sync (4) |
|
||||
| Status (3, suite-e2e drift only) | Auto-chain → Integration Test Sync (4.5) |
|
||||
| Status (3, CI drift only) | Auto-chain → CICD Sync (5) |
|
||||
| Status (3, no drift) | **Cycle complete** — end session, await re-invocation |
|
||||
| Status (3, todo tasks present) | Auto-chain → Suite Implement (3.5) — pre-routing gate fires before drift-based routing |
|
||||
| Status (3, no todo tasks, doc drift) | Auto-chain → Document Sync (4) |
|
||||
| Status (3, no todo tasks, suite-e2e drift only) | Auto-chain → Integration Test Sync (4.5) |
|
||||
| Status (3, no todo tasks, CI drift only) | Auto-chain → CICD Sync (5) |
|
||||
| Status (3, no todo tasks, no drift) | **Cycle complete** — end session, await re-invocation |
|
||||
| Status (3, registry mismatch) | Ask user (A: discover, B: onboard, C: continue) |
|
||||
| Suite Implement (3.5, user picked A, success) | Silent re-status; auto-chain per post-implementation drift (Step 4 / 4.5 / 5 / cycle complete) |
|
||||
| Suite Implement (3.5, user picked B) | Mark `skipped`; auto-chain per Step 3's original drift findings |
|
||||
| Suite Implement (3.5, user picked C) | **Session boundary** — end session, await re-invocation |
|
||||
| Suite Implement (3.5, FAILED ×3) | Standard Failure Handling escalation (`protocols.md`) |
|
||||
| Document Sync (4) + suite-e2e drift pending | Auto-chain → Integration Test Sync (4.5) |
|
||||
| Document Sync (4) + CI drift only pending | Auto-chain → CICD Sync (5) |
|
||||
| Document Sync (4) + no further drift | **Cycle complete** |
|
||||
@@ -317,11 +456,12 @@ Flow-specific slot values:
|
||||
| 2 | Config Review | `IN PROGRESS (awaiting human)` |
|
||||
| 2.5 | Glossary & Architecture Vision | `SKIPPED (already captured)` |
|
||||
| 3 | Status | `DONE (no drift)`, `DONE (N drifts)` |
|
||||
| 3.5 | Suite Implement | `DONE (N tasks)`, `SKIPPED (no todo tasks)`, `SKIPPED (user picked B)`, `IN PROGRESS (batch M of ~N)`, `IN PROGRESS (awaiting-task-review)` |
|
||||
| 4 | Document Sync | `DONE (N docs)`, `SKIPPED (no doc drift)` |
|
||||
| 4.5 | Integration Test Sync | `DONE (N files)`, `SKIPPED (no suite-e2e drift)`, `SKIPPED (no suite_e2e config block)` |
|
||||
| 5 | CICD Sync | `DONE (N files)`, `SKIPPED (no CI drift)` |
|
||||
|
||||
All rows accept the shared state tokens (`DONE`, `IN PROGRESS`, `NOT STARTED`, `FAILED (retry N/3)`); rows 2.5, 4, 4.5, and 5 additionally accept `SKIPPED`.
|
||||
All rows accept the shared state tokens (`DONE`, `IN PROGRESS`, `NOT STARTED`, `FAILED (retry N/3)`); rows 2.5, 3.5, 4, 4.5, and 5 additionally accept `SKIPPED`.
|
||||
|
||||
Row rendering format:
|
||||
|
||||
@@ -330,6 +470,7 @@ Row rendering format:
|
||||
Step 2 Config Review [<state token>]
|
||||
Step 2.5 Glossary & Architecture Vision [<state token>]
|
||||
Step 3 Status [<state token>]
|
||||
Step 3.5 Suite Implement [<state token>]
|
||||
Step 4 Document Sync [<state token>]
|
||||
Step 4.5 Integration Test Sync [<state token>]
|
||||
Step 5 CICD Sync [<state token>]
|
||||
@@ -337,8 +478,12 @@ Row rendering format:
|
||||
|
||||
## Notes for the meta-repo flow
|
||||
|
||||
- **No session boundary except Step 2 and Step 2.5**: unlike existing-code flow (which has boundaries around decompose), meta-repo flow only pauses at config review and the one-shot glossary/vision capture. Once both are confirmed, syncing is fast enough to complete in one session and Step 2.5 idempotently no-ops on every subsequent invocation.
|
||||
- **Session boundaries**: Step 2 (Config Review pending), Step 2.5 (one-shot glossary/vision review), and Step 3.5 (when user picks C "Pause"). Step 3.5's A/B picks do NOT cross a session boundary — they auto-chain to syncs in the same session.
|
||||
- **Cyclical, not terminal**: no "done forever" state. Each invocation completes a drift cycle; next invocation starts fresh.
|
||||
- **No tracker integration**: this flow does NOT create Jira/ADO tickets. Maintenance is not a feature — if a feature-level ticket spans the meta-repo's concerns, it lives in the per-component workspace.
|
||||
- **Tracker integration scope**: this flow does NOT create Jira/ADO tickets in its sync skills (Status / Document Sync / E2E / CICD). Step 3.5 (Suite Implement) IS tracker-integrated — it transitions existing tickets In Progress → In Testing per the implement skill's standard tracker handling. Suite-level tickets are authored manually by the operator (typically as children of an Epic that spans multiple components, like AZ-539); the flow doesn't auto-create them.
|
||||
- **Per-component vs. suite-level work**:
|
||||
- Tickets that touch component source code (`<component>/src/**`) belong in that component's own workspace `/autodev` cycle. The meta-repo flow does NOT execute them.
|
||||
- Tickets that touch suite-root paths only (`.gitmodules`, `_infra/**`, suite `e2e/**`, root `README.md`, suite `_docs/**` outside `tasks/_*`) are eligible for Step 3.5.
|
||||
- Tickets that span both (e.g., AZ-550 B11 consumer cutover, which touches `autopilot/`, `ui/`, AND suite `e2e/`) are NOT executable from a single workspace by design — split the ticket so the suite-level slice can run in Step 3.5 and the component slices run in their owning workspaces.
|
||||
- **Onboarding is opt-in**: never auto-onboarded. User must explicitly request.
|
||||
- **Failure handling**: uses the same retry/escalation protocol as other flows (see `protocols.md`).
|
||||
|
||||
@@ -114,6 +114,7 @@ Before entering a step from this table for the first time in a session, verify t
|
||||
| greenfield | Decompose Tests | Step 1t + Step 3 — All test tasks | Create ticket per task, link to epic |
|
||||
| existing-code | Decompose Tests | Step 1t + Step 3 — All test tasks | Create ticket per task, link to epic |
|
||||
| existing-code | New Task | Step 7 — Ticket | Create ticket per task, link to epic |
|
||||
| meta-repo | Suite Implement | Step 3.5 — implement skill Step 5 / Step 12 | Transition existing tickets In Progress → In Testing per implement skill (does NOT create new tickets — operator authors them) |
|
||||
|
||||
### State File Marker
|
||||
|
||||
@@ -388,7 +389,7 @@ The banner shell is defined here once. Each flow file contributes only its step-
|
||||
where `<state token>` comes from the state-token set defined per row in the flow's step-list table.
|
||||
- `<current-suffix>` — optional, flow-specific. The existing-code flow appends ` (cycle <N>)` when `state.cycle > 1`; other flows leave it empty.
|
||||
- `Retry:` row — omit entirely when `retry_count` is 0. Include it with `<N>/3` otherwise.
|
||||
- `<footer-extras>` — optional, flow-specific. The meta-repo flow adds a `Config:` line with `_docs/_repo-config.yaml` state; other flows leave it empty.
|
||||
- `<footer-extras>` — optional, flow-specific. The meta-repo flow adds a `Config:` line with `_docs/_repo-config.yaml` state; other flows leave it empty unless **parent suite docs** apply: if `<workspace-root>/../docs` exists and is a directory, append `Suite docs (parent): <absolute path>` on its own line (or `Suite docs (parent): absent` is **not** required — omit when missing). This line is orthogonal to flow-specific footer lines; both may appear.
|
||||
|
||||
### State token set (shared)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ The autodev persists its position to `_docs/_autodev_state.md`. This is a lightw
|
||||
|
||||
## Current Step
|
||||
flow: [greenfield | existing-code | meta-repo]
|
||||
step: [1-17 for greenfield, 1-17 for existing-code, 1-6 for meta-repo, or "done"]
|
||||
step: [1-17 for greenfield, 1-17 for existing-code, 1-6 for meta-repo (incl. fractional 2.5 and 3.5), or "done"]
|
||||
name: [step name from the active flow's Step Reference Table]
|
||||
status: [not_started / in_progress / completed / skipped / failed]
|
||||
sub_step:
|
||||
@@ -82,6 +82,19 @@ retry_count: 0
|
||||
cycle: 1
|
||||
```
|
||||
|
||||
```
|
||||
flow: meta-repo
|
||||
step: 3.5
|
||||
name: Suite Implement
|
||||
status: in_progress
|
||||
sub_step:
|
||||
phase: 7
|
||||
name: batch-loop
|
||||
detail: "AZ-543 batch 1 of 1; suite-level"
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
```
|
||||
|
||||
```
|
||||
flow: existing-code
|
||||
step: 10
|
||||
@@ -100,7 +113,7 @@ cycle: 3
|
||||
1. **Create** on the first autodev invocation (after state detection determines Step 1)
|
||||
2. **Update** after every change — this includes: batch completion, sub-step progress, step completion, session boundary, failed retry, or any meaningful state transition. The state file must always reflect the current reality.
|
||||
3. **Read** as the first action on every invocation — before folder scanning
|
||||
4. **Cross-check**: verify against actual `_docs/` folder contents. If they disagree, trust the folder structure and update the state file
|
||||
4. **Cross-check**: verify against actual `_docs/` folder contents. If they disagree, trust the folder structure and update the state file. **Parent suite `docs/`**: on every invocation, also probe `<workspace-root>/../docs` (the parent directory’s `docs` folder — typical suite-level shared documentation next to a component repo). If it exists, mention it in the Status Summary footer per `protocols.md`; use it only as supplemental reading context unless a flow step explicitly ties detection to it. It never replaces workspace `_docs/` for step detection by default.
|
||||
5. **Never delete** the state file
|
||||
6. **Retry tracking**: increment `retry_count` on each failed auto-retry; reset to `0` on success. If `retry_count` reaches 3, set `status: failed`
|
||||
7. **Failed state on re-entry**: if `status: failed` with `retry_count: 3`, do NOT auto-retry — present the issue to the user first
|
||||
|
||||
@@ -64,6 +64,27 @@ TASKS_DIR/
|
||||
└── done/ ← completed tasks (moved here after implementation)
|
||||
```
|
||||
|
||||
### Suite-level invocation context (meta-repo flow)
|
||||
|
||||
When invoked from `.cursor/skills/autodev/flows/meta-repo.md` Step 3.5 (or any caller that supplies the same context envelope), the skill receives:
|
||||
|
||||
```
|
||||
suite_level: true
|
||||
TASKS_DIR: <override> # e.g., _docs/tasks/ (vs. default _docs/02_tasks/)
|
||||
module_layout_path: <override> # e.g., _docs/tasks/_suite_module_layout.md
|
||||
```
|
||||
|
||||
When `suite_level: true` is present, the following gate adjustments apply — and ONLY these. All other steps (1–14, 16) execute unchanged:
|
||||
|
||||
1. **TASKS_DIR override** is honored throughout the skill (Step 1 Parse, Step 13 Archive, Step 15 input paths if it ran). Default `_docs/02_tasks/` is replaced by the supplied path.
|
||||
2. **module_layout_path override** is read instead of the hardcoded `_docs/02_document/module-layout.md` in Step 4 (Assign File Ownership). The supplied file uses the same `Per-Component Mapping` schema. If both the override and the hardcoded path are missing, behavior is unchanged from default mode (STOP and instruct).
|
||||
3. **Step 14.5 (Cumulative Code Review) — SKIPPED**. The meta-repo has no `_docs/02_document/architecture_compliance_baseline.md`; cross-task drift is captured by the next `monorepo-status` cycle instead.
|
||||
4. **Step 15 (Product Implementation Completeness Gate) — SKIPPED**. The gate's hard inputs (`_docs/02_document/architecture.md`, `system-flows.md`, `components/*/description.md`) do not exist in the meta-repo artifact layout. Suite-level tasks are infrastructure / coordination work (renames, cross-repo edits, suite-root infra additions), not feature implementation; the equivalent completeness signal is the next `monorepo-status` drift report (which the meta-repo flow re-runs immediately after Step 3.5 returns).
|
||||
5. **Final report filename**: `_docs/03_implementation/suite_implementation_report_{run_name}.md` (in addition to the existing feature/test/refactor variants). Batch reports follow `_docs/03_implementation/suite_batch_{NN}_report.md`.
|
||||
6. **Tracker integration** (Step 5: In Progress, Step 12: In Testing) runs unchanged — suite-level tickets follow the same tracker rules as any other.
|
||||
|
||||
Without `suite_level: true`, none of these adjustments apply and the skill runs exactly as documented in default mode.
|
||||
|
||||
## Prerequisite Checks (BLOCKING)
|
||||
|
||||
1. `TASKS_DIR/todo/` exists and contains at least one task file for the selected context — **STOP if missing**
|
||||
@@ -103,7 +124,7 @@ TASKS_DIR/
|
||||
|
||||
### 4. Assign File Ownership
|
||||
|
||||
The authoritative file-ownership map is `_docs/02_document/module-layout.md` (produced by the decompose skill's Step 1.5). Task specs are purely behavioral — they do NOT carry file paths. Derive ownership from the layout, not from the task spec's prose.
|
||||
The authoritative file-ownership map is `_docs/02_document/module-layout.md` (produced by the decompose skill's Step 1.5), unless `suite_level: true` was supplied in the invocation context — in which case the `module_layout_path` override is read instead (see "Suite-level invocation context" above). Task specs are purely behavioral — they do NOT carry file paths. Derive ownership from the layout, not from the task spec's prose.
|
||||
|
||||
For each task in the batch:
|
||||
- Read the task spec's **Component** field.
|
||||
@@ -222,6 +243,8 @@ For product implementation, this archive means "batch implementation accepted."
|
||||
|
||||
### 14.5. Cumulative Code Review (every K batches)
|
||||
|
||||
**Skipped entirely when `suite_level: true`** (see "Suite-level invocation context" above) — the meta-repo has no `architecture_compliance_baseline.md` to evaluate against; cross-task drift is captured by the next `monorepo-status` cycle.
|
||||
|
||||
- **Trigger**: every K completed batches (default `K = 3`; configurable per run via a `cumulative_review_interval` knob in the invocation context)
|
||||
- **Purpose**: per-batch review (Step 9) catches batch-local issues; cumulative review catches issues that only appear when tasks are combined — architecture drift, cross-task inconsistency, duplicate symbols introduced across different batches, contracts that drifted across producer/consumer batches
|
||||
- **Scope**: the union of files changed since the **last** cumulative review (or since the start of the run if this is the first)
|
||||
@@ -239,7 +262,7 @@ For product implementation, this archive means "batch implementation accepted."
|
||||
|
||||
### 15. Product Implementation Completeness Gate
|
||||
|
||||
Run this gate after all **product implementation** tasks are complete and before writing any final product implementation report or allowing autodev to proceed to testability/test decomposition. Skip this gate only when the remaining context is explicitly test implementation or refactoring, as determined by the task files and report filename rules.
|
||||
Run this gate after all **product implementation** tasks are complete and before writing any final product implementation report or allowing autodev to proceed to testability/test decomposition. Skip this gate when (a) the remaining context is explicitly test implementation or refactoring (as determined by the task files and report filename rules), OR (b) `suite_level: true` was supplied in the invocation context (the gate's inputs do not exist in the meta-repo artifact layout — see "Suite-level invocation context" above).
|
||||
|
||||
**Goal**: catch the failure mode where narrow tests validate scaffold behavior while the task's actual outcome, included scope, architecture promise, or named integration remains unimplemented.
|
||||
|
||||
@@ -309,8 +332,9 @@ After each batch completes, save the batch report to `_docs/03_implementation/ba
|
||||
- **Test implementation** (tasks from test decomposition): `_docs/03_implementation/implementation_report_tests.md`
|
||||
- **Feature implementation**: `_docs/03_implementation/implementation_report_{feature_slug}_cycle{N}.md` where `{feature_slug}` is derived from the batch task names (e.g., `implementation_report_core_api_cycle2.md`) and `{N}` is the current `state.cycle` from `_docs/_autodev_state.md`. If `state.cycle` is absent (pre-migration), default to `cycle1`.
|
||||
- **Refactoring**: `_docs/03_implementation/implementation_report_refactor_{run_name}.md`
|
||||
- **Suite-level** (when `suite_level: true` was supplied — see "Suite-level invocation context" above): `_docs/03_implementation/suite_implementation_report_{run_name}.md`. Batch reports use `_docs/03_implementation/suite_batch_{NN}_report.md`. `{run_name}` is derived from the batch task IDs (e.g., `suite_implementation_report_az543_az549_az550.md`).
|
||||
|
||||
Determine the context from the task files being implemented: if all tasks have test-related names or belong to a test epic, use the tests filename; otherwise derive the feature slug from the component names and append the cycle suffix.
|
||||
Determine the context from the task files being implemented: if all tasks have test-related names or belong to a test epic, use the tests filename; if `suite_level: true` was supplied, use the suite filename; otherwise derive the feature slug from the component names and append the cycle suffix.
|
||||
|
||||
Batch report filenames must also include the cycle counter when running feature implementation: `_docs/03_implementation/batch_{NN}_cycle{N}_report.md` (test and refactor runs may use the plain `batch_{NN}_report.md` form since they are not cycle-scoped).
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Cycle 4 Step 16 — Deploy Report
|
||||
|
||||
**Date**: 2026-05-13
|
||||
**Cycle**: 4 (autodev existing-code Step 16)
|
||||
**Mode chosen**: real cutover (option A in the cycle-4 deploy gate — "Push to ui/ dev only")
|
||||
**Outcome**: ui/ dev pushed; stage/prod cutover deferred to a later turn; admin/ dev NOT pushed; cross-workspace AZ-513 still un-implemented.
|
||||
|
||||
## What was actually deployed
|
||||
|
||||
| Repo | Branch | Commits pushed | Pipeline triggered |
|
||||
|------|--------|----------------|--------------------|
|
||||
| `ui/` | `dev` (`09449bd..8737491`) | 4 | Woodpecker dev build for `ui/` |
|
||||
| `admin/` | — | 0 | none |
|
||||
|
||||
### Commits pushed to `ui/` `origin/dev`
|
||||
|
||||
```
|
||||
8737491 [AZ-512] Cycle 4 Steps 12-15: test-spec sync + docs + sec + perf
|
||||
ecacfa8 [AZ-512] Admin class inline edit form + PATCH wiring (cy4 batch 16)
|
||||
ef56d9c [AZ-512] chore: reactivate for cycle 4 (Option B path)
|
||||
eef3bdf [AZ-509][AZ-510][AZ-511] Cycle 3 closure: deploy + retro + state
|
||||
```
|
||||
|
||||
The cycle-3 closure commit `eef3bdf` was locally ahead since cycle 3's deploy step (deferred at the cycle-3 push-scope gate), and gets pushed now alongside cycle-4's three commits as a single fast-forward.
|
||||
|
||||
## What was NOT done (deferred / pending)
|
||||
|
||||
| ID | Item | Reason | Owner |
|
||||
|----|------|--------|-------|
|
||||
| D-CY4-STAGE | `ui/` `dev → stage → push origin/stage` | User chose option A (dev-only) at the cycle-4 deploy gate. Stage cutover deferred. **Will compound with cycle-3 stage deferral** — when stage cutover lands, it will ship cycles 3 + 4 simultaneously. | User |
|
||||
| D-CY4-MAIN | `ui/` `stage → main → push origin/main` (prod cutover) | Same reason. Devices will not auto-pull cycle-3 + cycle-4 changes until this completes. | User |
|
||||
| D-CY4-AZ513-IMPL | Implementation of AZ-513 (admin/ POST + PATCH + DELETE /classes routes) | Cross-workspace dependency: `admin/` workspace must implement before AZ-512 is functionally usable in any environment. Filed in Jira (AZ-513, parent epic AZ-509, Blocks AZ-512). UI ships with MSW-stubbed tests under user-authorized Option B — the live PATCH endpoint does not exist server-side yet, so the deployed `ui/` dev build will surface `admin.classes.updateFailed` on real edits. | admin/ team |
|
||||
| D-CY4-ADMIN-PUSH | `admin/` `dev push origin/dev` | User did not select option C at the cycle-4 deploy gate. The AZ-513 task spec sits locally on `admin/` `dev` (since cycle 3). | User |
|
||||
|
||||
## Carry-forward from cycles 2 and 3
|
||||
|
||||
Cycle 2's `deploy_planning_sync_cycle2.md` deferred 3 items to leftovers in `_docs/_process_leftovers/2026-05-12_az-498-deploy-and-key-revocations.md`. Cycle 3 did not close any of them. Cycle 4 also did not close them:
|
||||
|
||||
| ID (origin) | Item | Status as of 2026-05-13 (cycle 4 close) |
|
||||
|----|------|-------|
|
||||
| L-AZ-498-DEPLOY | UI tile-swap prod cutover | Still deferred — cross-workspace satellite-provider gate unchanged. **Will compound with cycle-3 + cycle-4 stage/prod deferrals** when finally promoted. |
|
||||
| L-AZ-499-OWM-REVOKE | OpenWeatherMap key revocation at owm dashboard | Still pending — manual third-party action; owner: user. |
|
||||
| L-AZ-501-GOOGLE-REVOKE | Google Geocode key revocation at Google Cloud Console | Still pending — manual third-party action; owner: user. |
|
||||
| L-AZ-512-ADMIN-PREREQ | AZ-513 implementation + ship in `admin/` workspace | Re-opened cycle 4 under user-authorized Option B. UI implementation now landed; gate stays open until admin/ AZ-513 ships AND deploys. |
|
||||
|
||||
These leftovers need a status sweep at the start of the next `/autodev` invocation per `tracker.mdc` Leftovers Mechanism.
|
||||
|
||||
## Cycle-4 deployment-doc deltas (NOT written this cycle)
|
||||
|
||||
In strict autodev terms, Step 16 in this cycle was a real cutover (option A), not a planning sync. The cycle-2 pattern of patching `_docs/02_document/deployment/*` was therefore skipped here because:
|
||||
|
||||
- AZ-512 introduced **no** changes to Dockerfile, `.woodpecker/`, env vars, or `nginx.conf` (verified inline during Step 14 security audit; the cycle-4 delta `security_report_cycle4_delta.md` enumerates the changed files).
|
||||
- AZ-512's only wire-shape change is one new HTTP method on an existing URL (`PATCH /api/admin/classes/{id}` — already routed to `admin/` by `nginx.conf` since cycle 2 because `DELETE /api/admin/classes/{id}` was already proxied through the same route block).
|
||||
- No new env vars, no new container, no new exposed port.
|
||||
|
||||
If a future cycle adds env vars, infra changes, or new services, the cycle-2 planning-sync pattern (update `environment_strategy.md`, `ci_cd_pipeline.md`, `containerization.md`, `observability.md`) should be applied.
|
||||
|
||||
## Verification
|
||||
|
||||
- `git push origin dev` for `ui/` returned `09449bd..8737491 dev -> dev` (4 commits, fast-forward).
|
||||
- `git status -sb` for `ui/` confirms `dev` and `origin/dev` are synced post-push (no `[ahead N]`).
|
||||
- Functional test suite green pre-push (243 passed, 13 quarantined skips, 0 failed — see `test-output/summary.csv` and `test-output/fast-report.xml`). Up +12 vs cycle 3 from the new `tests/admin_class_edit.test.tsx` suite.
|
||||
- Static perf NFT-PERF-01 green pre-push (291 332 B gzipped vs ≤ 2 097 152 B threshold — see `test-output/performance-summary.txt` and `_docs/06_metrics/perf_2026-05-13_cycle4.md`).
|
||||
- Security cycle-4 delta verdict PASS_WITH_WARNINGS pre-push (see `_docs/05_security/security_report_cycle4_delta.md`).
|
||||
- No nginx/Docker/CI config changes in cycle 4.
|
||||
- Cross-workspace deploy gate (AZ-513) explicitly acknowledged and re-recorded in this report and in the leftover entry. The deployed UI on `ui/` dev will return `admin.classes.updateFailed` on real PATCH attempts until `admin/` AZ-513 ships — by design under user-authorized Option B.
|
||||
|
||||
## Cycle-3 → cycle-4 push-scope progression
|
||||
|
||||
Cycle 3 deploy gate: user picked option A (ui/ dev only). Cycle 4 deploy gate: user picked option A again (ui/ dev only). The same trade-off applies — stage/prod cutover is being collected for a single later promotion. Two consecutive cycles of dev-only pushes means the eventual stage promotion will batch AZ-510 + AZ-511 + AZ-512 deltas into one stage build, with the additional gate that AZ-513 must have shipped in admin/ by that time (otherwise the AZ-512 edit feature renders but cannot complete saves).
|
||||
|
||||
## Auto-chain
|
||||
|
||||
→ Step 17 (Retrospective) for cycle 4.
|
||||
@@ -0,0 +1,192 @@
|
||||
# Retrospective — 2026-05-13 (Phase B Cycle 4)
|
||||
|
||||
**Mode**: cycle-end (autodev existing-code Step 17)
|
||||
**Scope**: Phase B, cycle 4 (`state.cycle = 4`)
|
||||
**Epic / theme**: AZ-509 (UI workspace cycle 3 epic, continued) — single carry-over task AZ-512 reactivated under user-authorized Option B after cycle 3's Cross-Workspace Verification BLOCKING gate
|
||||
**Cycle duration**: 1 batch (batch 16) over 1 working day (2026-05-13)
|
||||
**Previous retro**: `_docs/06_metrics/retro_2026-05-13_cycle3.md` (cycle 3, same calendar day)
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
| Metric | Value | Δ vs cycle 3 |
|
||||
|--------|-------|--------------|
|
||||
| Tasks attempted | 1 (AZ-512) | −2 |
|
||||
| Tasks delivered | 1 (AZ-512) | −1 (cycle 3 shipped 2, deferred 1) |
|
||||
| Tasks deferred at spec gate | 0 (the only deferral was the cycle-3 carry, already reactivated this cycle) | −1 |
|
||||
| Total batches | 1 (batch 16) | −2 |
|
||||
| Total complexity points planned | 3 | −6 |
|
||||
| Total complexity points delivered | 3 | −3 |
|
||||
| Avg tasks per batch | 1 | 0 |
|
||||
| Avg complexity per (completed) batch | 3 | 0 |
|
||||
| Source files mutated | 5 production + test + 1 component-doc + 5 cross-cutting docs | n/a (different shape from cycle 3) |
|
||||
| Cycle shape | Single-task reactivation cycle — user explicitly overrode the cycle-3 conservative-path default | new pattern |
|
||||
|
||||
Sources: `batch_16_cycle4_report.md`, `implementation_report_admin_class_edit_cycle4.md`, `deploy_cycle4_report.md`, `security_report_cycle4_delta.md`, `perf_2026-05-13_cycle4.md`, `structure_2026-05-13_cycle4.md`.
|
||||
|
||||
## Quality Metrics
|
||||
|
||||
### Code Review Results
|
||||
|
||||
| Verdict | Count | Percentage | Δ vs cycle 3 |
|
||||
|---------|-------|-----------|--------------|
|
||||
| PASS (inline self-review per batch report) | 1 (batch 16) | 100 % | +1 (cycle 3 had 2 PASS) |
|
||||
| PASS_WITH_WARNINGS | 0 | 0 % | 0 |
|
||||
| FAIL | 0 | 0 % | 0 |
|
||||
| (no review — deferred at gate) | 0 | 0 % | −1 |
|
||||
|
||||
Note: batch 16 used inline self-review (3-point single-task batch). A formal `/code-review` skill run is scheduled for batch 18 (cumulative-review cadence is every K=3 batches).
|
||||
|
||||
### Findings by Severity (code review only)
|
||||
|
||||
| Severity | Count | Δ vs cycle 3 |
|
||||
|----------|-------|--------------|
|
||||
| Critical | 0 | 0 |
|
||||
| High | 0 | 0 |
|
||||
| Medium | 0 | 0 |
|
||||
| Low | 0 | 0 |
|
||||
|
||||
### Findings by Category (code review)
|
||||
|
||||
All zero (cycle 3 was also all-zero). No new pattern.
|
||||
|
||||
### Security-Audit Findings (Step 14 — cycle 4 delta against cycle 3)
|
||||
|
||||
| Status change | Count | Notable IDs |
|
||||
|---------------|-------|-------------|
|
||||
| Closed | 0 | — |
|
||||
| Newly introduced (LOW) | 1 | F-SAST-CY4-1 — lost-update / mid-air-collision on `PATCH /api/admin/classes/{id}` (by design per AZ-512 spec; promotes to a UI ticket only when AZ-513 lands and the backend's concurrency model is known) |
|
||||
| Carried forward unchanged | 12 | F-SAST-1 (HIGH, git-history), F-SAST-CY3-1 (LOW, test-only barrel export), F-SAST-2/3/4, F-INF-1..5 |
|
||||
|
||||
**Security verdict trajectory**: cycle 3 PASS_WITH_WARNINGS → cycle 4 **PASS_WITH_WARNINGS** (unchanged). `bun audit` re-run clean. No OWASP category status flipped.
|
||||
|
||||
## Structural Metrics
|
||||
|
||||
Source: `_docs/06_metrics/structure_2026-05-13_cycle4.md` (this cycle), compared against `structure_2026-05-13.md` (cycle 3 close).
|
||||
|
||||
| Metric | Cycle 2 close | Cycle 3 close | Cycle 4 close | Δ vs cycle 3 |
|
||||
|--------|---------------|---------------|---------------|--------------|
|
||||
| Component count | 12 | 12 | 12 | 0 |
|
||||
| Public-API barrels | 11 / 11 | 11 / 11 | 11 / 11 | 0 |
|
||||
| STC-ARCH-01 carve-out exemptions | 1 | 0 | 0 | 0 (held at zero) |
|
||||
| Commit-time static gates | 33 / 33 PASS | 33 / 33 PASS | 33 / 33 PASS | 0 |
|
||||
| Architecture cycles | 0 | 0 | 0 | 0 |
|
||||
| Architecture findings open (baseline F1–F9) | 7 of 9 | 6 of 9 | 6 of 9 | 0 |
|
||||
| Newly introduced architecture violations | 0 | 0 | 0 | 0 |
|
||||
| Net architecture delta this cycle | 0 | −1 | **0** | reverted to net-zero |
|
||||
| Wire-contract assertions (`endpoints.test.ts`) | 36 | 37 | 37 | 0 (AZ-512 reused `endpoints.admin.class(id)`) |
|
||||
| Fast-profile suite | 229 PASS / 13 SKIP / 0 FAIL | 231 PASS / 13 SKIP / 0 FAIL | **243 PASS / 13 SKIP / 0 FAIL** | **+12 PASS**, 0 SKIP |
|
||||
| Bundle (gzipped initial JS) | 290 465 B | 290 575 B | **291 332 B** | +757 B (+0.26 %; ~13.89 % budget) |
|
||||
|
||||
### Auto-lesson triggers (per skill Step 1)
|
||||
|
||||
- Net Architecture delta > 0? **No** — delta is 0.
|
||||
- Structural metric regression > 20 %? **No** — every structural metric held; test count +5.2% (improvement); bundle +0.26% (well within budget).
|
||||
- Contract coverage % decreased? **No** — wire-contract assertions held at 37.
|
||||
- New finding category emerged? **No** — security audit ran in delta mode; categories stable.
|
||||
|
||||
**Zero auto-lesson triggers fired.** Manual lessons (3 picked) appear in §LESSONS Append below.
|
||||
|
||||
## Efficiency
|
||||
|
||||
| Metric | Value | Δ vs cycle 3 |
|
||||
|--------|-------|--------------|
|
||||
| Blocked tasks (cycle-internal) | 0 | 0 |
|
||||
| Tasks deferred to backlog at spec gate | 0 | −1 (the cycle-3 deferral was the one reactivated here) |
|
||||
| Cross-workspace prerequisite tickets filed | 0 | −1 (AZ-513 already filed in cycle 3) |
|
||||
| Pre-existing bugs surfaced as side observations | 1 (MSW `/api/admin/users` paginated vs `AdminPage.tsx` flat-array expectation) | 0 |
|
||||
| Tasks pending external user action (cycle-4 close) | **9** | +2 vs cycle 3's 7 |
|
||||
| Tasks requiring fixes after review | 0 | 0 |
|
||||
| Batch with most findings | none — 0 findings cycle-wide | n/a |
|
||||
| Auto-fix loops invoked | 0 | 0 |
|
||||
| Stuck-agent incidents | 0 | 0 |
|
||||
| Unplanned implementation-time test stabilization loops | 1 — selector-target fix in `destructive_ux.test.tsx` after the ✎ button was inserted before `×` | −3 (cycle 3 had 4 for AZ-510's module-scoped state ripple) |
|
||||
|
||||
### Blocker Analysis
|
||||
|
||||
| Blocker Type | Count | Prevention |
|
||||
|--------------|-------|-----------|
|
||||
| Pre-existing bug surfaced during test writing | 1 | New cycle-4 lesson: when a new test mounts the full container component, run it once *without* defensive fixture overrides and let the natural crashes surface latent fixture-vs-source drift, then either fix or document — never silently work around. See Improvement Action #3. |
|
||||
| Selector regression in adjacent test from new affordance | 1 | New cycle-4 lesson: adding a new control to a DOM row that already holds existing controls requires auditing the test corpus for selectors like `querySelector('button')` or `getByRole('button')` without disambiguation. See Improvement Action #2. |
|
||||
| Cycle-3 deferred deploy items (carry) | 3 (D-CY3-STAGE/MAIN/ADMIN-PUSH) | Still not actioned. Cycle 4 added 3 more deploy-deferred items (D-CY4-STAGE/MAIN/ADMIN-PUSH). Compounding. |
|
||||
| Cross-workspace deploy gate (carry from cycles 2 and 3) | 4 (L-AZ-498-DEPLOY, L-AZ-499-OWM-REVOKE, L-AZ-501-GOOGLE-REVOKE, L-AZ-512-ADMIN-PREREQ — last one re-opened cycle 4) | Same as cycle-3 retro Action #3 — drain mechanism still not implemented. |
|
||||
|
||||
### User-action backlog at cycle close
|
||||
|
||||
| Category | Count | Items |
|
||||
|----------|-------|-------|
|
||||
| Manual third-party console action | 2 | L-AZ-499-OWM-REVOKE, L-AZ-501-GOOGLE-REVOKE (carry from cycle 2) |
|
||||
| Cross-workspace deploy gate (satellite-provider) | 1 | L-AZ-498-DEPLOY (carry from cycle 2) |
|
||||
| Cross-workspace prerequisite ticket awaiting sibling-team work | 1 | AZ-513 implementation on `admin/` (re-opened cycle 4 under user-authorized Option B) |
|
||||
| Cycle deploy-push pending | 5 | D-CY3-STAGE, D-CY3-MAIN, D-CY3-ADMIN-PUSH (carry); D-CY4-STAGE, D-CY4-MAIN, D-CY4-AZ513-IMPL — note the cycle-4 AZ-513 deploy gate is the same item as the cross-workspace prereq above when counted only once (de-dup) |
|
||||
| **Total (de-duplicated)** | **9** | (cycle 1 close: 0 → cycle 2 close: 3 → cycle 3 close: 7 → cycle 4 close: **9**) |
|
||||
|
||||
> Trajectory continues: 0 → 3 → 7 → **9**. Net growth +2 this cycle (cycle 4 added D-CY4-STAGE + D-CY4-MAIN; AZ-513 re-opened as `cross-workspace prerequisite`; D-CY4-ADMIN-PUSH was carried-not-added because the user kept the same dev-only push scope as cycle 3). Cycle-3 retro Improvement Action #3 (track backlog as first-class metric) is now being applied — but the drain mechanism (step-0 sweep that closes items, not just notices them) is still pending. **Backlog growth is decelerating** (+3, +4, **+2**); even so, the gap between accumulated and drained remains the dominant signal.
|
||||
|
||||
### User-decision points (cycle 4 only)
|
||||
|
||||
- Cycle-4 entry: user **explicitly overrode** the cycle-3 conservative-path default for AZ-512 ("implement 512, 513 would be implemented in minutes. You can write mocks for backend data anyway for testing."). The spec was updated to record this as user-authorized Option B; the leftover entry was re-opened with the Option-B rationale. This is the first cross-cycle override of a spec-conservative default in cycles 1-4.
|
||||
- Step 13 → Steps 14+15 gate: user chose **D** (run both Security Audit AND Performance Test) — first time across cycles 1-4 that BOTH optional gates ran inline. Cycle 3 also ran both via auto-chain but Step 15 emitted no separate report; cycle 4 produced standalone `perf_2026-05-13_cycle4.md` for the first time.
|
||||
- Cycle-4 Step 16 deploy gate: user chose **A** (push to ui/ dev only) — same option as cycle 3. Stage / main / admin/ dev push deferred.
|
||||
|
||||
## Trend Comparison
|
||||
|
||||
| Trend | Cycle 1 | Cycle 2 | Cycle 3 | Cycle 4 | Direction |
|
||||
|-------|---------|---------|---------|---------|-----------|
|
||||
| Code review pass rate (formally-reviewed batches) | 100 % | 50 % | 100 % | 100 % (self-review) | held |
|
||||
| Test count (cumulative this cycle delta) | +46 | +20 | +2 | **+12** | rebounded from cycle-3 low |
|
||||
| Static gate count | +2 | +2 | 0 | **0** | held |
|
||||
| Architecture findings open (baseline) | 7 (−2) | 7 (0) | 6 (−1) | **6 (0)** | held flat |
|
||||
| STC-ARCH-01 exemptions | 1 | 1 | 0 | **0** | held at zero |
|
||||
| Wire-contract assertions | 36 | 36 | 37 (+1) | **37 (0)** | held |
|
||||
| Pending USER actions at cycle close | 0 | 3 | 7 | **9** | ⬆ still growing (rate decelerating) |
|
||||
| Tasks deferred to backlog at spec gate | 0 | 0 | 1 | **0** | reverted (the cycle-3 deferral was the one reactivated) |
|
||||
| Cycles where user overrode a spec-conservative default | 0 | 0 | 0 | **1** (AZ-512 Option B) | new pattern |
|
||||
| Bundle (gzipped initial JS, B) | — | 290 465 | 290 575 (+110) | **291 332 (+757)** | growing in line with feature delta; far within budget |
|
||||
|
||||
Cycle 4 is the first single-task reactivation cycle (vs cycle 3's three-task fresh cycle). The cycle-3 retro called out that the AZ-512 gate worked as designed; cycle 4 confirms the *other half* of the design: a user-authorized override path can flow through the entire 9→17 step sequence without regressions, while preserving the deploy gate. Both halves of the gate design are now field-validated.
|
||||
|
||||
## Top 3 Improvement Actions
|
||||
|
||||
1. **Codify a "pre-existing-bug surface lifting" routine — observe-then-document, never silently work-around.**
|
||||
While writing `tests/admin_class_edit.test.tsx`, I discovered the `/api/admin/users` MSW handler's paginated response vs `AdminPage.tsx`'s flat-array expectation by hitting a `users.map is not a function` render crash. The route taken was: document in batch_16_cycle4_report.md "Pre-existing bug noted", apply a local workaround (`stubUsersAsPlainArray()` in `beforeEach`), and recommend filing a separate UI-workspace ticket. This was the right tactical move, but the **systematic routine** is missing — there's no checklist anywhere that says "when a new test mounts a container component, run it once with default fixtures only, name any crashes, and decide explicitly fix-now vs document-and-defer." Without that routine, future cycles will keep accumulating quiet local workarounds and the side-observed bug list grows without a tracking artifact.
|
||||
- Impact: medium — the failure mode (silent test-fixture overrides masking real source bugs) is the test-side analog of "client-side validation only" — looks green, but tested against a fake. Two distinct cycles (3 and 4) already surfaced one bug each through this route.
|
||||
- Effort: low — add a section "Pre-existing-bug surfacing during test writing" to `_docs/02_tasks/_templates/module_scoped_state_introduction.md` (created in cycle 3) and to the implementation skill's batch-report template; require the batch report to either list "Pre-existing bug noted" entries or affirm "None observed; ran with default fixtures only".
|
||||
|
||||
2. **Audit test selectors that pick "the button" / "the link" / "the input" without disambiguation, before adding a new affordance to an existing DOM region.**
|
||||
The cycle-4 ✎ edit button was inserted into the same `<td>` that holds the `×` delete button. `tests/destructive_ux.test.tsx` had three call sites using `firstRow.querySelector('button')` — each silently rebound to the new ✎ button instead of the old `×` button, and the tests would have shipped green-but-meaningless if not caught by the test run. The fix was 1-line per site (`Array.from(...).find(b => b.textContent === '×')`). The deeper lesson is that the failure mode is **invisible at code-review time** — a code reviewer reading the source diff has no view into which test selectors will resolve to which DOM element, only the test run reveals it. The cheap structural prevention: before adding a new control to a DOM region, grep the test corpus for `querySelector('button|input|a')` / `getByRole('button')` without name/text disambiguation, in the same file / sibling files, and add disambiguating selectors *in the affordance batch*.
|
||||
- Impact: medium — saves one stabilization loop per affordance addition; the cost of NOT catching it is silent test-meaning-drift in destructive-UX assertions, which is exactly the kind of bug Finding B4 (cycle 1) was filed for.
|
||||
- Effort: low — add a 3-bullet checklist to the implement skill's "Adjacent hygiene" rules: (a) before inserting a new button/input into an existing row/region, grep for non-disambiguated selectors targeting that region; (b) update them in the same commit; (c) if you can't make them disambiguated without changing the source DOM, prefer giving the new control a stable `data-testid` over rewriting test selectors.
|
||||
|
||||
3. **Add a "user-action backlog drain rate" to the retrospective metric set.**
|
||||
Cycle 3 retro added "user-action backlog at cycle close" as an absolute count. Cycle 4 now has two consecutive data points (7 → 9). The signal in absolute count is being applied — but the signal that matters for process-shape is the **drain rate**: how many items got closed *this* cycle vs how many got added? Cycle 4: 1 item state-transitioned (L-AZ-512-ADMIN-PREREQ moved from "deferred awaiting AZ-513" to "re-opened under Option B"; technically still open), +2 net new (D-CY4-STAGE, D-CY4-MAIN). So drain = 0, add = 2, net = +2. Tracking drain explicitly will make the drain-mechanism conversation concrete — today the retro just says "backlog is +2, drain mechanism still pending" with no metric to optimize.
|
||||
- Impact: medium — operationalizes cycle-3 Action #3. Makes the drain-mechanism design (which is presumably a step-0 sweep that closes items, not just notices them) measurable from the first cycle it runs.
|
||||
- Effort: low — extend `.cursor/skills/retrospective/SKILL.md` Step 1 metric collection with a "User-action backlog drain rate" subsection (count of items added this cycle vs items closed this cycle vs net change vs absolute close-count), and add to the retrospective-report template.
|
||||
|
||||
## Suggested Rule / Skill Updates
|
||||
|
||||
| File | Change | Rationale |
|
||||
|------|--------|-----------|
|
||||
| `.cursor/skills/implement/SKILL.md` (Adjacent Hygiene section) | Add the 3-bullet "test selector audit" checklist for inserting a new control into an existing DOM region. | §Top 3 Improvement Action #2. |
|
||||
| `.cursor/skills/implement/templates/batch_report.md` (if it exists) or `_docs/02_tasks/_templates/module_scoped_state_introduction.md` | Add "Pre-existing-bug surfacing during test writing" subsection requirement: batch report must explicitly list observed pre-existing bugs OR affirm "None observed; ran with default fixtures only". | §Top 3 Improvement Action #1. |
|
||||
| `.cursor/skills/retrospective/SKILL.md` (Step 1 metrics) | Add **"User-action backlog drain rate"** metric: items added this cycle / items closed this cycle / net delta / absolute close-count; track alongside the absolute count introduced in cycle 3. | §Top 3 Improvement Action #3. |
|
||||
| `.cursor/skills/retrospective/templates/retrospective-report.md` | Add a "User-action backlog drain rate" sub-table alongside the absolute table under Efficiency. | §Top 3 Improvement Action #3. |
|
||||
| `_docs/LESSONS.md` (top) | Append the 3 lessons in §LESSONS Append below; trim to ≤ 15 entries. | Skill Step 4. |
|
||||
|
||||
## Notes — Step 16 outcome
|
||||
|
||||
Step 16 (Deploy) ran in **real-cutover mode (option A)** for the second consecutive cycle. Push scope was ui/ `dev` only (4 commits, fast-forward `09449bd..8737491`). The cycle-3 closure commit `eef3bdf` (which had been locally ahead since cycle 3's push) shipped this cycle alongside cycle-4's three commits. Stage / main / admin/ `dev` pushes were deferred at the push-scope sub-gate (user chose option A — ui/ dev only).
|
||||
|
||||
- Devices will not auto-pull cycle-3 + cycle-4 changes until `dev → stage → main` completes (D-CY4-STAGE, D-CY4-MAIN).
|
||||
- AZ-513 task spec still sits locally on `admin/` `dev` — admin/ team cannot pick it up until D-CY3-ADMIN-PUSH lands (now carried into cycle 5).
|
||||
- No Dockerfile / `.woodpecker/` / nginx / env changes in cycle 4 — verified inline by the security audit (Step 14 enumerated the changed-file set as 6 source/test + 5 doc files only).
|
||||
- The deployed ui/ dev build will surface `admin.classes.updateFailed` on real edits until AZ-513 ships in admin/ — by design under the user-authorized Option B path.
|
||||
|
||||
These items add to the user-action backlog; see §Efficiency → User-action backlog table.
|
||||
|
||||
## LESSONS Append (top 3, single-sentence, tagged)
|
||||
|
||||
1. **[testing]** When inserting a new control (button, input, link) into an existing DOM row or region that already holds other controls, audit the test corpus *before* the commit for non-disambiguated selectors targeting that region (`querySelector('button')`, `getByRole('button')` without `name`/`text`, indexed `querySelectorAll('button')[0]`) and either update them with disambiguating text/role/name in the same affordance commit or give the new control a stable `data-testid` — otherwise the new control silently rebinds existing assertions to the wrong element and the tests ship green-but-meaningless, exactly as cycle 4's `destructive_ux.test.tsx` did when the AZ-512 ✎ button became the new first button in the class-row action cell.
|
||||
|
||||
2. **[testing]** When a new test mounts a container component end-to-end, run it once with the project's default test fixtures only (no per-test override) and explicitly name any natural crashes ("`users.map is not a function`") in the batch report as "Pre-existing bug noted" — never silently apply a local fixture workaround without recording the latent drift, because each silent workaround hides a source-vs-fixture mismatch that future authors will re-encounter as a "mysterious test setup", and cycle 4's `tests/admin_class_edit.test.tsx` was the second cycle to surface one through this route.
|
||||
|
||||
3. **[process]** When the user explicitly overrides a spec-conservative cycle-defer decision (the AZ-512 Option B authorization: "implement now, write mocks for backend"), the autodev MUST preserve every downstream gate that the conservative path would have enforced — re-record the override rationale in the leftover entry, keep the cross-workspace deploy gate visible at Step 16, mark the carried tickets distinctly from cycle-internal carries, and surface the override as a first-class retrospective trend ("Cycles where user overrode a spec-conservative default") — so the operating cost of the override stays measurable and the user's downstream visibility is unchanged from the conservative path.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Structural Snapshot — 2026-05-13 (Phase B Cycle 4 close)
|
||||
|
||||
**Cycle**: Phase B, cycle 4 (`state.cycle = 4`)
|
||||
**Source-of-truth files**: `_docs/02_document/module-layout.md`, `_docs/02_document/architecture_compliance_baseline.md`, `scripts/check-arch-imports.mjs`, `scripts/run-tests.sh`, `src/api/endpoints.test.ts`.
|
||||
**Previous snapshot**: `_docs/06_metrics/structure_2026-05-13.md` (Phase B cycle 3 close).
|
||||
|
||||
> Cycle 4 was a single-task, contained UI-feature cycle (AZ-512 admin class inline edit). It introduced **no new components**, **no new gates**, **no new barrels**, **no new wire-contract assertions**, and **no new architecture findings**. The structural snapshot is therefore a near-identity copy of cycle 3 close with two non-structural deltas: test count (+12) and bundle size (+757 B).
|
||||
|
||||
## Component Inventory
|
||||
|
||||
| Metric | Cycle 1 close | Cycle 3 close | Cycle 4 close | Δ vs cycle 3 |
|
||||
|--------|---------------|---------------|---------------|--------------|
|
||||
| Component count | 12 | 12 | 12 | 0 |
|
||||
| Components with Public API barrels | 11 | 11 | 11 | 0 |
|
||||
| Barrel coverage (eligible components) | 100 % | 100 % | 100 % | 0 |
|
||||
| Documented feature→feature edges (grandfathered) | 1 | 1 | 1 | 0 |
|
||||
| Documented STC-ARCH-01 carve-out exemptions | 1 | 0 | 0 | 0 (held at zero) |
|
||||
| Cycles in component import graph | 0 | 0 | 0 | 0 |
|
||||
|
||||
## Architecture Gates (cycle 4 close)
|
||||
|
||||
| Gate | Added in | Enforces | Status (cycle 4 close) |
|
||||
|------|----------|----------|------------------------|
|
||||
| `STC-ARCH-01` | Cycle 1 / AZ-485 | No cross-component deep imports; barrels are the Public API | PASS (zero exemptions — held since cycle 3) |
|
||||
| `STC-ARCH-02` | Cycle 1 / AZ-486 | No hardcoded `/api/<service>/...` literals in production source | PASS |
|
||||
| `STC-SEC1C` | Cycle 2 / AZ-499 | Banned literal: OpenWeatherMap key | PASS |
|
||||
| `STC-SEC1D` | Cycle 2 / AZ-501 | Banned literal: Google Geocode key | PASS |
|
||||
| `FT-P-22` (key parity) | (i18n coverage gate) | `en.json` ↔ `ua.json` key parity | PASS (extended cycle 4: covers `admin.classes.{title,edit,save,cancel,nameRequired,maxSizeMustBePositive,updateFailed}`) |
|
||||
| `FT-P-23` (no hardcoded strings) | (i18n coverage gate) | No raw English strings outside i18n bundles | PASS (the aria-label-as-hardcoded-English failure during cycle-4 implementation was caught by this gate and fixed before commit — see batch_16_cycle4_report.md "Pre-existing bug noted") |
|
||||
|
||||
Total commit-time static gates: **33** (cycle 3 close = 33; cycle 4 close = 33 — no new gates this cycle, **all existing gates green**).
|
||||
|
||||
## Architecture Baseline Delta vs `architecture_compliance_baseline.md`
|
||||
|
||||
| Finding | Category | Cycle 1 close | Cycle 2 close | Cycle 3 close | Cycle 4 close |
|
||||
|---------|----------|---------------|---------------|---------------|---------------|
|
||||
| F1 — mission-planner vs flights duplication | Architecture | Open | Open | Open | Open |
|
||||
| F2 — cross-feature edge `07_dataset → 06_annotations` | Architecture | Open (grandfathered) | Open | Open | Open |
|
||||
| F3 — classColors physical/logical owner split | Architecture | Open | Open | RESOLVED (AZ-511) | RESOLVED |
|
||||
| F4 — No Public API barrels | Architecture | RESOLVED (AZ-485) | RESOLVED | RESOLVED | RESOLVED |
|
||||
| F5 — Pre-existing cycle inside `mission-planner` | Architecture | Open | Open | Open | Open |
|
||||
| F6 — No `src/shared/` | Architecture | Open | Open | Open | Open |
|
||||
| F7 — Hardcoded `/api/<service>/` literals | Architecture | RESOLVED (AZ-486) | RESOLVED | RESOLVED | RESOLVED |
|
||||
| F8 — Layering-table inconsistency | Architecture | Open | Open | Open | Open |
|
||||
| F9 — Inert second Vite entry tree | Architecture | Open | Open | Open | Open |
|
||||
|
||||
- **Resolved this cycle**: 0
|
||||
- **Newly introduced this cycle**: 0
|
||||
- **Architecture findings open at cycle 4 close**: 6 of 9 baseline (F1, F2, F5, F6, F8, F9) — unchanged
|
||||
- **Net architecture delta cycle 4**: 0 (no movement)
|
||||
|
||||
## Contract Coverage
|
||||
|
||||
- `_docs/02_document/contracts/` does NOT exist; project uses **code-derived contracts pattern** via `src/api/endpoints.test.ts`.
|
||||
- Wire-contract assertions count: cycle 1 = 36, cycle 2 = 36, cycle 3 = 37, cycle 4 = **37** (no change — AZ-512 reused the existing `endpoints.admin.class(id)` builder for PATCH; no new builder introduced per task constraint).
|
||||
|
||||
## Test Suite Snapshot
|
||||
|
||||
| Profile | Cycle 1 close | Cycle 2 close | Cycle 3 close | Cycle 4 close | Δ vs cycle 3 |
|
||||
|---------|---------------|---------------|---------------|---------------|--------------|
|
||||
| Fast (count) | 209 PASS / 13 SKIP / 0 FAIL | 229 PASS / 13 SKIP / 0 FAIL | 231 PASS / 13 SKIP / 0 FAIL | **243 PASS / 13 SKIP / 0 FAIL** | **+12 PASS**, 0 SKIP |
|
||||
| Static (gates) | 31 / 31 PASS | 33 / 33 PASS | 33 / 33 PASS | 33 / 33 PASS | 0 |
|
||||
| Build | green | green | green | green | 0 |
|
||||
| Bundle (gzipped initial JS) | not measured | 290 465 B | 290 575 B | **291 332 B** | **+757 B** (+0.26 %) |
|
||||
|
||||
The +12 PASS comes from `tests/admin_class_edit.test.tsx` (the entire AZ-512 suite). No other test files changed counts; `tests/destructive_ux.test.tsx`'s selector fix kept its existing 6 cases (2 fixed, 4 carried).
|
||||
|
||||
The +757 B bundle delta is explained at byte-level in `_docs/06_metrics/perf_2026-05-13_cycle4.md` (~500–600 B from the new `AdminPage` handlers + JSX, ~150–200 B from 7 i18n keys × 2 locales).
|
||||
|
||||
## Cycle 4 Source-of-Truth Mutations
|
||||
|
||||
| File / area | Mutation | Driver |
|
||||
|-------------|----------|--------|
|
||||
| `src/features/admin/AdminPage.tsx` | Inline edit state (4 hooks), 4 new handlers, conditional colspan row JSX, pencil affordance, `t('admin.classes')` → `t('admin.classes.title')` | AZ-512 |
|
||||
| `src/i18n/en.json`, `src/i18n/ua.json` | `admin.classes` flat string → nested object (`title` + 6 keys for edit UI in both locales) | AZ-512 |
|
||||
| `tests/msw/handlers/admin.ts` | New `http.patch('/api/admin/classes/:id', ...)` partial-merge handler | AZ-512 (test infra) |
|
||||
| `tests/admin_class_edit.test.tsx` | NEW — 12 tests covering AC-1..AC-6, AC-8 | AZ-512 |
|
||||
| `tests/destructive_ux.test.tsx` | Selector fix at 3 call sites (`querySelector('button')` → `Array.from(...).find(b => b.textContent === '×')`) | Adjacent hygiene from AZ-512 |
|
||||
| `_docs/02_document/components/08_admin/description.md` | Edit affordance + PATCH wiring recorded | AZ-512 (spec-authorized) |
|
||||
| `_docs/02_document/architecture.md` (row 272) | `08_admin/AdminPage` row gains PATCH /api/admin/classes/{id} with AZ-513 deploy-gate caveat | Step 13 (Update Docs) |
|
||||
| `_docs/02_document/modules/src__features__admin__AdminPage.md` | Header cycle-4 banner; new state slots, four new handlers, layout note, PATCH integrations row, expanded i18n key list, Tests section | Step 13 (Update Docs) |
|
||||
| `_docs/02_document/tests/blackbox-tests.md` | Added FT-P-62, FT-N-18 | Step 12 (Test-Spec Sync) |
|
||||
| `_docs/02_document/tests/traceability-matrix.md` | O9 → Covered; references FT-P-62 + FT-N-18 + AZ-513 deploy gate | Step 12 (Test-Spec Sync) |
|
||||
| `_docs/05_security/security_report_cycle4_delta.md` | NEW — cycle-4 delta; verdict PASS_WITH_WARNINGS; one new LOW finding (F-SAST-CY4-1) | Step 14 (Security Audit) |
|
||||
| `_docs/05_security/security_report.md` | Cycle-4 amendment banner | Step 14 (Security Audit) |
|
||||
| `_docs/06_metrics/perf_2026-05-13_cycle4.md` | NEW — NFT-PERF-01 PASS at 291 332 B | Step 15 (Performance Test) |
|
||||
|
||||
## Auto-lesson triggers (per skill Step 1)
|
||||
|
||||
- Net Architecture delta > 0? **No** — delta is 0. No `architecture` lesson trigger.
|
||||
- Structural metric regression > 20%? **No** — every structural metric held at its cycle-3 value, except test count (+5.2%) and bundle (+0.26%), both improvements / within-budget.
|
||||
- Contract coverage % decreased? **No** — same 37 assertions (no builder added, no builder removed).
|
||||
- New finding category emerged? **No** — security audit ran in delta mode; categories are stable.
|
||||
|
||||
No auto-lesson triggers fired in cycle 4. Manual lessons (3 picked) appear in the retro report.
|
||||
@@ -8,6 +8,42 @@ Categories: estimation · architecture · testing · dependencies · tooling ·
|
||||
|
||||
---
|
||||
|
||||
- [2026-05-13] [testing] When inserting a new control (button, input, link)
|
||||
into an existing DOM row or region that already holds other controls, audit
|
||||
the test corpus *before* the commit for non-disambiguated selectors
|
||||
targeting that region (`querySelector('button')`, `getByRole('button')`
|
||||
without `name`/`text`, indexed `querySelectorAll('button')[0]`) and either
|
||||
update them with disambiguating text/role/name in the same affordance
|
||||
commit or give the new control a stable `data-testid` — otherwise the new
|
||||
control silently rebinds existing assertions to the wrong element and the
|
||||
tests ship green-but-meaningless, as cycle 4's `destructive_ux.test.tsx`
|
||||
did when the AZ-512 ✎ button became the new first button in the class-row
|
||||
action cell.
|
||||
Source: _docs/06_metrics/retro_2026-05-13_cycle4.md
|
||||
|
||||
- [2026-05-13] [testing] When a new test mounts a container component
|
||||
end-to-end, run it once with the project's default test fixtures only (no
|
||||
per-test override) and explicitly name any natural crashes ("`users.map is
|
||||
not a function`") in the batch report as "Pre-existing bug noted" — never
|
||||
silently apply a local fixture workaround without recording the latent
|
||||
drift, because each silent workaround hides a source-vs-fixture mismatch
|
||||
that future authors will re-encounter as a "mysterious test setup", and
|
||||
cycle 4's `tests/admin_class_edit.test.tsx` was the second cycle to
|
||||
surface one through this route.
|
||||
Source: _docs/06_metrics/retro_2026-05-13_cycle4.md
|
||||
|
||||
- [2026-05-13] [process] When the user explicitly overrides a
|
||||
spec-conservative cycle-defer decision (the AZ-512 Option B authorization:
|
||||
"implement now, write mocks for backend"), the autodev MUST preserve every
|
||||
downstream gate that the conservative path would have enforced — re-record
|
||||
the override rationale in the leftover entry, keep the cross-workspace
|
||||
deploy gate visible at Step 16, mark the carried tickets distinctly from
|
||||
cycle-internal carries, and surface the override as a first-class
|
||||
retrospective trend ("Cycles where user overrode a spec-conservative
|
||||
default") — so the operating cost of the override stays measurable and
|
||||
the user's downstream visibility is unchanged from the conservative path.
|
||||
Source: _docs/06_metrics/retro_2026-05-13_cycle4.md
|
||||
|
||||
- [2026-05-13] [process] When a task spec defines a Cross-Workspace Verification
|
||||
BLOCKING gate and the user skips the choice prompt, the autodev MUST default
|
||||
to the most conservative spec-aligned option (Option A: file prerequisite
|
||||
|
||||
+12
-7
@@ -2,19 +2,24 @@
|
||||
|
||||
## Current Step
|
||||
flow: existing-code
|
||||
step: 16
|
||||
name: Deploy
|
||||
step: 9
|
||||
name: New Task
|
||||
status: not_started
|
||||
sub_step:
|
||||
phase: 0
|
||||
name: awaiting-invocation
|
||||
detail: ""
|
||||
retry_count: 0
|
||||
cycle: 4
|
||||
cycle: 5
|
||||
tracker: jira
|
||||
|
||||
## Notes
|
||||
- Cycle 4 batch 16 shipped (commit ecacfa8): AZ-512 — 3/3 pts. Jira: To Do → In Testing.
|
||||
- Cross-workspace: AZ-513 on admin/ NOT shipped. Step 16 (Deploy) gates on it.
|
||||
- Leftovers: `2026-05-12_az-498-deploy-and-key-revocations.md` (manual), `2026-05-13_az-512-admin-classes-prereq.md` (re-opened).
|
||||
- Pre-existing bug surfaced during AZ-512: `/api/admin/users` MSW shape (paginated) vs `AdminPage` consumption (flat `User[]`) mismatch. Flagged in batch + impl reports; needs separate UI ticket triage.
|
||||
- Cycle 4 CLOSED: AZ-512 — 3/3 pts. Jira: In Testing → Done. Retro `retro_2026-05-13_cycle4.md` + deploy report `deploy_cycle4_report.md` + perf `perf_2026-05-13_cycle4.md` + security delta `security_report_cycle4_delta.md` + structure snapshot `structure_2026-05-13_cycle4.md` written. Cycle-4 push: `09449bd..8737491` (4 commits) → origin/dev.
|
||||
- Cycle 5 awaiting next `/autodev` New Task invocation.
|
||||
- Leftovers carried into cycle 5 (replay at Step 0):
|
||||
- `2026-05-12_az-498-deploy-and-key-revocations.md` — 3 manual third-party items (UI satellite-provider deploy gate; OWM revoke; Google Geocode revoke).
|
||||
- `2026-05-13_az-512-admin-classes-prereq.md` — re-opened under user-authorized Option B; closes when AZ-513 ships AND deploys in admin/.
|
||||
- Cross-workspace status: AZ-513 (admin/) still not implemented. UI's PATCH /api/admin/classes/{id} returns 404 in any env until admin/ ships AZ-513.
|
||||
- User-action backlog at cycle-4 close (per retro): 9 items (de-duplicated). +2 vs cycle 3.
|
||||
- Pre-existing bug surfaced during AZ-512: `/api/admin/users` MSW shape (paginated) vs `AdminPage` consumption (flat `User[]`). Awaiting separate UI-workspace ticket triage; pre-existing, not introduced by AZ-512.
|
||||
- Cycle-3 deferred deploy items still carry: D-CY3-STAGE, D-CY3-MAIN, D-CY3-ADMIN-PUSH. Cycle 4 added: D-CY4-STAGE, D-CY4-MAIN (D-CY4-ADMIN-PUSH not added — user kept same ui/-dev-only scope).
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Azaion UI – v2 Visual-Polish Redesign
|
||||
|
||||
Two parallel takes on the same brief: refresh the original wireframes in [_docs/ui_design/](../) without touching their information architecture. The originals stay as the source of truth for **what** each page contains; v2 explores **how** it could look.
|
||||
|
||||
## Aesthetic direction
|
||||
|
||||
**"Tactical Operations Console"** — defense-grade mission control, leaning on the visual language of air-traffic control consoles and Bloomberg-style trader terminals. Dense, technical, deliberate. The drone-annotation domain rewards this register more than the generic dark-SaaS look the originals defaulted to.
|
||||
|
||||
Shared design tokens (palette, typography, form language) are spelled out in [plugin/_design_system.md](plugin/_design_system.md). The Stitch project uses the same tokens in its design-system asset.
|
||||
|
||||
| Token | Value |
|
||||
|-------|-------|
|
||||
| Page bg | `#0A0D10` |
|
||||
| Panels | `#13171C` |
|
||||
| Raised | `#1A1F26` |
|
||||
| Hairlines | `#252B34` |
|
||||
| Amber accent | `#FF9D3D` |
|
||||
| Cyan accent | `#36D6C5` |
|
||||
| Red accent | `#FF4756` |
|
||||
| Green accent | `#3DDC84` |
|
||||
| Blue accent | `#4E9EFF` |
|
||||
| Display / mono | JetBrains Mono |
|
||||
| Body | IBM Plex Sans |
|
||||
|
||||
## Versions
|
||||
|
||||
### plugin/ — frontend-design plugin
|
||||
|
||||
Self-contained HTML, double-click to view. Tailwind via CDN + an inline `<style>` block per page for design tokens, fonts, and the corner-bracket utility. These are the version closest to the brief — every spec point in the design system is honored.
|
||||
|
||||
| Page | File |
|
||||
|------|------|
|
||||
| Flights | [plugin/flights.html](plugin/flights.html) |
|
||||
| Annotations | [plugin/annotations.html](plugin/annotations.html) |
|
||||
| Dataset Explorer | [plugin/dataset_explorer.html](plugin/dataset_explorer.html) |
|
||||
| Admin | [plugin/admin.html](plugin/admin.html) |
|
||||
| Settings | [plugin/settings.html](plugin/settings.html) |
|
||||
|
||||
Signature moves:
|
||||
- Amber 8px **corner brackets** on every major panel — the through-line that ties the whole system together.
|
||||
- ALL-CAPS mono micro-labels with `0.12em` letter-spacing.
|
||||
- Tabular numerics everywhere; lat/lon/sat/port/frame-counts/percentages all align.
|
||||
- Real inline-SVG NATO affiliation icons on the Annotations canvas (rectangle / diamond / quatrefoil) — not text glyphs.
|
||||
- Annotation list rows carry per-row class-color gradient stripes.
|
||||
- GPS-Denied mode flips the panel framing from amber to red 2px brackets + a pulsing "GPS-DENIED ACTIVE" badge.
|
||||
|
||||
### stitch/ — Google Stitch MCP
|
||||
|
||||
Generated through Google's Stitch design tool against the same design-system asset (project ID `15028193902086176686`, design system `assets/6747203704700882150`). These ship as wider full-page renders (2560 × 2048) and use Stitch's component vocabulary — useful as an alternate take to A/B against the plugin version.
|
||||
|
||||
| Page | File |
|
||||
|------|------|
|
||||
| Flights | [stitch/flights.html](stitch/flights.html) |
|
||||
| Annotations | [stitch/annotations.html](stitch/annotations.html) |
|
||||
| Dataset Explorer | [stitch/dataset_explorer.html](stitch/dataset_explorer.html) |
|
||||
| Admin | [stitch/admin.html](stitch/admin.html) |
|
||||
| Settings | [stitch/settings.html](stitch/settings.html) |
|
||||
|
||||
**Stitch project URL**: open `projects/15028193902086176686` inside the Stitch web UI to view, edit, or re-export.
|
||||
|
||||
## How to compare
|
||||
|
||||
```
|
||||
# Originals
|
||||
_docs/ui_design/flights.html
|
||||
_docs/ui_design/annotations.html
|
||||
...
|
||||
|
||||
# Plugin redesign
|
||||
_docs/ui_design/v2/plugin/flights.html
|
||||
_docs/ui_design/v2/plugin/annotations.html
|
||||
...
|
||||
|
||||
# Stitch redesign
|
||||
_docs/ui_design/v2/stitch/flights.html
|
||||
...
|
||||
```
|
||||
|
||||
Open the three side-by-side in a browser. The plugin version is the recommended baseline for adopting into the React app; the Stitch version is useful for client-facing concept presentations.
|
||||
|
||||
## What's NOT in scope
|
||||
|
||||
- No changes to React components in `src/`. These are static design references.
|
||||
- No backend / API changes.
|
||||
- No IA / interaction rework — only visual polish. If a page's layout in `README.md` says "left sidebar 250px + main + right sidebar 200px," v2 keeps that.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Azaion Tactical Ops — Design System (Plugin Version)
|
||||
|
||||
Shared aesthetic spec for every page in `_docs/ui_design/v2/plugin/`. **Every page must adhere to this contract.** If a page deviates from a token here, that's a bug.
|
||||
|
||||
## Aesthetic
|
||||
|
||||
Defense / mission-control console. Dense, technical, deliberate. Think air-traffic-control + military HUD + Bloomberg Terminal — never gamer-RGB, never consumer-glossy.
|
||||
|
||||
## Palette (dark only, no light mode)
|
||||
|
||||
```
|
||||
--surface-0: #0A0D10 /* page bg */
|
||||
--surface-1: #13171C /* panels, sidebars */
|
||||
--surface-2: #1A1F26 /* raised rows, hover */
|
||||
--surface-input: #0A0D10 /* input fill, sits darker than the panel containing it */
|
||||
--border-hair: #252B34 /* 1px borders, used everywhere */
|
||||
--border-raised: #3B4451 /* used for active/focus 2px */
|
||||
--text-primary: #E8ECF1
|
||||
--text-secondary: #9AA4B2
|
||||
--text-muted: #5B6573
|
||||
--accent-amber: #FF9D3D /* primary / brand / warnings */
|
||||
--accent-cyan: #36D6C5 /* live data, friendly */
|
||||
--accent-red: #FF4756 /* hostile, destructive, GPS-denied */
|
||||
--accent-green: #3DDC84 /* validated, connected, ready */
|
||||
--accent-blue: #4E9EFF /* info, edited */
|
||||
```
|
||||
|
||||
Class colors (used in detection-class swatches) stay as-is from README.md (`#FF0000`, `#00FF00`, `#0000FF`, `#FFFF00`, `#FF00FF`, `#00FFFF` etc.) — those are domain data, not theme.
|
||||
|
||||
## Typography
|
||||
|
||||
- Headline / display / micro-labels / numerics → **JetBrains Mono** (Google Fonts)
|
||||
- Body / general UI text → **IBM Plex Sans** (Google Fonts)
|
||||
- ALL-CAPS micro-labels: `font: 10px/1.4 'JetBrains Mono'; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-secondary)`
|
||||
- Numerics: always `font-variant-numeric: tabular-nums`
|
||||
- Body default: `13px/1.5 'IBM Plex Sans'`, primary color
|
||||
- Page section heading: `11px` mono, uppercase, amber color
|
||||
|
||||
Include the Google Fonts links in each `<head>`:
|
||||
|
||||
```html
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
```
|
||||
|
||||
## Form language
|
||||
|
||||
- 1px hairline borders everywhere; corners square or `border-radius: 2px` / `4px` max — never `rounded-full` outside of status dots and avatar.
|
||||
- Active panel borders use 2px in amber (`--accent-amber`) or cyan.
|
||||
- **Corner brackets** — the signature element. Frame *every* major panel/card with four 8px L-shaped brackets, drawn as two 1px lines per corner in amber (or in the panel-active color). Use this CSS helper:
|
||||
|
||||
```css
|
||||
.bracket { position: relative; }
|
||||
.bracket::before, .bracket::after,
|
||||
.bracket > .br::before, .bracket > .br::after {
|
||||
content: ''; position: absolute; width: 8px; height: 8px;
|
||||
border-color: var(--accent-amber); border-style: solid; border-width: 0;
|
||||
}
|
||||
.bracket::before { top: -1px; left: -1px; border-top-width: 1px; border-left-width: 1px; }
|
||||
.bracket::after { top: -1px; right: -1px; border-top-width: 1px; border-right-width: 1px; }
|
||||
.bracket > .br::before { bottom: -1px; left: -1px; border-bottom-width: 1px; border-left-width: 1px; }
|
||||
.bracket > .br::after { bottom: -1px; right: -1px; border-bottom-width: 1px; border-right-width: 1px; }
|
||||
```
|
||||
|
||||
then `<div class="bracket panel">…<span class="br"></span></div>`.
|
||||
|
||||
- Subtle background grid (60px × 60px, 3% white) on map/canvas surfaces:
|
||||
|
||||
```css
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
```
|
||||
|
||||
- Status pills: leading 6px dot + UPPERCASE 10px mono label, 1px border in status color, transparent fill, 2px radius.
|
||||
|
||||
```html
|
||||
<span class="pill pill-green"><span class="dot"></span>VALIDATED</span>
|
||||
```
|
||||
|
||||
- Live indicator: 6px dot in cyan or red, with `animation: pulse 1.6s ease-in-out infinite`.
|
||||
|
||||
## Spacing
|
||||
|
||||
- Base 4px.
|
||||
- Panel padding: 16px.
|
||||
- Form gap: 12px between fields.
|
||||
- Tight list row height: 28px (sidebars), 32px (tables).
|
||||
- Header bar height: 48px.
|
||||
|
||||
## Components
|
||||
|
||||
**Buttons**
|
||||
|
||||
- Primary: `bg: amber; color: #0A0D10; border: 1px solid amber; padding: 6px 14px; font: 11px mono; letter-spacing: 0.08em; text-transform: uppercase`
|
||||
- Secondary: `bg: transparent; color: amber; border: 1px solid amber` (with hover → fill at 12% opacity)
|
||||
- Ghost: same as secondary but `border: 1px solid var(--border-hair); color: var(--text-secondary)`
|
||||
- Danger: red variant of primary
|
||||
- Icon button: 28×28, ghost styling
|
||||
|
||||
**Inputs**
|
||||
|
||||
- `bg: var(--surface-input); border: 1px solid var(--border-hair); border-radius: 2px; padding: 6px 10px; height: 32px; font: 12px 'IBM Plex Sans'; color: var(--text-primary)`
|
||||
- Focus: `border-color: var(--accent-amber); box-shadow: 0 0 0 1px var(--accent-amber)`
|
||||
- Placeholder: `var(--text-muted)`
|
||||
|
||||
**Tables**
|
||||
|
||||
- No zebra stripes. Row separator = 1px hairline. Header row: 10px mono uppercase, secondary text. Hover row → `var(--surface-2)`.
|
||||
|
||||
## Global header
|
||||
|
||||
```
|
||||
[AZAION mark] [FLIGHT SELECTOR ▾] | FLIGHTS / ANNOTATIONS / DATASET / ADMIN [user@x.com] [⚙] [⏻]
|
||||
```
|
||||
|
||||
- Logo: amber, JetBrains Mono Bold, `tracking: 0.2em`, `font-size: 14px`.
|
||||
- Flight selector: 28px-tall pill with mono flight id + ▾ icon, 1px amber border, surface-1 fill.
|
||||
- Tab nav: each tab is a flat label with 2px bottom border in amber when active, no top-rounding, 12px sans.
|
||||
- Header bottom: 1px hairline.
|
||||
|
||||
## Mobile bottom nav (optional, only if implementing responsive)
|
||||
|
||||
Hide tab nav at `< 768px` and show a 56px fixed bottom bar with 5 icon+label items.
|
||||
|
||||
## Don't
|
||||
|
||||
- No purple gradients. No glassmorphism. No drop shadows over 4px blur.
|
||||
- No emoji used as functional UI. (Decorative readouts may use the bracket characters `⌐ ¬ ⌜ ⌝ ⌞ ⌟`.)
|
||||
- No rounded-full anywhere except status dots and avatar circle.
|
||||
- Don't change the IA / panel arrangement defined in `../../README.md` — this pass is visual polish only.
|
||||
@@ -0,0 +1,837 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AZAION // ADMIN — System Configuration</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--surface-0: #0A0D10;
|
||||
--surface-1: #13171C;
|
||||
--surface-2: #1A1F26;
|
||||
--surface-input: #0A0D10;
|
||||
--border-hair: #252B34;
|
||||
--border-raised: #3B4451;
|
||||
--text-primary: #E8ECF1;
|
||||
--text-secondary: #9AA4B2;
|
||||
--text-muted: #5B6573;
|
||||
--accent-amber: #FF9D3D;
|
||||
--accent-cyan: #36D6C5;
|
||||
--accent-red: #FF4756;
|
||||
--accent-green: #3DDC84;
|
||||
--accent-blue: #4E9EFF;
|
||||
}
|
||||
|
||||
html, body { background: var(--surface-0); color: var(--text-primary); }
|
||||
body {
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
}
|
||||
|
||||
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
|
||||
.tnum { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.micro {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sect-head {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
/* Corner brackets */
|
||||
.bracket { position: relative; }
|
||||
.bracket::before, .bracket::after,
|
||||
.bracket > .br::before, .bracket > .br::after {
|
||||
content: ''; position: absolute; width: 8px; height: 8px;
|
||||
border-color: var(--accent-amber); border-style: solid; border-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.bracket::before { top: -1px; left: -1px; border-top-width: 1px; border-left-width: 1px; }
|
||||
.bracket::after { top: -1px; right: -1px; border-top-width: 1px; border-right-width: 1px; }
|
||||
.bracket > .br::before { bottom: -1px; left: -1px; border-bottom-width: 1px; border-left-width: 1px; }
|
||||
.bracket > .br::after { bottom: -1px; right: -1px; border-bottom-width: 1px; border-right-width: 1px; }
|
||||
|
||||
/* Subtle grid backdrop */
|
||||
.grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.inp {
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-hair);
|
||||
border-radius: 2px;
|
||||
height: 32px;
|
||||
padding: 6px 10px;
|
||||
font: 12px 'IBM Plex Sans';
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
.inp:focus { border-color: var(--accent-amber); box-shadow: 0 0 0 1px var(--accent-amber); }
|
||||
.inp::placeholder { color: var(--text-muted); }
|
||||
.inp-mono { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 28px; padding: 0 12px;
|
||||
font: 600 11px 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 2px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color .12s, color .12s, border-color .12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent-amber);
|
||||
color: #0A0D10;
|
||||
border-color: var(--accent-amber);
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(1.08); }
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--accent-amber);
|
||||
border-color: var(--accent-amber);
|
||||
}
|
||||
.btn-secondary:hover { background: rgba(255,157,61,.12); }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-hair);
|
||||
}
|
||||
.btn-ghost:hover { color: var(--text-primary); border-color: var(--border-raised); }
|
||||
.btn-danger {
|
||||
background: var(--accent-red);
|
||||
color: #0A0D10;
|
||||
border-color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* Icon button */
|
||||
.ibtn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: color .1s, background .1s, border-color .1s;
|
||||
}
|
||||
.ibtn:hover { color: var(--text-primary); background: var(--surface-2); border-color: var(--border-hair); }
|
||||
.ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent-red); background: rgba(255,71,86,.08); }
|
||||
.ibtn.edit:hover { color: var(--accent-amber); border-color: var(--accent-amber); background: rgba(255,157,61,.08); }
|
||||
.ibtn.cyan:hover { color: var(--accent-cyan); border-color: var(--accent-cyan); background: rgba(54,214,197,.08); }
|
||||
|
||||
/* Header-scoped icon buttons override the smaller in-table variant */
|
||||
header .ibtn {
|
||||
width: 28px; height: 28px;
|
||||
border: 1px solid var(--border-hair);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
header .ibtn:hover { background: var(--surface-2); color: var(--text-primary); border-color: var(--border-raised); }
|
||||
header .ibtn.active { color: var(--accent-amber); border-color: var(--accent-amber); background: rgba(255,157,61,0.08); }
|
||||
header .ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent-red); background: rgba(255,71,86,0.08); }
|
||||
|
||||
/* Pills */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 18px; padding: 0 8px;
|
||||
font: 600 10px 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.10em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.pill-green { color: var(--accent-green); }
|
||||
.pill-red { color: var(--accent-red); }
|
||||
.pill-cyan { color: var(--accent-cyan); }
|
||||
.pill-amber { color: var(--accent-amber); }
|
||||
.pill-blue { color: var(--accent-blue); }
|
||||
.pill-muted { color: var(--text-muted); }
|
||||
|
||||
/* Chip (role chips, type chips — solid filled, denser) */
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
height: 18px; min-width: 60px; padding: 0 8px;
|
||||
font: 600 10px 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.chip-admin { background: rgba(255,157,61,.16); color: var(--accent-amber); border: 1px solid rgba(255,157,61,.35); }
|
||||
.chip-operator { background: rgba(78,158,255,.14); color: var(--accent-blue); border: 1px solid rgba(78,158,255,.35); }
|
||||
.chip-viewer { background: rgba(154,164,178,.10); color: var(--text-secondary); border: 1px solid var(--border-hair); }
|
||||
|
||||
/* Type squares (P / C / F) */
|
||||
.type-sq {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 2px;
|
||||
font: 700 9px 'JetBrains Mono', monospace;
|
||||
color: #0A0D10;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Color swatch */
|
||||
.swatch {
|
||||
display: inline-block; width: 12px; height: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
border-radius: 1px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Segmented control */
|
||||
.seg { display: inline-flex; border: 1px solid var(--border-hair); border-radius: 2px; overflow: hidden; }
|
||||
.seg-btn {
|
||||
height: 30px; padding: 0 14px;
|
||||
font: 600 10px 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.10em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-input);
|
||||
border-right: 1px solid var(--border-hair);
|
||||
cursor: pointer;
|
||||
transition: background .1s, color .1s;
|
||||
}
|
||||
.seg-btn:last-child { border-right: 0; }
|
||||
.seg-btn:hover { color: var(--text-primary); }
|
||||
.seg-btn.active {
|
||||
background: var(--accent-amber);
|
||||
color: #0A0D10;
|
||||
}
|
||||
|
||||
/* Header bar */
|
||||
.tab {
|
||||
display: inline-flex; align-items: center;
|
||||
height: 48px; padding: 0 14px;
|
||||
font: 500 12px/1 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.10em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab:hover { color: var(--text-primary); }
|
||||
.tab.active { color: var(--text-primary); border-bottom-color: var(--accent-amber); font-weight: 500; }
|
||||
|
||||
/* Table rows */
|
||||
.row-hover:hover { background: var(--surface-2); }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: var(--surface-0); }
|
||||
::-webkit-scrollbar-thumb { background: #1f2630; border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #2a323e; }
|
||||
|
||||
/* Star button */
|
||||
.star { color: var(--accent-amber); }
|
||||
.star-off { color: var(--text-muted); }
|
||||
|
||||
/* Pulse for live dot */
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
.live { animation: pulse 1.6s ease-in-out infinite; }
|
||||
|
||||
/* Reveal-on-hover */
|
||||
.row-hover .reveal { opacity: 0; transition: opacity .12s; }
|
||||
.row-hover:hover .reveal { opacity: 1; }
|
||||
|
||||
/* Card panel base */
|
||||
.panel {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border-hair);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Help hint under labels */
|
||||
.hint { font-size: 11px; color: var(--text-muted); line-height: 1.45; }
|
||||
|
||||
/* tabular numbers in tables */
|
||||
table.tabular td, table.tabular th { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* keep selects matching inp */
|
||||
select.inp { appearance: none; -webkit-appearance: none; background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%),
|
||||
linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%);
|
||||
background-position: calc(100% - 14px) 14px, calc(100% - 9px) 14px;
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 28px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-screen flex flex-col overflow-hidden">
|
||||
|
||||
<!-- ========== GLOBAL HEADER ========== -->
|
||||
<header class="flex items-center px-4 gap-3 border-b" style="background: var(--surface-1); border-color: var(--border-hair); height: 48px;">
|
||||
<span class="mono font-bold" style="color: var(--accent-amber); letter-spacing: 0.2em; font-size: 14px;">AZAION</span>
|
||||
|
||||
<span class="micro" style="color: var(--text-muted);">//</span>
|
||||
|
||||
<button class="inline-flex items-center gap-2 mono" style="height: 28px; padding: 0 10px; background: var(--surface-1); border: 1px solid var(--accent-amber); border-radius: 2px; font-size: 11px; letter-spacing: 0.10em;">
|
||||
<span class="dot live" style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent-cyan);"></span>
|
||||
<span style="color: var(--text-primary);">FL-03</span>
|
||||
<span style="color: var(--text-secondary); font-size: 10px;">▾</span>
|
||||
</button>
|
||||
|
||||
<nav class="flex items-center self-stretch ml-3">
|
||||
<a href="flights.html" class="tab">Flights</a>
|
||||
<a href="annotations.html" class="tab">Annotations</a>
|
||||
<a href="dataset_explorer.html" class="tab">Dataset</a>
|
||||
<a href="#" class="tab active">Admin</a>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-2 ml-auto micro">
|
||||
<span class="dot live" style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent-cyan);"></span>
|
||||
<span style="color: var(--accent-cyan);">LINK</span>
|
||||
<span style="color: var(--border-raised);">|</span>
|
||||
<span style="color: var(--text-secondary); text-transform: none; letter-spacing: 0;">user@azaion.com</span>
|
||||
<span style="color: var(--border-raised); margin: 0 4px;">|</span>
|
||||
<a href="#" class="ibtn" title="Settings">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
</a>
|
||||
<a href="#" class="ibtn danger" title="Sign out">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ========== MAIN LAYOUT ========== -->
|
||||
<main class="flex flex-1 overflow-hidden" style="background: var(--surface-0);">
|
||||
|
||||
<!-- ============ LEFT PANEL: DETECTION CLASSES (340px) ============ -->
|
||||
<aside class="shrink-0 flex flex-col" style="width: 340px; background: var(--surface-1); border-right: 1px solid var(--border-hair);">
|
||||
|
||||
<div class="px-4 pt-4 pb-3 flex items-center justify-between border-b" style="border-color: var(--border-hair);">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="sect-head">DETECTION CLASSES</span>
|
||||
<span class="mono tnum" style="font-size: 10px; color: var(--text-muted);">[19]</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Add -->
|
||||
<div class="px-4 py-3 flex items-center gap-2 border-b" style="border-color: var(--border-hair);">
|
||||
<div class="relative flex-1">
|
||||
<svg class="absolute left-2 top-1/2 -translate-y-1/2" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--text-muted);"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" placeholder="Search class…" class="inp" style="padding-left: 26px; height: 28px; font-size: 11px;">
|
||||
</div>
|
||||
<button class="btn btn-primary">
|
||||
<span>+ ADD</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<table class="w-full tabular">
|
||||
<thead class="sticky top-0" style="background: var(--surface-1);">
|
||||
<tr style="border-bottom: 1px solid var(--border-hair);">
|
||||
<th class="text-left px-3 py-2 micro" style="width: 36px;">#</th>
|
||||
<th class="text-left px-2 py-2 micro">Name</th>
|
||||
<th class="text-center px-2 py-2 micro" style="width: 30px;">Hex</th>
|
||||
<th class="text-right px-3 py-2 micro" style="width: 60px;">Ops</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Row template -->
|
||||
<!-- 0 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">00</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">ArmorVehicle</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #FF0000;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit" title="Edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger" title="Delete"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 1 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">01</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">Truck</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #00FF00;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 2 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">02</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">Vehicle</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #0000FF;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 3 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">03</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">Artillery</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #FFFF00;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 4 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">04</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">Shadow</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #FF00FF;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 5 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">05</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">Trenches</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #00FFFF;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 6 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">06</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">MilitaryMan</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #188021;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 7 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">07</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">TyreTracks</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #800000;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 8 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">08</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">AdditionArmoredTank</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #008000;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 9 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">09</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">Smoke</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #000080;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 10 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">10</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">Plane</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #4060FF;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 11 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">11</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">Moto</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #808000;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 12 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">12</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">CamouflageNet</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #800080;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 13 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">13</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">CamouflageBranches</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #2F4F4F;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 14 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">14</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">Roof</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #1E90FF;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 15 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">15</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">Building</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #FFB6C1;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 16 — inline edit example -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--accent-amber); height: 32px; background: rgba(255,157,61,.06);">
|
||||
<td class="px-3 mono tnum" style="color: var(--accent-amber); font-size: 12px;">16</td>
|
||||
<td class="px-2">
|
||||
<input type="text" value="Caponier" class="inp inp-mono" style="height: 22px; padding: 0 6px; font-size: 11px;">
|
||||
</td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #C04060; box-shadow: 0 0 0 1px var(--accent-amber);"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="inline-flex gap-1">
|
||||
<button class="ibtn cyan" title="Save"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><polyline points="20 6 9 17 4 12"/></svg></button>
|
||||
<button class="ibtn" title="Cancel"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 17 -->
|
||||
<tr class="row-hover" style="border-bottom: 1px solid var(--border-hair); height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">17</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">Ammo</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #33658A;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 18 -->
|
||||
<tr class="row-hover" style="height: 32px;">
|
||||
<td class="px-3 mono tnum" style="color: var(--text-muted); font-size: 12px;">18</td>
|
||||
<td class="px-2"><span style="font-size: 12px;">Protect.Struct</span></td>
|
||||
<td class="px-2 text-center"><span class="swatch" style="background: #969647;"></span></td>
|
||||
<td class="px-3 text-right">
|
||||
<span class="reveal inline-flex gap-1">
|
||||
<button class="ibtn edit"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button class="ibtn danger"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ============ CENTER COLUMN ============ -->
|
||||
<section class="flex-1 overflow-y-auto grid-bg">
|
||||
<div class="max-w-[920px] mx-auto p-6 space-y-6">
|
||||
|
||||
<!-- ===== AI RECOGNITION SETTINGS ===== -->
|
||||
<div>
|
||||
<div class="flex items-end justify-between mb-3">
|
||||
<div>
|
||||
<div class="sect-head">AI RECOGNITION ENGINE</div>
|
||||
<div class="hint mt-1">Detection model runtime parameters. Applied per-flight, hot-reloaded.</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 micro">
|
||||
<span style="color: var(--text-muted);">MODEL</span>
|
||||
<span class="mono tnum" style="color: var(--text-primary);">YOLOV8-X · CKPT-241</span>
|
||||
<span class="pill pill-cyan"><span class="dot live"></span>LOADED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bracket panel p-5">
|
||||
<span class="br"></span>
|
||||
|
||||
<div class="grid grid-cols-3 gap-x-6 gap-y-4">
|
||||
<!-- Frames -->
|
||||
<div>
|
||||
<label class="micro block mb-1">Frames To Recognize</label>
|
||||
<div class="hint mb-2">Number of consecutive frames the model averages before emitting a detection.</div>
|
||||
<div class="flex items-stretch gap-2">
|
||||
<input class="inp inp-mono" value="4" style="text-align: right; width: 88px;">
|
||||
<div class="flex flex-col" style="border: 1px solid var(--border-hair); border-radius: 2px;">
|
||||
<button class="mono" style="width: 24px; height: 15px; font-size: 9px; color: var(--text-secondary); background: var(--surface-input); border-bottom: 1px solid var(--border-hair);">▲</button>
|
||||
<button class="mono" style="width: 24px; height: 15px; font-size: 9px; color: var(--text-secondary); background: var(--surface-input);">▼</button>
|
||||
</div>
|
||||
<span class="micro self-center" style="color: var(--text-muted);">FR</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seconds -->
|
||||
<div>
|
||||
<label class="micro block mb-1">Min Seconds Between</label>
|
||||
<div class="hint mb-2">Cooldown gap between successive inference calls on the same video stream.</div>
|
||||
<div class="flex items-stretch gap-2">
|
||||
<input class="inp inp-mono" value="2" style="text-align: right; width: 88px;">
|
||||
<div class="flex flex-col" style="border: 1px solid var(--border-hair); border-radius: 2px;">
|
||||
<button class="mono" style="width: 24px; height: 15px; font-size: 9px; color: var(--text-secondary); background: var(--surface-input); border-bottom: 1px solid var(--border-hair);">▲</button>
|
||||
<button class="mono" style="width: 24px; height: 15px; font-size: 9px; color: var(--text-secondary); background: var(--surface-input);">▼</button>
|
||||
</div>
|
||||
<span class="micro self-center" style="color: var(--text-muted);">SEC</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confidence -->
|
||||
<div>
|
||||
<label class="micro block mb-1">Min Confidence</label>
|
||||
<div class="hint mb-2">Detections below this threshold are discarded before reaching the canvas.</div>
|
||||
<div class="flex items-stretch gap-2">
|
||||
<input class="inp inp-mono" value="25" style="text-align: right; width: 88px;">
|
||||
<div class="flex flex-col" style="border: 1px solid var(--border-hair); border-radius: 2px;">
|
||||
<button class="mono" style="width: 24px; height: 15px; font-size: 9px; color: var(--text-secondary); background: var(--surface-input); border-bottom: 1px solid var(--border-hair);">▲</button>
|
||||
<button class="mono" style="width: 24px; height: 15px; font-size: 9px; color: var(--text-secondary); background: var(--surface-input);">▼</button>
|
||||
</div>
|
||||
<span class="micro self-center" style="color: var(--text-muted);">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- footer / telemetry -->
|
||||
<div class="mt-5 pt-4 flex items-center justify-between" style="border-top: 1px dashed var(--border-hair);">
|
||||
<div class="flex items-center gap-5 micro">
|
||||
<span style="color: var(--text-muted);">LAST RUN <span class="mono tnum" style="color: var(--text-secondary);">11:43:09Z</span></span>
|
||||
<span style="color: var(--text-muted);">FRAMES <span class="mono tnum" style="color: var(--text-secondary);">14,228</span></span>
|
||||
<span style="color: var(--text-muted);">AVG CONF <span class="mono tnum" style="color: var(--accent-green);">71.4%</span></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-ghost">RESET</button>
|
||||
<button class="btn btn-primary">APPLY</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== GPS DEVICE SETTINGS ===== -->
|
||||
<div>
|
||||
<div class="flex items-end justify-between mb-3">
|
||||
<div>
|
||||
<div class="sect-head">GPS DEVICE LINK</div>
|
||||
<div class="hint mt-1">Ground-station receiver feeding the GPS-Denied correction pipeline.</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 micro">
|
||||
<span style="color: var(--text-muted);">SOCKET</span>
|
||||
<span class="mono tnum" style="color: var(--text-primary);">UDP/192.168.1.100:9001</span>
|
||||
<span class="pill pill-green"><span class="dot"></span>CONNECTED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bracket panel p-5">
|
||||
<span class="br"></span>
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-4">
|
||||
<!-- Address -->
|
||||
<div>
|
||||
<label class="micro block mb-1">Device Address</label>
|
||||
<div class="hint mb-2">IPv4 endpoint or hostname of the GPS receiver bridge.</div>
|
||||
<input class="inp inp-mono" value="192.168.1.100" placeholder="0.0.0.0">
|
||||
</div>
|
||||
|
||||
<!-- Port -->
|
||||
<div>
|
||||
<label class="micro block mb-1">Device Port</label>
|
||||
<div class="hint mb-2">UDP port the receiver streams NMEA sentences on.</div>
|
||||
<input class="inp inp-mono" value="9001" placeholder="9001" style="text-align: right;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Protocol — segmented -->
|
||||
<div class="mt-5">
|
||||
<label class="micro block mb-1">Protocol</label>
|
||||
<div class="hint mb-2">Wire format negotiated with the receiver. Switch only when the device is offline.</div>
|
||||
<div class="seg">
|
||||
<button class="seg-btn active">NMEA</button>
|
||||
<button class="seg-btn">UBX</button>
|
||||
<button class="seg-btn">MAVLINK</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- footer -->
|
||||
<div class="mt-5 pt-4 flex items-center justify-between" style="border-top: 1px dashed var(--border-hair);">
|
||||
<div class="flex items-center gap-5 micro">
|
||||
<span style="color: var(--text-muted);">FIX <span class="mono tnum" style="color: var(--accent-green);">3D · 11 SAT</span></span>
|
||||
<span style="color: var(--text-muted);">HDOP <span class="mono tnum" style="color: var(--text-secondary);">0.82</span></span>
|
||||
<span style="color: var(--text-muted);">LAST PKT <span class="mono tnum" style="color: var(--text-secondary);">+12ms</span></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-ghost">PING</button>
|
||||
<button class="btn btn-secondary">RECONNECT</button>
|
||||
<button class="btn btn-primary">APPLY</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ RIGHT PANEL: DEFAULT AIRCRAFTS (280px) ============ -->
|
||||
<aside class="shrink-0 flex flex-col" style="width: 280px; background: var(--surface-1); border-left: 1px solid var(--border-hair);">
|
||||
|
||||
<div class="px-4 pt-4 pb-3 flex items-center justify-between border-b" style="border-color: var(--border-hair);">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="sect-head">DEFAULT AIRCRAFTS</span>
|
||||
</div>
|
||||
<span class="mono tnum" style="font-size: 10px; color: var(--text-muted);">[06]</span>
|
||||
</div>
|
||||
|
||||
<!-- legend -->
|
||||
<div class="px-4 py-2.5 flex items-center gap-3 border-b micro" style="border-color: var(--border-hair); background: var(--surface-0);">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="type-sq" style="background: var(--accent-blue);">P</span>
|
||||
<span style="color: var(--text-muted);">PLANE</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="type-sq" style="background: var(--accent-green);">C</span>
|
||||
<span style="color: var(--text-muted);">COPTER</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="type-sq" style="background: var(--accent-amber);">F</span>
|
||||
<span style="color: var(--text-muted);">FIXED-W</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
|
||||
<!-- selected default -->
|
||||
<div class="row-hover flex items-center gap-3 px-4 py-2.5" style="border-bottom: 1px solid var(--border-hair); background: var(--surface-2); border-left: 2px solid var(--accent-amber);">
|
||||
<span class="type-sq" style="background: var(--accent-green);">C</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div style="font-size: 12.5px;">DJI Mavic 3</div>
|
||||
<div class="mono tnum" style="font-size: 10.5px; color: var(--text-muted);">AC-001 · 4K · 46MIN</div>
|
||||
</div>
|
||||
<button class="star" title="Default"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
|
||||
</div>
|
||||
|
||||
<div class="row-hover flex items-center gap-3 px-4 py-2.5" style="border-bottom: 1px solid var(--border-hair);">
|
||||
<span class="type-sq" style="background: var(--accent-green);">C</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div style="font-size: 12.5px;">Matrice 300 RTK</div>
|
||||
<div class="mono tnum" style="font-size: 10.5px; color: var(--text-muted);">AC-002 · 4K · 55MIN</div>
|
||||
</div>
|
||||
<button class="reveal ibtn" title="Set default"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
|
||||
<span class="star-off" style="display: var(--show-fb, inline-block);"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
|
||||
</div>
|
||||
|
||||
<div class="row-hover flex items-center gap-3 px-4 py-2.5" style="border-bottom: 1px solid var(--border-hair);">
|
||||
<span class="type-sq" style="background: var(--accent-amber);">F</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div style="font-size: 12.5px;">Leleka-100</div>
|
||||
<div class="mono tnum" style="font-size: 10.5px; color: var(--text-muted);">AC-003 · HD · 180MIN</div>
|
||||
</div>
|
||||
<button class="reveal ibtn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
|
||||
<span class="star-off"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
|
||||
</div>
|
||||
|
||||
<div class="row-hover flex items-center gap-3 px-4 py-2.5" style="border-bottom: 1px solid var(--border-hair);">
|
||||
<span class="type-sq" style="background: var(--accent-blue);">P</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div style="font-size: 12.5px;">Fixed Wing Scout</div>
|
||||
<div class="mono tnum" style="font-size: 10.5px; color: var(--text-muted);">AC-004 · 1080P · 95MIN</div>
|
||||
</div>
|
||||
<button class="reveal ibtn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
|
||||
<span class="star-off"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
|
||||
</div>
|
||||
|
||||
<div class="row-hover flex items-center gap-3 px-4 py-2.5" style="border-bottom: 1px solid var(--border-hair);">
|
||||
<span class="type-sq" style="background: var(--accent-green);">C</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div style="font-size: 12.5px;">Autel EVO II Pro</div>
|
||||
<div class="mono tnum" style="font-size: 10.5px; color: var(--text-muted);">AC-005 · 6K · 40MIN</div>
|
||||
</div>
|
||||
<button class="reveal ibtn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
|
||||
<span class="star-off"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
|
||||
</div>
|
||||
|
||||
<div class="row-hover flex items-center gap-3 px-4 py-2.5">
|
||||
<span class="type-sq" style="background: var(--accent-amber);">F</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div style="font-size: 12.5px;">PD-2 Recon</div>
|
||||
<div class="mono tnum" style="font-size: 10.5px; color: var(--text-muted);">AC-006 · HD · 600MIN</div>
|
||||
</div>
|
||||
<button class="reveal ibtn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
|
||||
<span class="star-off"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Add new -->
|
||||
<div class="px-4 py-3 border-t" style="border-color: var(--border-hair); background: var(--surface-0);">
|
||||
<button class="btn btn-secondary w-full justify-center">+ ADD AIRCRAFT</button>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,876 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AZAION // Annotations</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--surface-0: #0A0D10;
|
||||
--surface-1: #13171C;
|
||||
--surface-2: #1A1F26;
|
||||
--surface-input: #0A0D10;
|
||||
--border-hair: #252B34;
|
||||
--border-raised: #3B4451;
|
||||
--text-primary: #E8ECF1;
|
||||
--text-secondary: #9AA4B2;
|
||||
--text-muted: #5B6573;
|
||||
--accent-amber: #FF9D3D;
|
||||
--accent-cyan: #36D6C5;
|
||||
--accent-red: #FF4756;
|
||||
--accent-green: #3DDC84;
|
||||
--accent-blue: #4E9EFF;
|
||||
}
|
||||
html, body { background: var(--surface-0); color: var(--text-primary); }
|
||||
body { font-family: 'IBM Plex Sans', system-ui, sans-serif; font-size: 13px; line-height: 1.5; }
|
||||
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.micro {
|
||||
font: 500 10px/1.4 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.section-h {
|
||||
font: 600 11px/1.4 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
/* ── Corner brackets ──────────────────────────────────────── */
|
||||
.bracket { position: relative; }
|
||||
.bracket::before, .bracket::after,
|
||||
.bracket > .br::before, .bracket > .br::after {
|
||||
content: ''; position: absolute; width: 8px; height: 8px;
|
||||
border-color: var(--accent-amber); border-style: solid; border-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.bracket::before { top: -1px; left: -1px; border-top-width: 1px; border-left-width: 1px; }
|
||||
.bracket::after { top: -1px; right: -1px; border-top-width: 1px; border-right-width: 1px; }
|
||||
.bracket > .br::before { bottom: -1px; left: -1px; border-bottom-width: 1px; border-left-width: 1px; }
|
||||
.bracket > .br::after { bottom: -1px; right: -1px; border-bottom-width: 1px; border-right-width: 1px; }
|
||||
|
||||
.bracket-cyan::before, .bracket-cyan::after,
|
||||
.bracket-cyan > .br::before, .bracket-cyan > .br::after { border-color: var(--accent-cyan); }
|
||||
|
||||
/* ── Canvas grid backdrop ─────────────────────────────────── */
|
||||
.grid-bg {
|
||||
background-color: #0E1216;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
/* faux terrain wash so the canvas reads as imagery */
|
||||
.terrain {
|
||||
background-color: #11181B;
|
||||
background-image:
|
||||
radial-gradient(900px 500px at 30% 40%, rgba(48,72,60,0.45), transparent 60%),
|
||||
radial-gradient(700px 400px at 75% 65%, rgba(40,52,68,0.35), transparent 65%),
|
||||
radial-gradient(400px 300px at 60% 30%, rgba(82,64,40,0.18), transparent 70%),
|
||||
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||
background-size: auto, auto, auto, 48px 48px, 48px 48px;
|
||||
}
|
||||
|
||||
/* ── Buttons ──────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 28px; padding: 0 12px;
|
||||
font: 600 11px/1 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
border: 1px solid var(--border-hair); border-radius: 2px;
|
||||
color: var(--text-secondary); background: transparent;
|
||||
transition: background .12s, color .12s, border-color .12s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { background: var(--surface-2); color: var(--text-primary); }
|
||||
.btn-amber {
|
||||
background: var(--accent-amber); color: #0A0D10; border-color: var(--accent-amber);
|
||||
}
|
||||
.btn-amber:hover { filter: brightness(1.08); background: var(--accent-amber); color: #0A0D10; }
|
||||
.btn-ghost-amber { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||
.btn-ghost-amber:hover { background: rgba(255,157,61,0.12); color: var(--accent-amber); }
|
||||
.btn-danger { color: var(--accent-red); border-color: rgba(255,71,86,0.4); }
|
||||
.btn-danger:hover { background: rgba(255,71,86,0.12); color: var(--accent-red); border-color: var(--accent-red); }
|
||||
|
||||
.icobtn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px;
|
||||
border: 1px solid var(--border-hair); border-radius: 2px;
|
||||
background: var(--surface-1); color: var(--text-secondary);
|
||||
transition: background .12s, color .12s, border-color .12s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.icobtn:hover { background: var(--surface-2); color: var(--text-primary); border-color: var(--border-raised); }
|
||||
.icobtn.active { color: var(--accent-amber); border-color: var(--accent-amber); background: rgba(255,157,61,0.08); }
|
||||
|
||||
/* ── Inputs ───────────────────────────────────────────────── */
|
||||
.inp {
|
||||
height: 28px; padding: 0 10px;
|
||||
background: var(--surface-input); color: var(--text-primary);
|
||||
border: 1px solid var(--border-hair); border-radius: 2px;
|
||||
font: 12px 'IBM Plex Sans', sans-serif; outline: none;
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
.inp::placeholder { color: var(--text-muted); }
|
||||
.inp:focus { border-color: var(--accent-amber); box-shadow: 0 0 0 1px var(--accent-amber); }
|
||||
|
||||
/* ── Pills ────────────────────────────────────────────────── */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 20px; padding: 0 8px; border-radius: 2px;
|
||||
font: 600 10px/1 'JetBrains Mono', monospace; letter-spacing: 0.1em;
|
||||
text-transform: uppercase; border: 1px solid; background: transparent;
|
||||
}
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 999px; display: inline-block; }
|
||||
.pill-green { color: var(--accent-green); border-color: rgba(61,220,132,0.5); }
|
||||
.pill-green .dot { background: var(--accent-green); }
|
||||
.pill-cyan { color: var(--accent-cyan); border-color: rgba(54,214,197,0.5); }
|
||||
.pill-cyan .dot { background: var(--accent-cyan); }
|
||||
.pill-amber { color: var(--accent-amber); border-color: rgba(255,157,61,0.5); }
|
||||
.pill-amber .dot { background: var(--accent-amber); }
|
||||
.pill-red { color: var(--accent-red); border-color: rgba(255,71,86,0.5); }
|
||||
.pill-red .dot { background: var(--accent-red); }
|
||||
|
||||
.live-dot {
|
||||
width: 6px; height: 6px; border-radius: 999px;
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 0 rgba(54,214,197,0.5);
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%,100% { box-shadow: 0 0 0 0 rgba(54,214,197,0.5); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(54,214,197,0); }
|
||||
}
|
||||
|
||||
/* ── Media row ────────────────────────────────────────────── */
|
||||
.media-row {
|
||||
position: relative;
|
||||
display: grid; grid-template-columns: 44px 1fr auto; gap: 8px;
|
||||
align-items: center;
|
||||
height: 32px; padding: 0 12px 0 14px;
|
||||
border-bottom: 1px solid var(--border-hair);
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
.media-row:hover { background: var(--surface-2); }
|
||||
.media-row.active {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.media-row.active::before {
|
||||
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
|
||||
width: 2px; background: var(--accent-amber);
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 40px; height: 16px; border-radius: 2px;
|
||||
font: 600 9px/1 'JetBrains Mono', monospace; letter-spacing: 0.1em;
|
||||
border: 1px solid;
|
||||
}
|
||||
.chip-photo { color: var(--accent-cyan); border-color: rgba(54,214,197,0.45); background: rgba(54,214,197,0.06); }
|
||||
.chip-video { color: var(--accent-amber); border-color: rgba(255,157,61,0.45); background: rgba(255,157,61,0.06); }
|
||||
|
||||
/* ── Class row ────────────────────────────────────────────── */
|
||||
.class-row {
|
||||
display: grid; grid-template-columns: 16px 1fr auto; gap: 10px;
|
||||
align-items: center; height: 28px; padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-hair);
|
||||
cursor: pointer;
|
||||
}
|
||||
.class-row:hover { background: var(--surface-2); }
|
||||
.class-row.active { background: var(--surface-2); }
|
||||
.class-row.active .kbd { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||
.swatch { width: 12px; height: 12px; border-radius: 0; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.4); }
|
||||
.kbd {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 16px; padding: 0;
|
||||
font: 600 10px/1 'JetBrains Mono', monospace;
|
||||
color: var(--text-muted); border: 1px solid var(--border-hair); border-radius: 2px;
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
/* ── Segmented control (PhotoMode) ────────────────────────── */
|
||||
.seg {
|
||||
display: grid; grid-template-columns: 1fr 1fr 1fr;
|
||||
border: 1px solid var(--border-hair); border-radius: 2px;
|
||||
background: var(--surface-input); overflow: hidden;
|
||||
}
|
||||
.seg button {
|
||||
height: 28px;
|
||||
font: 600 10px/1 'JetBrains Mono', monospace; letter-spacing: 0.1em;
|
||||
text-transform: uppercase; color: var(--text-secondary);
|
||||
background: transparent; border-right: 1px solid var(--border-hair);
|
||||
cursor: pointer; transition: background .12s, color .12s;
|
||||
}
|
||||
.seg button:last-child { border-right: 0; }
|
||||
.seg button:hover { background: var(--surface-2); color: var(--text-primary); }
|
||||
.seg button.active { background: var(--accent-amber); color: #0A0D10; }
|
||||
|
||||
/* ── Annotation list row (gradient stripe) ────────────────── */
|
||||
.ann-row {
|
||||
position: relative;
|
||||
display: grid; grid-template-columns: 44px 1fr auto; gap: 8px;
|
||||
align-items: center;
|
||||
height: 36px; padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-hair);
|
||||
cursor: pointer;
|
||||
background-color: var(--surface-1);
|
||||
}
|
||||
.ann-row::after {
|
||||
content: ''; position: absolute; left: 0; right: 0; top: 0; bottom: 0;
|
||||
background-image: var(--row-grad, none);
|
||||
pointer-events: none;
|
||||
}
|
||||
.ann-row > * { position: relative; z-index: 1; }
|
||||
.ann-row:hover { background-color: var(--surface-2); }
|
||||
|
||||
/* ── Bounding box label chip ──────────────────────────────── */
|
||||
.bbox-label {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 22px; padding: 0 8px;
|
||||
font: 600 10px/1 'JetBrains Mono', monospace; letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 2px;
|
||||
background: rgba(10,13,16,0.92);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-hair);
|
||||
}
|
||||
.bbox-label .conf { color: var(--text-secondary); font-weight: 500; }
|
||||
|
||||
/* progress bar */
|
||||
.scrub {
|
||||
height: 4px; background: var(--surface-2); border: 1px solid var(--border-hair);
|
||||
border-radius: 2px; position: relative; cursor: pointer;
|
||||
}
|
||||
.scrub .fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent-amber); }
|
||||
.scrub .head {
|
||||
position: absolute; top: 50%; width: 2px; height: 10px; background: var(--accent-amber);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.scrub .head-knob {
|
||||
position: absolute; top: 50%; width: 12px; height: 12px;
|
||||
background: var(--accent-amber);
|
||||
border: 2px solid var(--surface-1);
|
||||
border-radius: 999px;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 0 0 1px var(--accent-amber), 0 0 8px rgba(255,157,61,0.45);
|
||||
z-index: 2;
|
||||
cursor: grab;
|
||||
}
|
||||
.scrub .tick {
|
||||
position: absolute; top: 50%; width: 1px; height: 6px; background: var(--text-muted);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.scrub .mark {
|
||||
position: absolute; top: -3px; width: 2px; height: 10px;
|
||||
}
|
||||
|
||||
/* volume */
|
||||
.vol {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
height: 2px; width: 72px; background: var(--border-hair); outline: none; border-radius: 2px;
|
||||
}
|
||||
.vol::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 10px; height: 10px; background: var(--accent-amber); border-radius: 0; cursor: pointer;
|
||||
}
|
||||
|
||||
/* Top header tabs */
|
||||
.tab {
|
||||
display: inline-flex; align-items: center;
|
||||
height: 48px; padding: 0 14px;
|
||||
font: 500 12px/1 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.10em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab:hover { color: var(--text-primary); }
|
||||
.tab.active { color: var(--text-primary); border-bottom-color: var(--accent-amber); font-weight: 500; }
|
||||
|
||||
/* Vertical hairline column separator */
|
||||
.vhair { width: 1px; background: var(--border-hair); }
|
||||
|
||||
/* Splitter affordance */
|
||||
.split {
|
||||
width: 4px; cursor: col-resize; background: transparent;
|
||||
position: relative;
|
||||
}
|
||||
.split::after {
|
||||
content: ''; position: absolute; left: 1px; top: 0; bottom: 0; width: 1px;
|
||||
background: var(--border-hair);
|
||||
}
|
||||
.split:hover::after { background: var(--accent-amber); }
|
||||
|
||||
/* AI banner */
|
||||
.ai-banner {
|
||||
backdrop-filter: blur(6px);
|
||||
background: rgba(10,13,16,0.78);
|
||||
border: 1px solid rgba(54,214,197,0.4);
|
||||
}
|
||||
|
||||
/* Crosshair on canvas */
|
||||
.crosshair {
|
||||
position: absolute; pointer-events: none;
|
||||
width: 100%; height: 100%; left: 0; top: 0;
|
||||
background:
|
||||
linear-gradient(rgba(255,157,61,0.10), rgba(255,157,61,0.10)) no-repeat,
|
||||
linear-gradient(rgba(255,157,61,0.10), rgba(255,157,61,0.10)) no-repeat;
|
||||
background-size: 100% 1px, 1px 100%;
|
||||
background-position: 0 62%, 47% 0;
|
||||
}
|
||||
|
||||
/* Selected handles */
|
||||
.handle {
|
||||
position: absolute; width: 6px; height: 6px;
|
||||
background: var(--accent-amber); border: 1px solid #0A0D10;
|
||||
}
|
||||
|
||||
/* Icon buttons in header */
|
||||
.ibtn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px;
|
||||
border: 1px solid var(--border-hair); border-radius: 2px;
|
||||
color: var(--text-secondary); background: transparent;
|
||||
transition: color .12s, border-color .12s, background-color .12s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ibtn:hover { color: var(--text-primary); border-color: var(--border-raised); background: var(--surface-2); }
|
||||
.ibtn.active { color: var(--accent-amber); border-color: var(--accent-amber); background: rgba(255,157,61,0.08); }
|
||||
.ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent-red); background: rgba(255,71,86,0.08); }
|
||||
|
||||
/* Scrollbars */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-hair); border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-raised); }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-screen overflow-hidden">
|
||||
|
||||
<!-- ───────────────────────────────────────────── GLOBAL HEADER -->
|
||||
<header class="h-12 flex items-center px-4 gap-3 border-b" style="border-color: var(--border-hair); background: var(--surface-1);">
|
||||
<span class="mono font-bold" style="color: var(--accent-amber); letter-spacing: 0.2em; font-size: 14px;">AZAION</span>
|
||||
|
||||
<span class="micro" style="color: var(--text-muted);">//</span>
|
||||
|
||||
<button class="inline-flex items-center gap-2 mono" style="height: 28px; padding: 0 10px; background: var(--surface-1); border: 1px solid var(--accent-amber); border-radius: 2px; font-size: 11px; letter-spacing: 0.10em;">
|
||||
<span class="live-dot"></span>
|
||||
<span style="color: var(--text-primary);">FL-03</span>
|
||||
<span style="color: var(--text-secondary); font-size: 10px;">▾</span>
|
||||
</button>
|
||||
|
||||
<nav class="flex items-center self-stretch ml-3">
|
||||
<a href="flights.html" class="tab">Flights</a>
|
||||
<a href="annotations.html" class="tab active">Annotations</a>
|
||||
<a href="dataset_explorer.html" class="tab">Dataset</a>
|
||||
<a href="admin.html" class="tab">Admin</a>
|
||||
</nav>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2" style="font: 500 10px/1.4 'JetBrains Mono', monospace; letter-spacing: 0.12em; text-transform: uppercase;">
|
||||
<span class="live-dot"></span>
|
||||
<span style="color: var(--accent-cyan);">LINK</span>
|
||||
<span style="color: var(--border-raised);">|</span>
|
||||
<span style="color: var(--text-secondary); text-transform: none; letter-spacing: 0;">user@azaion.com</span>
|
||||
<span style="color: var(--border-raised); margin: 0 4px;">|</span>
|
||||
<a href="settings.html" class="ibtn" title="Settings">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
</a>
|
||||
<a href="#" class="ibtn danger" title="Sign out">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ───────────────────────────────────────────── MAIN GRID -->
|
||||
<div class="flex" style="height: calc(100vh - 48px);">
|
||||
|
||||
<!-- ============ LEFT SIDEBAR ============ -->
|
||||
<aside class="flex flex-col shrink-0" style="width: 264px; background: var(--surface-1);">
|
||||
|
||||
<!-- Media list -->
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between px-3 h-9 border-b" style="border-color: var(--border-hair);">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="section-h">Media Files</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-muted);">24</span>
|
||||
</div>
|
||||
<button class="icobtn" style="width:22px;height:22px;" title="Upload">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-3 py-2 border-b" style="border-color: var(--border-hair);">
|
||||
<div class="relative">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2" style="color: var(--text-muted);">
|
||||
<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>
|
||||
</svg>
|
||||
<input class="inp w-full pl-7" placeholder="filter by name…" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<div class="media-row">
|
||||
<span class="chip chip-video">VIDEO</span>
|
||||
<span class="truncate" style="color: var(--text-primary);">recon_north_03.mp4</span>
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">04:12</span>
|
||||
</div>
|
||||
<div class="media-row active">
|
||||
<span class="chip chip-video">VIDEO</span>
|
||||
<span class="truncate" style="color: var(--text-primary); font-weight: 500;">strike_zone_07.mp4</span>
|
||||
<span class="mono text-[11px]" style="color: var(--accent-amber);">02:47</span>
|
||||
</div>
|
||||
<div class="media-row">
|
||||
<span class="chip chip-photo">PHOTO</span>
|
||||
<span class="truncate" style="color: var(--text-primary);">orthoframe_0142.jpg</span>
|
||||
<span class="mono text-[11px]" style="color: var(--text-muted);">—</span>
|
||||
</div>
|
||||
<div class="media-row">
|
||||
<span class="chip chip-photo">PHOTO</span>
|
||||
<span class="truncate" style="color: var(--text-primary);">orthoframe_0143.jpg</span>
|
||||
<span class="mono text-[11px]" style="color: var(--text-muted);">—</span>
|
||||
</div>
|
||||
<div class="media-row">
|
||||
<span class="chip chip-video">VIDEO</span>
|
||||
<span class="truncate" style="color: var(--text-primary);">patrol_sector_b.mp4</span>
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">11:08</span>
|
||||
</div>
|
||||
<div class="media-row">
|
||||
<span class="chip chip-photo">PHOTO</span>
|
||||
<span class="truncate" style="color: var(--text-primary);">orthoframe_0144.jpg</span>
|
||||
<span class="mono text-[11px]" style="color: var(--text-muted);">—</span>
|
||||
</div>
|
||||
<div class="media-row">
|
||||
<span class="chip chip-video">VIDEO</span>
|
||||
<span class="truncate" style="color: var(--text-primary);">night_ir_pass_02.mp4</span>
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">07:33</span>
|
||||
</div>
|
||||
<div class="media-row">
|
||||
<span class="chip chip-photo">PHOTO</span>
|
||||
<span class="truncate" style="color: var(--text-primary);">orthoframe_0145.jpg</span>
|
||||
<span class="mono text-[11px]" style="color: var(--text-muted);">—</span>
|
||||
</div>
|
||||
<div class="media-row">
|
||||
<span class="chip chip-video">VIDEO</span>
|
||||
<span class="truncate" style="color: var(--text-primary);">corridor_east_01.mp4</span>
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">03:51</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detection classes -->
|
||||
<div class="border-t" style="border-color: var(--border-hair);">
|
||||
<div class="flex items-center justify-between px-3 h-9 border-b" style="border-color: var(--border-hair);">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="section-h">Detection Classes</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-muted);">06</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[28px_1fr_auto] px-3 h-6 items-center border-b" style="border-color: var(--border-hair);">
|
||||
<span class="micro">#</span>
|
||||
<span class="micro">NAME</span>
|
||||
<span class="micro">KEY</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="class-row active">
|
||||
<span class="swatch" style="background:#FF0000"></span>
|
||||
<span style="color: var(--text-primary); font-weight: 500;">MilVeh</span>
|
||||
<span class="kbd">1</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#00FF00"></span>
|
||||
<span style="color: var(--text-primary);">Truck</span>
|
||||
<span class="kbd">2</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#0000FF"></span>
|
||||
<span style="color: var(--text-primary);">Vehicle</span>
|
||||
<span class="kbd">3</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#FFFF00"></span>
|
||||
<span style="color: var(--text-primary);">Artillery</span>
|
||||
<span class="kbd">4</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#FF00FF"></span>
|
||||
<span style="color: var(--text-primary);">Shadow</span>
|
||||
<span class="kbd">5</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#00FFFF"></span>
|
||||
<span style="color: var(--text-primary);">Trenches</span>
|
||||
<span class="kbd">6</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PhotoMode -->
|
||||
<div class="p-3 border-t" style="border-color: var(--border-hair);">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="micro">PhotoMode</span>
|
||||
</div>
|
||||
<div class="seg">
|
||||
<button class="active">Regular</button>
|
||||
<button>Winter</button>
|
||||
<button>Night</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="split"></div>
|
||||
|
||||
<!-- ============ MAIN VIEWER ============ -->
|
||||
<main class="flex-1 flex flex-col min-w-0" style="background: var(--surface-0);">
|
||||
|
||||
<!-- Toolbar above canvas -->
|
||||
<div class="h-9 flex items-center gap-3 px-4 border-b" style="border-color: var(--border-hair); background: var(--surface-1);">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="section-h">Canvas</span>
|
||||
<span class="mono text-[11px]" style="color: var(--text-muted);">strike_zone_07.mp4</span>
|
||||
<span class="mono text-[10px] px-1.5 py-0.5 border" style="color: var(--text-secondary); border-color: var(--border-hair);">1920×1080 · 30 FPS</span>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<span class="micro">ZOOM</span>
|
||||
<span class="mono text-[11px]" style="color: var(--text-primary);">142%</span>
|
||||
<span class="mx-2 h-4 w-px" style="background: var(--border-hair);"></span>
|
||||
<span class="micro">CURSOR</span>
|
||||
<span class="mono text-[11px]" style="color: var(--text-primary);">0.452, 0.318</span>
|
||||
<span class="mx-2 h-4 w-px" style="background: var(--border-hair);"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas -->
|
||||
<div class="flex-1 relative overflow-hidden">
|
||||
<div class="absolute inset-0 terrain"></div>
|
||||
|
||||
<!-- AI Detection banner -->
|
||||
<div class="absolute top-6 right-6 ai-banner rounded-[2px] px-3 py-2 w-72">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<span class="live-dot"></span>
|
||||
<span class="micro" style="color: var(--accent-cyan);">AI DETECTION IN PROGRESS</span>
|
||||
<span class="ml-auto mono text-[10px]" style="color: var(--text-muted);">3.2s</span>
|
||||
</div>
|
||||
<div class="mono text-[10px] space-y-0.5" style="color: var(--text-secondary);">
|
||||
<div><span style="color: var(--text-muted);">[14:22:41]</span> tile 04/16 → 2 candidates</div>
|
||||
<div><span style="color: var(--text-muted);">[14:22:42]</span> tile 05/16 → 1 candidate (conf 0.94)</div>
|
||||
<div><span style="color: var(--accent-cyan);">[14:22:43]</span> filtering by min_conf=0.25…</div>
|
||||
</div>
|
||||
<div class="mt-2 h-[2px] bg-black/40 overflow-hidden">
|
||||
<div style="height:100%; width: 38%; background: var(--accent-cyan);"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ───────── Bounding Box 1: Friendly + Ready (cyan) ───────── -->
|
||||
<div class="absolute" style="top: 28%; left: 18%; width: 22%; height: 28%;">
|
||||
<div class="absolute inset-0 border-2" style="border-color: var(--accent-cyan); background: rgba(54,214,197,0.05);"></div>
|
||||
<!-- corner brackets accent on the bbox -->
|
||||
<div class="absolute -top-px -left-px w-2 h-2 border-t-2 border-l-2" style="border-color: var(--accent-cyan);"></div>
|
||||
<div class="absolute -top-px -right-px w-2 h-2 border-t-2 border-r-2" style="border-color: var(--accent-cyan);"></div>
|
||||
<div class="absolute -bottom-px -left-px w-2 h-2 border-b-2 border-l-2" style="border-color: var(--accent-cyan);"></div>
|
||||
<div class="absolute -bottom-px -right-px w-2 h-2 border-b-2 border-r-2" style="border-color: var(--accent-cyan);"></div>
|
||||
<!-- selection handles -->
|
||||
<div class="handle" style="top: -3px; left: -3px;"></div>
|
||||
<div class="handle" style="top: -3px; left: calc(50% - 3px);"></div>
|
||||
<div class="handle" style="top: -3px; right: -3px;"></div>
|
||||
<div class="handle" style="top: calc(50% - 3px); left: -3px;"></div>
|
||||
<div class="handle" style="top: calc(50% - 3px); right: -3px;"></div>
|
||||
<div class="handle" style="bottom: -3px; left: -3px;"></div>
|
||||
<div class="handle" style="bottom: -3px; left: calc(50% - 3px);"></div>
|
||||
<div class="handle" style="bottom: -3px; right: -3px;"></div>
|
||||
|
||||
<!-- Label -->
|
||||
<div class="absolute" style="top: -26px; left: -2px;">
|
||||
<div class="bbox-label" style="border-color: rgba(54,214,197,0.6);">
|
||||
<!-- Friendly = rectangle (cyan) -->
|
||||
<svg width="11" height="9" viewBox="0 0 11 9">
|
||||
<rect x="0.5" y="0.5" width="10" height="8" fill="#87CEEB" stroke="#0A0D10" stroke-width="1"/>
|
||||
</svg>
|
||||
<!-- Ready = green dot -->
|
||||
<span style="width:6px;height:6px;border-radius:999px;background:var(--accent-green);display:inline-block;"></span>
|
||||
<span style="color: var(--accent-cyan);">VEHICLE</span>
|
||||
<span class="conf">94.2%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- corner coords -->
|
||||
<div class="absolute -bottom-4 right-0 mono text-[9px]" style="color: var(--text-muted);">0.40, 0.56</div>
|
||||
</div>
|
||||
|
||||
<!-- ───────── Bounding Box 2: Hostile + Ready (red) ───────── -->
|
||||
<div class="absolute" style="top: 44%; left: 56%; width: 18%; height: 24%;">
|
||||
<div class="absolute inset-0 border-2" style="border-color: var(--accent-red); background: rgba(255,71,86,0.06);"></div>
|
||||
<div class="absolute -top-px -left-px w-2 h-2 border-t-2 border-l-2" style="border-color: var(--accent-red);"></div>
|
||||
<div class="absolute -top-px -right-px w-2 h-2 border-t-2 border-r-2" style="border-color: var(--accent-red);"></div>
|
||||
<div class="absolute -bottom-px -left-px w-2 h-2 border-b-2 border-l-2" style="border-color: var(--accent-red);"></div>
|
||||
<div class="absolute -bottom-px -right-px w-2 h-2 border-b-2 border-r-2" style="border-color: var(--accent-red);"></div>
|
||||
|
||||
<div class="absolute" style="top: -26px; left: -2px;">
|
||||
<div class="bbox-label" style="border-color: rgba(255,71,86,0.6);">
|
||||
<!-- Hostile = diamond (red, rotated square) -->
|
||||
<svg width="11" height="11" viewBox="0 0 11 11">
|
||||
<polygon points="5.5,0.7 10.3,5.5 5.5,10.3 0.7,5.5" fill="#FF0000" stroke="#0A0D10" stroke-width="1"/>
|
||||
</svg>
|
||||
<span style="width:6px;height:6px;border-radius:999px;background:var(--accent-green);display:inline-block;"></span>
|
||||
<span style="color: var(--accent-red);">MILVEH</span>
|
||||
<span class="conf">88.6%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute -bottom-4 right-0 mono text-[9px]" style="color: var(--text-muted);">0.74, 0.68</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrubber + Controls -->
|
||||
<div class="border-t" style="border-color: var(--border-hair); background: var(--surface-1);">
|
||||
<!-- Scrubber -->
|
||||
<div class="px-4 pt-3 pb-2">
|
||||
<div class="scrub">
|
||||
<div class="fill" style="width: 35%;"></div>
|
||||
<!-- annotation marks -->
|
||||
<div class="mark" style="left: 8%; background: #FF0000;"></div>
|
||||
<div class="mark" style="left: 12%; background: #00FF00;"></div>
|
||||
<div class="mark" style="left: 18%; background: #0000FF;"></div>
|
||||
<div class="mark" style="left: 26%; background: #FFFF00;"></div>
|
||||
<div class="mark" style="left: 35%; background: var(--accent-amber);"></div>
|
||||
<div class="mark" style="left: 51%; background: #FF0000;"></div>
|
||||
<div class="mark" style="left: 60%; background: #FFFF00;"></div>
|
||||
<div class="mark" style="left: 73%; background: #00FFFF;"></div>
|
||||
<div class="mark" style="left: 84%; background: #FF0000;"></div>
|
||||
<div class="head" style="left: 35%;"></div>
|
||||
<div class="head-knob" style="left: 35%;"></div>
|
||||
<!-- tick marks -->
|
||||
<div class="tick" style="left: 0%;"></div>
|
||||
<div class="tick" style="left: 25%;"></div>
|
||||
<div class="tick" style="left: 50%;"></div>
|
||||
<div class="tick" style="left: 75%;"></div>
|
||||
<div class="tick" style="left: 100%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls row -->
|
||||
<div class="px-4 pb-3 flex items-center gap-3">
|
||||
<!-- Transport group -->
|
||||
<div class="flex items-center gap-1 p-1 border rounded-[2px]" style="border-color: var(--border-hair);">
|
||||
<button class="icobtn" title="Previous media" style="border: 0; background: transparent;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button class="icobtn" title="Back 5s" style="border: 0; background: transparent;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M11 18V6l-8.5 6zM22 18V6l-8.5 6z"/></svg>
|
||||
</button>
|
||||
<button class="icobtn active" title="Play" style="background: rgba(255,157,61,0.12);">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="icobtn" title="Forward 5s" style="border: 0; background: transparent;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M13 6v12l8.5-6zM2 6v12l8.5-6z"/></svg>
|
||||
</button>
|
||||
<button class="icobtn" title="Next media" style="border: 0; background: transparent;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2zM6 18l8.5-6L6 6z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="micro">FRAME STEP</span>
|
||||
<div class="flex items-center gap-1 p-1 border rounded-[2px]" style="border-color: var(--border-hair);">
|
||||
<button class="icobtn mono" style="width:30px; font-size:10px; border:0; background:transparent; letter-spacing:0;">1</button>
|
||||
<button class="icobtn mono" style="width:30px; font-size:10px; border:0; background:transparent; letter-spacing:0;">5</button>
|
||||
<button class="icobtn mono" style="width:30px; font-size:10px; border:0; background:transparent; letter-spacing:0;">10</button>
|
||||
<button class="icobtn mono" style="width:30px; font-size:10px; border:0; background:transparent; letter-spacing:0;">30</button>
|
||||
<button class="icobtn mono" style="width:30px; font-size:10px; border:0; background:transparent; letter-spacing:0;">60</button>
|
||||
</div>
|
||||
|
||||
<span class="mx-1 h-5 w-px" style="background: var(--border-hair);"></span>
|
||||
|
||||
<button class="btn btn-ghost-amber">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
|
||||
Save
|
||||
</button>
|
||||
<button class="btn btn-danger">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
<button class="btn btn-danger" title="Delete all on frame">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11l4 6M14 11l-4 6"/></svg>
|
||||
Delete All
|
||||
</button>
|
||||
|
||||
<span class="mx-1 h-5 w-px" style="background: var(--border-hair);"></span>
|
||||
|
||||
<button class="btn btn-amber">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7V3h4"/><path d="M17 3h4v4"/><path d="M21 17v4h-4"/><path d="M7 21H3v-4"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/></svg>
|
||||
AI Detect
|
||||
<span class="ml-1 mono opacity-70" style="font-size:9px;">[R]</span>
|
||||
</button>
|
||||
|
||||
<span class="mx-1 h-5 w-px" style="background: var(--border-hair);"></span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button class="icobtn" title="Mute">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z"/></svg>
|
||||
</button>
|
||||
<input type="range" class="vol" min="0" max="100" value="62" />
|
||||
<span class="mono text-[10px]" style="color: var(--text-muted); width: 24px;">62</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<div class="px-4 h-7 flex items-center border-t" style="border-color: var(--border-hair); background: var(--surface-0);">
|
||||
<span class="mono text-[11px]" style="color: var(--text-primary);">00:58.412</span>
|
||||
<span class="mono text-[11px] mx-1.5" style="color: var(--text-muted);">/</span>
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">02:47.000</span>
|
||||
<span class="mx-3 h-4 w-px" style="background: var(--border-hair);"></span>
|
||||
<span class="micro">FRAME</span>
|
||||
<span class="mono text-[11px] ml-1.5" style="color: var(--text-primary);">1284 / 5040</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="split"></div>
|
||||
|
||||
<!-- ============ RIGHT SIDEBAR — Annotations List ============ -->
|
||||
<aside class="flex flex-col shrink-0" style="width: 232px; background: var(--surface-1);">
|
||||
<div class="flex items-center justify-between px-3 h-9 border-b" style="border-color: var(--border-hair);">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="section-h">Annotations</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-muted);">14</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="icobtn" style="width:22px;height:22px;" title="Filter">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.5 10 19 14 21 14 12.5"/></svg>
|
||||
</button>
|
||||
<button class="icobtn" style="width:22px;height:22px;" title="Sort">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h13M3 12h9M3 18h5M17 8l4-4 4 4M21 4v16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[44px_1fr_auto] gap-2 px-3 h-6 items-center border-b" style="border-color: var(--border-hair);">
|
||||
<span class="micro">TIME</span>
|
||||
<span class="micro">CLASS</span>
|
||||
<span class="micro">CONF</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<!-- 00:12 — single class red 95% -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(255,0,0,0.55) 0%, rgba(255,0,0,0.10) 60%, transparent 100%);">
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">00:12</span>
|
||||
<span style="color: var(--text-primary); font-weight: 500;">MilVeh</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-secondary);">95%</span>
|
||||
</div>
|
||||
<!-- 00:18 — multi: green 88% + blue 71% -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(0,255,0,0.50) 0%, rgba(0,255,0,0.10) 48%, rgba(0,0,255,0.40) 52%, rgba(0,0,255,0.08) 100%);">
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">00:18</span>
|
||||
<span style="color: var(--text-primary);">Truck +1</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-secondary);">88%</span>
|
||||
</div>
|
||||
<!-- 00:24 — single blue 76% -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(0,0,255,0.40) 0%, rgba(0,0,255,0.08) 60%, transparent 100%);">
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">00:24</span>
|
||||
<span style="color: var(--text-primary);">Vehicle</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-secondary);">76%</span>
|
||||
</div>
|
||||
<!-- 00:31 — yellow 92% -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(255,255,0,0.50) 0%, rgba(255,255,0,0.10) 60%, transparent 100%);">
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">00:31</span>
|
||||
<span style="color: var(--text-primary);">Artillery</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-secondary);">92%</span>
|
||||
</div>
|
||||
<!-- 00:45 — multi: red 94 + yellow 81 + cyan 64 -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(255,0,0,0.52) 0%, rgba(255,0,0,0.10) 30%, rgba(255,255,0,0.42) 34%, rgba(255,255,0,0.08) 64%, rgba(0,255,255,0.30) 68%, rgba(0,255,255,0.05) 100%);">
|
||||
<span class="mono text-[11px]" style="color: var(--accent-amber);">00:45</span>
|
||||
<span style="color: var(--text-primary); font-weight: 600;">MilVeh +2</span>
|
||||
<span class="mono text-[10px]" style="color: var(--accent-amber);">94%</span>
|
||||
</div>
|
||||
<!-- 00:58 — current frame, selected look (cyan + red co-present) -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(54,214,197,0.55) 0%, rgba(54,214,197,0.10) 48%, rgba(255,71,86,0.50) 52%, rgba(255,71,86,0.10) 100%); background-color: var(--surface-2);">
|
||||
<span class="mono text-[11px]" style="color: var(--accent-amber); font-weight: 600;">00:58</span>
|
||||
<span style="color: var(--text-primary); font-weight: 600;">Vehicle, MilVeh</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-primary);">94%</span>
|
||||
</div>
|
||||
<!-- 01:09 — magenta 70% -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(255,0,255,0.40) 0%, rgba(255,0,255,0.08) 60%, transparent 100%);">
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">01:09</span>
|
||||
<span style="color: var(--text-primary);">Shadow</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-secondary);">70%</span>
|
||||
</div>
|
||||
<!-- 01:22 — cyan + green -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(0,255,255,0.45) 0%, rgba(0,255,255,0.10) 48%, rgba(0,255,0,0.40) 52%, rgba(0,255,0,0.08) 100%);">
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">01:22</span>
|
||||
<span style="color: var(--text-primary);">Trenches +1</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-secondary);">83%</span>
|
||||
</div>
|
||||
<!-- 01:38 — red 97% -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(255,0,0,0.58) 0%, rgba(255,0,0,0.12) 60%, transparent 100%);">
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">01:38</span>
|
||||
<span style="color: var(--text-primary);">MilVeh</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-secondary);">97%</span>
|
||||
</div>
|
||||
<!-- 01:51 — empty frame (no detections) -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(221,221,221,0.10), rgba(221,221,221,0.04));">
|
||||
<span class="mono text-[11px]" style="color: var(--text-muted);">01:51</span>
|
||||
<span style="color: var(--text-muted); font-style: italic;">empty frame</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-muted);">—</span>
|
||||
</div>
|
||||
<!-- 02:04 — green -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(0,255,0,0.45) 0%, rgba(0,255,0,0.10) 60%, transparent 100%);">
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">02:04</span>
|
||||
<span style="color: var(--text-primary);">Truck</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-secondary);">85%</span>
|
||||
</div>
|
||||
<!-- 02:19 — yellow + red -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(255,255,0,0.45) 0%, rgba(255,255,0,0.10) 48%, rgba(255,0,0,0.50) 52%, rgba(255,0,0,0.10) 100%);">
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">02:19</span>
|
||||
<span style="color: var(--text-primary);">Artillery +1</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-secondary);">79%</span>
|
||||
</div>
|
||||
<!-- 02:33 — blue 68% -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(0,0,255,0.35) 0%, rgba(0,0,255,0.06) 60%, transparent 100%);">
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">02:33</span>
|
||||
<span style="color: var(--text-primary);">Vehicle</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-secondary);">68%</span>
|
||||
</div>
|
||||
<!-- 02:41 — red 91% -->
|
||||
<div class="ann-row" style="--row-grad: linear-gradient(90deg, rgba(255,0,0,0.52) 0%, rgba(255,0,0,0.10) 60%, transparent 100%);">
|
||||
<span class="mono text-[11px]" style="color: var(--text-secondary);">02:41</span>
|
||||
<span style="color: var(--text-primary);">MilVeh</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-secondary);">91%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer summary -->
|
||||
<div class="border-t px-3 py-2.5" style="border-color: var(--border-hair); background: var(--surface-0);">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="micro">SUMMARY</span>
|
||||
<span class="mono text-[10px]" style="color: var(--text-muted);">14 ann · 3 empty</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 h-2">
|
||||
<span style="flex: 5; background: #FF0000; height: 100%;"></span>
|
||||
<span style="flex: 3; background: #00FF00; height: 100%;"></span>
|
||||
<span style="flex: 2; background: #0000FF; height: 100%;"></span>
|
||||
<span style="flex: 2; background: #FFFF00; height: 100%;"></span>
|
||||
<span style="flex: 1; background: #FF00FF; height: 100%;"></span>
|
||||
<span style="flex: 1; background: #00FFFF; height: 100%;"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-2 mono text-[10px]" style="color: var(--text-muted);">
|
||||
<span><span style="color:#FF0000;">■</span> 5</span>
|
||||
<span><span style="color:#00FF00;">■</span> 3</span>
|
||||
<span><span style="color:#0000FF;">■</span> 2</span>
|
||||
<span><span style="color:#FFFF00;">■</span> 2</span>
|
||||
<span><span style="color:#FF00FF;">■</span> 1</span>
|
||||
<span><span style="color:#00FFFF;">■</span> 1</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,876 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Azaion // Dataset Explorer</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
s0: '#0A0D10',
|
||||
s1: '#13171C',
|
||||
s2: '#1A1F26',
|
||||
sin: '#0A0D10',
|
||||
bh: '#252B34',
|
||||
br2: '#3B4451',
|
||||
tp: '#E8ECF1',
|
||||
ts: '#9AA4B2',
|
||||
tm: '#5B6573',
|
||||
amber: '#FF9D3D',
|
||||
cyan: '#36D6C5',
|
||||
red: '#FF4756',
|
||||
green: '#3DDC84',
|
||||
blue: '#4E9EFF',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"IBM Plex Sans"', 'system-ui', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono"', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--surface-0: #0A0D10;
|
||||
--surface-1: #13171C;
|
||||
--surface-2: #1A1F26;
|
||||
--surface-input: #0A0D10;
|
||||
--border-hair: #252B34;
|
||||
--border-raised: #3B4451;
|
||||
--text-primary: #E8ECF1;
|
||||
--text-secondary: #9AA4B2;
|
||||
--text-muted: #5B6573;
|
||||
--accent-amber: #FF9D3D;
|
||||
--accent-cyan: #36D6C5;
|
||||
--accent-red: #FF4756;
|
||||
--accent-green: #3DDC84;
|
||||
--accent-blue: #4E9EFF;
|
||||
}
|
||||
html, body { background: var(--surface-0); color: var(--text-primary); }
|
||||
body { font: 13px/1.5 'IBM Plex Sans', system-ui, sans-serif; }
|
||||
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
.micro {
|
||||
font: 10px/1.4 'JetBrains Mono', ui-monospace, monospace;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.sec-heading {
|
||||
font: 600 11px/1.2 'JetBrains Mono', ui-monospace, monospace;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
/* corner brackets */
|
||||
.bracket { position: relative; }
|
||||
.bracket::before, .bracket::after,
|
||||
.bracket > .br::before, .bracket > .br::after {
|
||||
content: ''; position: absolute; width: 8px; height: 8px;
|
||||
border-color: var(--accent-amber); border-style: solid; border-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.bracket::before { top: -1px; left: -1px; border-top-width: 1px; border-left-width: 1px; }
|
||||
.bracket::after { top: -1px; right: -1px; border-top-width: 1px; border-right-width: 1px; }
|
||||
.bracket > .br::before { content:''; position:absolute; bottom: -1px; left: -1px; width:8px; height:8px; border-bottom: 1px solid var(--accent-amber); border-left: 1px solid var(--accent-amber); }
|
||||
.bracket > .br::after { content:''; position:absolute; bottom: -1px; right: -1px; width:8px; height:8px; border-bottom: 1px solid var(--accent-amber); border-right:1px solid var(--accent-amber); }
|
||||
|
||||
/* base panel */
|
||||
.panel {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border-hair);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* inputs */
|
||||
.inp {
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-hair);
|
||||
border-radius: 2px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
color: var(--text-primary);
|
||||
font: 12px 'IBM Plex Sans', system-ui, sans-serif;
|
||||
outline: none;
|
||||
}
|
||||
.inp:focus { border-color: var(--accent-amber); box-shadow: 0 0 0 1px var(--accent-amber); }
|
||||
.inp::placeholder { color: var(--text-muted); }
|
||||
.inp-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; letter-spacing: 0.04em; }
|
||||
|
||||
/* buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 2px;
|
||||
font: 600 11px 'JetBrains Mono', ui-monospace, monospace;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary { background: var(--accent-amber); color: #0A0D10; border-color: var(--accent-amber); }
|
||||
.btn-primary:hover { filter: brightness(1.08); }
|
||||
.btn-ghost { background: transparent; color: var(--text-secondary); border-color: var(--border-hair); }
|
||||
.btn-ghost:hover { color: var(--text-primary); border-color: var(--border-raised); }
|
||||
.btn-secondary { background: transparent; color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||
.btn-secondary:hover { background: rgba(255,157,61,0.12); }
|
||||
|
||||
/* status pill */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 2px;
|
||||
font: 600 10px 'JetBrains Mono', ui-monospace, monospace;
|
||||
letter-spacing: 0.10em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-hair);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; flex: 0 0 6px; }
|
||||
.pill-green { color: var(--accent-green); border-color: var(--accent-green); }
|
||||
.pill-amber { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||
.pill-blue { color: var(--accent-blue); border-color: var(--accent-blue); }
|
||||
.pill-red { color: var(--accent-red); border-color: var(--accent-red); }
|
||||
.pill-none { color: var(--text-muted); border-color: var(--border-raised); }
|
||||
.pill-cyan { color: var(--accent-cyan); border-color: var(--accent-cyan); }
|
||||
|
||||
/* status chips (filter) */
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 0 10px; height: 24px;
|
||||
border-radius: 2px;
|
||||
font: 600 10px/1 'JetBrains Mono', ui-monospace, monospace;
|
||||
letter-spacing: 0.10em;
|
||||
text-transform: uppercase;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-hair);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip .dot { width: 6px; height: 6px; border-radius: 999px; flex: 0 0 6px; }
|
||||
.chip:hover { color: var(--text-primary); border-color: var(--border-raised); }
|
||||
.chip-active-green { color: var(--accent-green); border-color: var(--accent-green); background: rgba(61,220,132,0.12); }
|
||||
.chip-active-amber { color: var(--accent-amber); border-color: var(--accent-amber); background: rgba(255,157,61,0.12); }
|
||||
.chip-active-blue { color: var(--accent-blue); border-color: var(--accent-blue); background: rgba(78,158,255,0.12); }
|
||||
.chip-active-muted { color: var(--text-primary); border-color: var(--border-raised); background: rgba(91,101,115,0.18); }
|
||||
|
||||
/* Toggle switch — square knob, 2px radius */
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 30px; height: 16px;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-hair);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
flex: 0 0 30px;
|
||||
transition: background-color 120ms, border-color 120ms;
|
||||
}
|
||||
.switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px; left: 1px;
|
||||
width: 12px; height: 12px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 2px;
|
||||
transition: transform 120ms, background-color 120ms;
|
||||
}
|
||||
.switch.on { background: rgba(255,157,61,0.22); border-color: var(--accent-amber); }
|
||||
.switch.on::after { transform: translateX(14px); background: var(--accent-amber); }
|
||||
|
||||
/* Detection class row */
|
||||
.class-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
height: 28px; padding: 0 8px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.class-row:hover { background: var(--surface-2); color: var(--text-primary); }
|
||||
.class-row.active { background: var(--surface-2); color: var(--text-primary); }
|
||||
.class-row.active .count { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||
.swatch { width: 12px; height: 12px; flex: 0 0 12px; border: 1px solid rgba(255,255,255,0.10); }
|
||||
.count {
|
||||
margin-left: auto;
|
||||
padding: 2px 6px;
|
||||
font: 500 10px 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-hair);
|
||||
border-radius: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Tab strip */
|
||||
.tab {
|
||||
display: inline-flex; align-items: center;
|
||||
height: 48px; padding: 0 14px;
|
||||
font: 500 12px/1 'JetBrains Mono', ui-monospace, monospace;
|
||||
letter-spacing: 0.10em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab:hover { color: var(--text-primary); }
|
||||
.tab.active { color: var(--text-primary); border-bottom-color: var(--accent-amber); font-weight: 500; }
|
||||
.tab .badge {
|
||||
font: 500 10px 'JetBrains Mono', ui-monospace, monospace;
|
||||
color: var(--text-muted);
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border-hair);
|
||||
border-radius: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
.tab.active .badge { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||
|
||||
/* Thumbnail tile */
|
||||
.tile {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border-hair);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 100ms;
|
||||
}
|
||||
.tile:hover { border-color: var(--accent-amber); }
|
||||
.tile.seed { border-color: var(--accent-red); }
|
||||
.tile.selected { border: 2px solid var(--accent-amber); }
|
||||
.tile .img {
|
||||
position: absolute; inset: 0;
|
||||
background-size: cover; background-position: center;
|
||||
}
|
||||
.tile .scrim {
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(0,0,0,0.0) 55%, rgba(0,0,0,0.55) 100%);
|
||||
background-size: 24px 24px, 24px 24px, 100% 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tile .pill { padding: 2px 6px; font-size: 9px; letter-spacing: 0.08em; }
|
||||
.tile .corner-tag {
|
||||
position: absolute; top: 6px; right: 6px;
|
||||
font: 500 9px 'JetBrains Mono', ui-monospace, monospace;
|
||||
color: var(--text-primary);
|
||||
background: rgba(10,13,16,0.65);
|
||||
border: 1px solid var(--border-hair);
|
||||
padding: 1px 5px;
|
||||
letter-spacing: 0.06em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.tile .check {
|
||||
position: absolute; top: 4px; left: 4px;
|
||||
width: 14px; height: 14px;
|
||||
background: var(--accent-amber);
|
||||
color: #0A0D10;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.tile .bbox {
|
||||
position: absolute;
|
||||
border: 1px solid;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
/* live dot animation */
|
||||
@keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:0.35 } }
|
||||
.live { animation: pulse 1.6s ease-in-out infinite; }
|
||||
|
||||
/* Icon buttons in header */
|
||||
.ibtn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px;
|
||||
border: 1px solid var(--border-hair); border-radius: 2px;
|
||||
color: var(--text-secondary); background: transparent;
|
||||
transition: color .12s, border-color .12s, background-color .12s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ibtn:hover { color: var(--text-primary); border-color: var(--border-raised); background: var(--surface-2); }
|
||||
.ibtn.active { color: var(--accent-amber); border-color: var(--accent-amber); background: rgba(255,157,61,0.08); }
|
||||
.ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent-red); background: rgba(255,71,86,0.08); }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-hair); border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-raised); }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* divider */
|
||||
.vdiv { width: 1px; height: 20px; background: var(--border-hair); }
|
||||
|
||||
/* tile scene gradients (varied) */
|
||||
.scene-forest-1 { background: radial-gradient(120% 80% at 30% 20%, #2f4636 0%, #1c2a22 55%, #0e1612 100%); }
|
||||
.scene-forest-2 { background: linear-gradient(160deg, #324a3a 0%, #1b2820 60%, #0e1612 100%); }
|
||||
.scene-urban-1 { background: linear-gradient(155deg, #3a4150 0%, #232a36 55%, #14181f 100%); }
|
||||
.scene-urban-2 { background: radial-gradient(120% 90% at 70% 30%, #4a5568 0%, #2a313d 60%, #14181f 100%); }
|
||||
.scene-desert-1 { background: linear-gradient(165deg, #6a513a 0%, #44332a 55%, #1f1813 100%); }
|
||||
.scene-desert-2 { background: radial-gradient(110% 85% at 20% 70%, #7a5a3e 0%, #4a3522 60%, #20160d 100%); }
|
||||
.scene-dusk-1 { background: linear-gradient(180deg, #2a1d2d 0%, #3b2a35 30%, #1d2230 70%, #0d1118 100%); }
|
||||
.scene-dusk-2 { background: linear-gradient(180deg, #1a2438 0%, #2d2236 45%, #1a1820 100%); }
|
||||
.scene-field-1 { background: linear-gradient(160deg, #4a5232 0%, #2e3520 60%, #15170d 100%); }
|
||||
.scene-field-2 { background: radial-gradient(120% 80% at 60% 40%, #5a5a30 0%, #353720 55%, #15170d 100%); }
|
||||
.scene-coast-1 { background: linear-gradient(170deg, #2d4a52 0%, #1e3036 60%, #0c1416 100%); }
|
||||
.scene-night-1 { background: radial-gradient(140% 100% at 50% 30%, #1c2740 0%, #10182a 60%, #06080f 100%); }
|
||||
.scene-snow-1 { background: linear-gradient(180deg, #4a5560 0%, #2c333c 55%, #161a20 100%); }
|
||||
.scene-rural-1 { background: linear-gradient(160deg, #3d4a35 0%, #2a3328 50%, #141a14 100%); }
|
||||
|
||||
/* faint terrain dot pattern overlay */
|
||||
.terrain::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background-image:
|
||||
radial-gradient(rgba(255,255,255,0.05) 1px, transparent 1px),
|
||||
radial-gradient(rgba(0,0,0,0.18) 1px, transparent 1px);
|
||||
background-size: 7px 7px, 9px 9px;
|
||||
background-position: 0 0, 3px 4px;
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-screen flex flex-col overflow-hidden">
|
||||
|
||||
<!-- ============ HEADER ============ -->
|
||||
<header class="flex items-center h-12 px-4 gap-3 border-b border-[color:var(--border-hair)] bg-[color:var(--surface-1)] shrink-0">
|
||||
<span class="mono font-bold" style="color: var(--accent-amber); letter-spacing: 0.2em; font-size: 14px;">AZAION</span>
|
||||
|
||||
<span class="micro" style="color: var(--text-muted);">//</span>
|
||||
|
||||
<button class="inline-flex items-center gap-2 mono" style="height: 28px; padding: 0 10px; background: var(--surface-1); border: 1px solid var(--accent-amber); border-radius: 2px; font-size: 11px; letter-spacing: 0.10em;">
|
||||
<span class="w-1.5 h-1.5 rounded-full live" style="background: var(--accent-cyan);"></span>
|
||||
<span style="color: var(--text-primary);">FL-03</span>
|
||||
<span style="color: var(--text-secondary); font-size: 10px;">▾</span>
|
||||
</button>
|
||||
|
||||
<nav class="flex items-center self-stretch ml-3">
|
||||
<a href="flights.html" class="tab">Flights</a>
|
||||
<a href="annotations.html" class="tab">Annotations</a>
|
||||
<a href="dataset_explorer.html" class="tab active">Dataset</a>
|
||||
<a href="admin.html" class="tab">Admin</a>
|
||||
</nav>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2" style="font: 500 10px/1.4 'JetBrains Mono', monospace; letter-spacing: 0.12em; text-transform: uppercase;">
|
||||
<span class="w-1.5 h-1.5 rounded-full live" style="background: var(--accent-cyan);"></span>
|
||||
<span style="color: var(--accent-cyan);">LINK</span>
|
||||
<span style="color: var(--border-raised);">|</span>
|
||||
<span style="color: var(--text-secondary); text-transform: none; letter-spacing: 0;">user@azaion.com</span>
|
||||
<span style="color: var(--border-raised); margin: 0 4px;">|</span>
|
||||
<a href="settings.html" class="ibtn" title="Settings">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
</a>
|
||||
<a href="#" class="ibtn danger" title="Sign out">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ============ MAIN ============ -->
|
||||
<div class="flex-1 flex overflow-hidden p-3 gap-3">
|
||||
|
||||
<!-- ============ LEFT PANEL ============ -->
|
||||
<aside class="bracket panel flex flex-col" style="width:250px; flex-shrink:0;">
|
||||
<span class="br"></span>
|
||||
|
||||
<!-- Detection Classes -->
|
||||
<div class="px-3 pt-3 pb-2 flex items-center justify-between border-b border-[color:var(--border-hair)]">
|
||||
<span class="sec-heading">Detection Classes</span>
|
||||
<span class="mono text-[10px] text-tm">16</span>
|
||||
</div>
|
||||
|
||||
<div class="px-2 py-2 flex flex-col gap-0.5 overflow-y-auto" style="max-height: 46vh;">
|
||||
<div class="class-row active">
|
||||
<span class="swatch" style="background:#FF0000"></span>
|
||||
<span class="text-[12px]">ArmorVehicle</span>
|
||||
<span class="count num">124</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#00B341"></span>
|
||||
<span class="text-[12px]">Truck</span>
|
||||
<span class="count num">86</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#0044FF"></span>
|
||||
<span class="text-[12px]">Vehicle</span>
|
||||
<span class="count num">312</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#FFFF00"></span>
|
||||
<span class="text-[12px]">Artillery</span>
|
||||
<span class="count num">47</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#FF00FF"></span>
|
||||
<span class="text-[12px]">Shadow</span>
|
||||
<span class="count num">203</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#00FFFF"></span>
|
||||
<span class="text-[12px]">Trenches</span>
|
||||
<span class="count num">59</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#FF6B00"></span>
|
||||
<span class="text-[12px]">ActiveMine</span>
|
||||
<span class="count num">12</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#9D4EFF"></span>
|
||||
<span class="text-[12px]">AAGun</span>
|
||||
<span class="count num">8</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#FFFFFF"></span>
|
||||
<span class="text-[12px]">Bunker</span>
|
||||
<span class="count num">21</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#7AB800"></span>
|
||||
<span class="text-[12px]">Infantry</span>
|
||||
<span class="count num">73</span>
|
||||
</div>
|
||||
<div class="class-row">
|
||||
<span class="swatch" style="background:#FF1493"></span>
|
||||
<span class="text-[12px]">UAV</span>
|
||||
<span class="count num">5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-auto border-t border-[color:var(--border-hair)] px-3 py-3 flex flex-col gap-3">
|
||||
<div class="micro">Filters</div>
|
||||
|
||||
<!-- Toggle row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[12px] text-tp">Show with objects only</span>
|
||||
<span class="text-[10px] text-tm">Hide empty frames</span>
|
||||
</div>
|
||||
<div class="switch on" role="switch" aria-checked="true"></div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" style="color:var(--text-muted)">
|
||||
<circle cx="11" cy="11" r="7"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input class="inp w-full" style="padding-left:28px" placeholder="Search annotation name…" />
|
||||
</div>
|
||||
|
||||
<!-- Quick stats -->
|
||||
<div class="grid grid-cols-2 gap-2 pt-1">
|
||||
<div class="border border-[color:var(--border-hair)] rounded-[2px] p-2">
|
||||
<div class="micro" style="color:var(--text-muted)">Total</div>
|
||||
<div class="mono text-[15px] text-tp">1,047</div>
|
||||
</div>
|
||||
<div class="border border-[color:var(--border-hair)] rounded-[2px] p-2">
|
||||
<div class="micro" style="color:var(--text-muted)">Validated</div>
|
||||
<div class="mono text-[15px] text-green">612</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ============ MAIN AREA ============ -->
|
||||
<main class="flex-1 flex flex-col min-w-0 gap-3">
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="bracket panel relative flex items-center gap-3 px-3" style="height:48px;">
|
||||
<span class="br"></span>
|
||||
|
||||
<!-- Date Range -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="micro">Range</span>
|
||||
<input class="inp inp-mono" style="width:104px" value="2025-02-09" />
|
||||
<span class="mono text-tm">—</span>
|
||||
<input class="inp inp-mono" style="width:104px" value="2025-02-11" />
|
||||
</div>
|
||||
|
||||
<span class="vdiv"></span>
|
||||
|
||||
<!-- Flight -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="micro">Flight</span>
|
||||
<button class="inp flex items-center gap-2" style="padding:0 10px; height:28px;">
|
||||
<span class="w-1.5 h-1.5 rounded-full" style="background:var(--accent-amber)"></span>
|
||||
<span class="mono text-[12px] text-tp tracking-wider">FL-03</span>
|
||||
<span class="text-[10px] text-tm ml-1">▾</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="vdiv"></span>
|
||||
|
||||
<!-- Status chips -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="micro mr-1">Status</span>
|
||||
<button class="chip">
|
||||
<span class="dot" style="background:var(--text-muted)"></span>None
|
||||
</button>
|
||||
<button class="chip chip-active-amber">
|
||||
<span class="dot" style="background:var(--accent-amber)"></span>Created
|
||||
</button>
|
||||
<button class="chip chip-active-blue">
|
||||
<span class="dot" style="background:var(--accent-blue)"></span>Edited
|
||||
</button>
|
||||
<button class="chip chip-active-green">
|
||||
<span class="dot" style="background:var(--accent-green)"></span>Validated
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<span class="micro" style="color:var(--text-muted)">Showing</span>
|
||||
<span class="mono text-[12px] text-tp">214<span class="text-tm"> / 1047</span></span>
|
||||
<span class="vdiv"></span>
|
||||
<button class="w-7 h-7 flex items-center justify-center border border-[color:var(--border-hair)] rounded-[2px] text-ts hover:text-tp hover:border-[color:var(--border-raised)]" title="Sort">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3 6h18M6 12h12M10 18h4"/></svg>
|
||||
</button>
|
||||
<button class="w-7 h-7 flex items-center justify-center border border-[color:var(--border-hair)] rounded-[2px] text-ts hover:text-tp hover:border-[color:var(--border-raised)]" title="Grid density">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab strip + grid panel -->
|
||||
<div class="bracket panel relative flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<span class="br"></span>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex items-center px-2 border-b border-[color:var(--border-hair)] shrink-0">
|
||||
<div class="tab active">
|
||||
<span>Annotations</span>
|
||||
<span class="badge num">214</span>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<span>Editor</span>
|
||||
<span class="badge">—</span>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<span>Class Distribution</span>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2 px-2 micro" style="color:var(--text-muted)">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-cyan live"></span>
|
||||
<span>Live sync</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));">
|
||||
|
||||
<!-- Tile 1 - Validated, forest, selected -->
|
||||
<div class="tile selected">
|
||||
<div class="img scene-forest-1 terrain"></div>
|
||||
<div class="bbox" style="top:38%; left:30%; width:24%; height:18%; border-color:#FF0000;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="check"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><polyline points="20 6 9 17 4 12"/></svg></div>
|
||||
<div class="corner-tag mono">12 MAY · RD</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-green"><span class="dot"></span>VALIDATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 2 - Created, urban -->
|
||||
<div class="tile">
|
||||
<div class="img scene-urban-1 terrain"></div>
|
||||
<div class="bbox" style="top:48%; left:42%; width:18%; height:14%; border-color:#0044FF;"></div>
|
||||
<div class="bbox" style="top:30%; left:18%; width:12%; height:10%; border-color:#FF00FF;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">12 MAY · AB</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-amber"><span class="dot"></span>CREATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 3 - Validated, desert -->
|
||||
<div class="tile">
|
||||
<div class="img scene-desert-1 terrain"></div>
|
||||
<div class="bbox" style="top:55%; left:35%; width:30%; height:20%; border-color:#FF0000;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">11 MAY · RD</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-green"><span class="dot"></span>VALIDATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 4 - Edited, forest 2 -->
|
||||
<div class="tile">
|
||||
<div class="img scene-forest-2 terrain"></div>
|
||||
<div class="bbox" style="top:42%; left:50%; width:20%; height:16%; border-color:#00B341;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">11 MAY · MK</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-blue"><span class="dot"></span>EDITED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 5 - None, urban 2 -->
|
||||
<div class="tile">
|
||||
<div class="img scene-urban-2 terrain"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">11 MAY · RD</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-none"><span class="dot"></span>NONE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 6 - Validated, field -->
|
||||
<div class="tile">
|
||||
<div class="img scene-field-1 terrain"></div>
|
||||
<div class="bbox" style="top:36%; left:24%; width:22%; height:18%; border-color:#FF0000;"></div>
|
||||
<div class="bbox" style="top:60%; left:58%; width:14%; height:10%; border-color:#FFFF00;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">11 MAY · OK</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-green"><span class="dot"></span>VALIDATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 7 - Created, desert 2, SEED -->
|
||||
<div class="tile seed">
|
||||
<div class="img scene-desert-2 terrain"></div>
|
||||
<div class="bbox" style="top:44%; left:36%; width:28%; height:22%; border-color:#FF6B00;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">10 MAY · RD</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-amber"><span class="dot"></span>CREATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 8 - Validated, forest, selected -->
|
||||
<div class="tile selected">
|
||||
<div class="img scene-forest-1 terrain"></div>
|
||||
<div class="bbox" style="top:30%; left:28%; width:18%; height:16%; border-color:#FF0000;"></div>
|
||||
<div class="bbox" style="top:56%; left:52%; width:20%; height:14%; border-color:#0044FF;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="check"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><polyline points="20 6 9 17 4 12"/></svg></div>
|
||||
<div class="corner-tag mono">10 MAY · RD</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-green"><span class="dot"></span>VALIDATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 9 - Edited, dusk -->
|
||||
<div class="tile">
|
||||
<div class="img scene-dusk-1 terrain"></div>
|
||||
<div class="bbox" style="top:48%; left:40%; width:24%; height:16%; border-color:#00B341;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">10 MAY · MK</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-blue"><span class="dot"></span>EDITED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 10 - None, urban 1 -->
|
||||
<div class="tile">
|
||||
<div class="img scene-urban-1 terrain"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">10 MAY · AB</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-none"><span class="dot"></span>NONE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 11 - Validated, forest 2 -->
|
||||
<div class="tile">
|
||||
<div class="img scene-forest-2 terrain"></div>
|
||||
<div class="bbox" style="top:38%; left:32%; width:26%; height:20%; border-color:#FF0000;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">10 MAY · RD</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-green"><span class="dot"></span>VALIDATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 12 - Created, desert -->
|
||||
<div class="tile">
|
||||
<div class="img scene-desert-1 terrain"></div>
|
||||
<div class="bbox" style="top:50%; left:46%; width:18%; height:14%; border-color:#FFFF00;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">10 MAY · OK</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-amber"><span class="dot"></span>CREATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 13 - Validated, urban 2 -->
|
||||
<div class="tile">
|
||||
<div class="img scene-urban-2 terrain"></div>
|
||||
<div class="bbox" style="top:32%; left:22%; width:18%; height:14%; border-color:#0044FF;"></div>
|
||||
<div class="bbox" style="top:58%; left:56%; width:24%; height:18%; border-color:#FF00FF;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">09 MAY · RD</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-green"><span class="dot"></span>VALIDATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 14 - Edited, dusk 2 -->
|
||||
<div class="tile">
|
||||
<div class="img scene-dusk-2 terrain"></div>
|
||||
<div class="bbox" style="top:44%; left:38%; width:22%; height:16%; border-color:#9D4EFF;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">09 MAY · MK</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-blue"><span class="dot"></span>EDITED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 15 - None, field 2 -->
|
||||
<div class="tile">
|
||||
<div class="img scene-field-2 terrain"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">09 MAY · OK</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-none"><span class="dot"></span>NONE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 16 - Validated, coast, selected -->
|
||||
<div class="tile selected">
|
||||
<div class="img scene-coast-1 terrain"></div>
|
||||
<div class="bbox" style="top:40%; left:30%; width:24%; height:18%; border-color:#FF0000;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="check"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><polyline points="20 6 9 17 4 12"/></svg></div>
|
||||
<div class="corner-tag mono">09 MAY · RD</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-green"><span class="dot"></span>VALIDATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 17 - Created, night, SEED -->
|
||||
<div class="tile seed">
|
||||
<div class="img scene-night-1 terrain"></div>
|
||||
<div class="bbox" style="top:46%; left:42%; width:20%; height:14%; border-color:#00FFFF;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">09 MAY · RD</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-amber"><span class="dot"></span>CREATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 18 - Validated, snow -->
|
||||
<div class="tile">
|
||||
<div class="img scene-snow-1 terrain"></div>
|
||||
<div class="bbox" style="top:42%; left:36%; width:22%; height:18%; border-color:#FF0000;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">09 MAY · AB</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-green"><span class="dot"></span>VALIDATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 19 - Edited, rural -->
|
||||
<div class="tile">
|
||||
<div class="img scene-rural-1 terrain"></div>
|
||||
<div class="bbox" style="top:50%; left:30%; width:30%; height:18%; border-color:#00B341;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">08 MAY · MK</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-blue"><span class="dot"></span>EDITED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 20 - Validated, forest 2 -->
|
||||
<div class="tile">
|
||||
<div class="img scene-forest-2 terrain"></div>
|
||||
<div class="bbox" style="top:34%; left:26%; width:20%; height:16%; border-color:#FF0000;"></div>
|
||||
<div class="bbox" style="top:60%; left:56%; width:18%; height:12%; border-color:#FFFF00;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">08 MAY · RD</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-green"><span class="dot"></span>VALIDATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 21 - None, dusk 2 -->
|
||||
<div class="tile">
|
||||
<div class="img scene-dusk-2 terrain"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">08 MAY · OK</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-none"><span class="dot"></span>NONE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 22 - Created, desert 2 -->
|
||||
<div class="tile">
|
||||
<div class="img scene-desert-2 terrain"></div>
|
||||
<div class="bbox" style="top:48%; left:40%; width:24%; height:18%; border-color:#FF6B00;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">08 MAY · RD</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-amber"><span class="dot"></span>CREATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 23 - Validated, urban 1 -->
|
||||
<div class="tile">
|
||||
<div class="img scene-urban-1 terrain"></div>
|
||||
<div class="bbox" style="top:40%; left:34%; width:22%; height:16%; border-color:#0044FF;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">08 MAY · AB</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-green"><span class="dot"></span>VALIDATED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile 24 - Edited, coast -->
|
||||
<div class="tile">
|
||||
<div class="img scene-coast-1 terrain"></div>
|
||||
<div class="bbox" style="top:48%; left:44%; width:18%; height:14%; border-color:#00FFFF;"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="corner-tag mono">08 MAY · MK</div>
|
||||
<div class="absolute bottom-1.5 left-1.5">
|
||||
<span class="pill pill-blue"><span class="dot"></span>EDITED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="bracket panel relative flex items-center gap-3 px-3 shrink-0" style="height:44px;">
|
||||
<span class="br"></span>
|
||||
|
||||
<button class="btn btn-primary">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Validate (3)
|
||||
</button>
|
||||
|
||||
<button class="btn btn-ghost">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/><path d="M20.49 15A9 9 0 0 1 5.64 18.36L1 14"/></svg>
|
||||
Refresh Thumbnails
|
||||
</button>
|
||||
|
||||
<span class="vdiv"></span>
|
||||
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="micro">Selected</span>
|
||||
<span class="mono text-[12px] text-tp truncate">ann_FL03_0231_ArmorVehicle_07</span>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<span class="text-[11px] text-tm">3 of 214 selected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,895 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AZAION // FLIGHTS — Tactical Ops</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--surface-0: #0A0D10;
|
||||
--surface-1: #13171C;
|
||||
--surface-2: #1A1F26;
|
||||
--surface-input: #0A0D10;
|
||||
--border-hair: #252B34;
|
||||
--border-raised: #3B4451;
|
||||
--text-primary: #E8ECF1;
|
||||
--text-secondary:#9AA4B2;
|
||||
--text-muted: #5B6573;
|
||||
--accent-amber: #FF9D3D;
|
||||
--accent-cyan: #36D6C5;
|
||||
--accent-red: #FF4756;
|
||||
--accent-green: #3DDC84;
|
||||
--accent-blue: #4E9EFF;
|
||||
}
|
||||
html, body {
|
||||
background: var(--surface-0);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
body {
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
|
||||
.num { font-variant-numeric: tabular-nums; font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
||||
|
||||
.micro {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.section-head {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
/* Corner brackets */
|
||||
.bracket { position: relative; }
|
||||
.bracket::before, .bracket::after,
|
||||
.bracket > .br::before, .bracket > .br::after {
|
||||
content: ''; position: absolute; width: 8px; height: 8px;
|
||||
border-color: var(--accent-amber); border-style: solid; border-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.bracket::before { top: -1px; left: -1px; border-top-width: 1px; border-left-width: 1px; }
|
||||
.bracket::after { top: -1px; right: -1px; border-top-width: 1px; border-right-width: 1px; }
|
||||
.bracket > .br::before { bottom: -1px; left: -1px; border-bottom-width: 1px; border-left-width: 1px; }
|
||||
.bracket > .br::after { bottom: -1px; right: -1px; border-bottom-width: 1px; border-right-width: 1px; }
|
||||
|
||||
.bracket-cyan::before, .bracket-cyan::after,
|
||||
.bracket-cyan > .br::before, .bracket-cyan > .br::after { border-color: var(--accent-cyan); }
|
||||
.bracket-red::before, .bracket-red::after,
|
||||
.bracket-red > .br::before, .bracket-red > .br::after { border-color: var(--accent-red); }
|
||||
|
||||
.hair { border-color: var(--border-hair); }
|
||||
.panel { background: var(--surface-1); border: 1px solid var(--border-hair); }
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background: var(--accent-amber); color: #0A0D10; border: 1px solid var(--accent-amber);
|
||||
padding: 6px 14px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; font-weight: 600;
|
||||
transition: filter .12s;
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(1.08); }
|
||||
.btn-secondary {
|
||||
background: transparent; color: var(--accent-amber); border: 1px solid var(--accent-amber);
|
||||
padding: 6px 14px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
}
|
||||
.btn-secondary:hover { background: rgba(255,157,61,0.12); }
|
||||
.btn-ghost {
|
||||
background: transparent; color: var(--text-secondary); border: 1px solid var(--border-hair);
|
||||
padding: 6px 14px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
}
|
||||
.btn-ghost:hover { color: var(--text-primary); border-color: var(--border-raised); }
|
||||
.btn-danger {
|
||||
background: var(--accent-red); color: #0A0D10; border: 1px solid var(--accent-red);
|
||||
padding: 6px 14px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; font-weight: 600;
|
||||
}
|
||||
.btn-cyan {
|
||||
background: transparent; color: var(--accent-cyan); border: 1px solid var(--accent-cyan);
|
||||
padding: 6px 14px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
}
|
||||
.btn-cyan:hover { background: rgba(54,214,197,0.10); }
|
||||
|
||||
/* Inputs */
|
||||
.ipt {
|
||||
background: var(--surface-input); border: 1px solid var(--border-hair);
|
||||
border-radius: 2px; padding: 6px 10px; height: 32px;
|
||||
font-family: 'IBM Plex Sans', sans-serif; font-size: 12px;
|
||||
color: var(--text-primary); width: 100%;
|
||||
}
|
||||
.ipt:focus { outline: none; border-color: var(--accent-amber); box-shadow: 0 0 0 1px var(--accent-amber); }
|
||||
.ipt::placeholder { color: var(--text-muted); }
|
||||
.ipt-num { font-variant-numeric: tabular-nums; font-family: 'JetBrains Mono', monospace; }
|
||||
select.ipt { appearance: none; background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%),
|
||||
linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%);
|
||||
background-position: calc(100% - 14px) 14px, calc(100% - 9px) 14px;
|
||||
background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; padding-right: 26px; }
|
||||
input[type="date"].ipt { color-scheme: dark; }
|
||||
|
||||
/* Pill / status */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 2px 8px; border-radius: 2px; border: 1px solid currentColor;
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 10px;
|
||||
letter-spacing: 0.12em; text-transform: uppercase;
|
||||
background: transparent;
|
||||
}
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
|
||||
.pill-green { color: var(--accent-green); }
|
||||
.pill-cyan { color: var(--accent-cyan); }
|
||||
.pill-red { color: var(--accent-red); }
|
||||
.pill-amber { color: var(--accent-amber); }
|
||||
.pill-muted { color: var(--text-secondary); border-color: var(--border-hair); }
|
||||
|
||||
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
|
||||
.pulse { animation: pulse 1.6s ease-in-out infinite; }
|
||||
|
||||
/* Header live-dot — glow-ring animation, matches other plugin pages */
|
||||
.live-dot {
|
||||
width: 6px; height: 6px; border-radius: 999px;
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 0 rgba(54,214,197,0.5);
|
||||
animation: liveDotPulse 1.6s ease-in-out infinite;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
}
|
||||
@keyframes liveDotPulse {
|
||||
0%,100% { box-shadow: 0 0 0 0 rgba(54,214,197,0.5); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(54,214,197,0); }
|
||||
}
|
||||
|
||||
/* Draw-mode selector buttons */
|
||||
.dmode {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
height: 32px; padding: 0 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px; font-weight: 600;
|
||||
letter-spacing: 0.10em; text-transform: uppercase;
|
||||
border: 1px solid; border-radius: 2px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color .12s, color .12s, box-shadow .12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dmode:hover { background-color: rgba(255,255,255,0.04); }
|
||||
.dmode-sq { width: 32px; height: 32px; padding: 0; }
|
||||
.dmode-amber { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||
.dmode-amber.active { background-color: rgba(255,157,61,0.20); box-shadow: inset 0 0 0 1px var(--accent-amber); }
|
||||
.dmode-green { color: var(--accent-green); border-color: var(--accent-green); }
|
||||
.dmode-green.active { background-color: rgba(61,220,132,0.18); box-shadow: inset 0 0 0 1px var(--accent-green); }
|
||||
.dmode-red { color: var(--accent-red); border-color: var(--accent-red); }
|
||||
.dmode-red.active { background-color: rgba(255,71,86,0.18); box-shadow: inset 0 0 0 1px var(--accent-red); }
|
||||
|
||||
/* Params panel collapse */
|
||||
.params-panel { width: 290px; transition: width .18s ease; }
|
||||
.params-panel.collapsed { width: 44px; }
|
||||
.params-panel.collapsed .panel-body { display: none; }
|
||||
.params-panel:not(.collapsed) .collapsed-rail { display: none; }
|
||||
.collapsed-rail {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
padding: 10px 6px;
|
||||
}
|
||||
.rail-btn {
|
||||
width: 32px; height: 32px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px solid var(--border-hair); border-radius: 2px;
|
||||
background: var(--surface-0); color: var(--text-secondary);
|
||||
cursor: pointer; transition: color .12s, border-color .12s, background-color .12s;
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
||||
}
|
||||
.rail-btn:hover { color: var(--text-primary); border-color: var(--border-raised); background: var(--surface-2); }
|
||||
.collapse-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px;
|
||||
border: 1px solid var(--border-hair); border-radius: 2px;
|
||||
background: var(--surface-1); color: var(--text-secondary);
|
||||
cursor: pointer; transition: color .12s, border-color .12s;
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
||||
}
|
||||
.collapse-btn:hover { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||
|
||||
/* Tab nav */
|
||||
.tab {
|
||||
display: inline-flex; align-items: center;
|
||||
height: 48px; padding: 0 14px;
|
||||
font: 500 12px/1 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.10em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab:hover { color: var(--text-primary); }
|
||||
.tab.active { color: var(--text-primary); border-bottom-color: var(--accent-amber); font-weight: 500; }
|
||||
|
||||
/* Icon buttons in header */
|
||||
.ibtn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px;
|
||||
border: 1px solid var(--border-hair); border-radius: 2px;
|
||||
color: var(--text-secondary); background: transparent;
|
||||
transition: color .12s, border-color .12s, background-color .12s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ibtn:hover { color: var(--text-primary); border-color: var(--border-raised); background: var(--surface-2); }
|
||||
.ibtn.active { color: var(--accent-amber); border-color: var(--accent-amber); background: rgba(255,157,61,0.08); }
|
||||
.ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent-red); background: rgba(255,71,86,0.08); }
|
||||
|
||||
/* Flight list row */
|
||||
.fl-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
height: 28px; padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-hair);
|
||||
cursor: pointer;
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.fl-row:hover { background: var(--surface-2); }
|
||||
.fl-row.active { background: var(--surface-2); position: relative; }
|
||||
.fl-row.active::before {
|
||||
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 2px;
|
||||
background: var(--accent-amber);
|
||||
}
|
||||
.fl-row .fid { color: var(--accent-amber); }
|
||||
.fl-row .meta { margin-left: auto; font-size: 10px; color: var(--text-muted); letter-spacing: 0.08em; }
|
||||
|
||||
/* Waypoint row */
|
||||
.wp-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
height: 30px; padding: 0 4px;
|
||||
border-bottom: 1px solid var(--border-hair);
|
||||
font-size: 12px; color: var(--text-primary);
|
||||
}
|
||||
.wp-row:last-child { border-bottom: none; }
|
||||
.wp-row .wp-id {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||
color: var(--text-secondary); width: 28px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wp-row .wp-marker { width: 10px; height: 10px; flex-shrink: 0; }
|
||||
.wp-row .wp-tag {
|
||||
margin-left: auto; font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text-muted); border: 1px solid var(--border-hair);
|
||||
padding: 1px 5px; border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Map background grid */
|
||||
.map-grid {
|
||||
background-color: #0F1318;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||
radial-gradient(ellipse at 30% 40%, rgba(54,214,197,0.04), transparent 60%),
|
||||
radial-gradient(ellipse at 80% 70%, rgba(255,157,61,0.03), transparent 65%);
|
||||
background-size: 60px 60px, 60px 60px, 100% 100%, 100% 100%;
|
||||
}
|
||||
|
||||
/* GPS-Denied accent state */
|
||||
.gps-active-frame {
|
||||
border: 2px solid var(--accent-red) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(255,71,86,0.12);
|
||||
}
|
||||
.gps-active-frame.bracket::before, .gps-active-frame.bracket::after,
|
||||
.gps-active-frame.bracket > .br::before, .gps-active-frame.bracket > .br::after {
|
||||
border-color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: var(--surface-0); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-hair); border-radius: 0; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-raised); }
|
||||
|
||||
/* Map waypoint markers (svg-styled overlays) */
|
||||
.wp-marker-map {
|
||||
position: absolute; transform: translate(-50%, -50%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.wp-square { width: 12px; height: 12px; background: #0A0D10; border: 1.5px solid var(--accent-cyan); }
|
||||
.wp-square.corrected { border-color: var(--accent-cyan); background: rgba(54,214,197,0.15); }
|
||||
.wp-diamond { width: 14px; height: 14px; background: var(--accent-green); border: 1.5px solid #0A0D10; transform: translate(-50%,-50%) rotate(45deg); box-shadow: 0 0 0 1px var(--accent-green); }
|
||||
.wp-octagon {
|
||||
width: 16px; height: 16px; background: var(--accent-red);
|
||||
clip-path: polygon(30% 0, 70% 0, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0 70%, 0 30%);
|
||||
}
|
||||
|
||||
.crosshair-x, .crosshair-y {
|
||||
position: absolute; background: rgba(255,255,255,0.06); pointer-events: none;
|
||||
}
|
||||
.crosshair-x { left: 0; right: 0; height: 1px; top: 50%; }
|
||||
.crosshair-y { top: 0; bottom: 0; width: 1px; left: 50%; }
|
||||
|
||||
.map-axis-label {
|
||||
position: absolute; font-family: 'JetBrains Mono', monospace; font-size: 9px;
|
||||
color: var(--text-muted); letter-spacing: 0.1em; text-transform: uppercase;
|
||||
}
|
||||
|
||||
details > summary { list-style: none; cursor: pointer; }
|
||||
details > summary::-webkit-details-marker { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-screen flex flex-col overflow-hidden">
|
||||
|
||||
<!-- ========================= GLOBAL HEADER ========================= -->
|
||||
<header class="h-12 flex items-center px-4 gap-3 border-b" style="border-color: var(--border-hair); background: var(--surface-1);">
|
||||
<span class="mono font-bold" style="color: var(--accent-amber); letter-spacing: 0.2em; font-size: 14px;">AZAION</span>
|
||||
|
||||
<span class="micro" style="color: var(--text-muted);">//</span>
|
||||
|
||||
<button class="inline-flex items-center gap-2 mono" style="height: 28px; padding: 0 10px; background: var(--surface-1); border: 1px solid var(--accent-amber); border-radius: 2px; font-size: 11px; letter-spacing: 0.10em;">
|
||||
<span class="live-dot"></span>
|
||||
<span style="color: var(--text-primary);">FL-03</span>
|
||||
<span style="color: var(--text-secondary); font-size: 10px;">▾</span>
|
||||
</button>
|
||||
|
||||
<nav class="flex items-center self-stretch ml-3">
|
||||
<a href="flights.html" class="tab active">Flights</a>
|
||||
<a href="annotations.html" class="tab">Annotations</a>
|
||||
<a href="dataset_explorer.html" class="tab">Dataset</a>
|
||||
<a href="admin.html" class="tab">Admin</a>
|
||||
</nav>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2" style="font: 500 10px/1.4 'JetBrains Mono', monospace; letter-spacing: 0.12em; text-transform: uppercase;">
|
||||
<span class="live-dot"></span>
|
||||
<span style="color: var(--accent-cyan);">LINK</span>
|
||||
<span style="color: var(--border-raised);">|</span>
|
||||
<span style="color: var(--text-secondary); text-transform: none; letter-spacing: 0;">user@azaion.com</span>
|
||||
<span style="color: var(--border-raised); margin: 0 4px;">|</span>
|
||||
<a href="settings.html" class="ibtn" title="Settings">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
</a>
|
||||
<a href="#" class="ibtn danger" title="Sign out">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ========================= MAIN ROW ========================= -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
|
||||
<!-- =========================================================== -->
|
||||
<!-- FLIGHT LIST SIDEBAR (~200px) -->
|
||||
<!-- =========================================================== -->
|
||||
<aside class="w-[210px] shrink-0 flex flex-col border-r hair" style="background: var(--surface-1);">
|
||||
<div class="px-3 py-2.5 flex items-center justify-between border-b hair">
|
||||
<span class="section-head">Flight Roster</span>
|
||||
<span class="micro num" style="color: var(--text-muted);">04</span>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="px-3 py-2 border-b hair">
|
||||
<div class="relative">
|
||||
<input class="ipt h-7 text-[11px] pl-7 mono" placeholder="SEARCH FLIGHTS" style="letter-spacing:0.08em;">
|
||||
<svg class="absolute left-2 top-1/2 -translate-y-1/2" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--text-muted);"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flight list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="fl-row active">
|
||||
<span class="fid">FL02</span>
|
||||
<span style="color: var(--accent-amber);" title="Pinned">★</span>
|
||||
<span class="meta num">05/12</span>
|
||||
</div>
|
||||
<div class="fl-row">
|
||||
<span class="fid">FL01</span>
|
||||
<span class="meta num">05/09</span>
|
||||
</div>
|
||||
<div class="fl-row">
|
||||
<span class="fid">FL03</span>
|
||||
<span class="meta num">05/08</span>
|
||||
</div>
|
||||
<div class="fl-row">
|
||||
<span class="fid">FL04</span>
|
||||
<span class="meta num">05/03</span>
|
||||
</div>
|
||||
<div class="fl-row">
|
||||
<span class="fid" style="color: var(--text-muted);">FL05</span>
|
||||
<span class="micro" style="color: var(--text-muted);">DRAFT</span>
|
||||
<span class="meta num">04/28</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create -->
|
||||
<div class="p-3 border-t hair">
|
||||
<button class="btn-primary w-full flex items-center justify-center gap-2">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M5 1 V9 M1 5 H9" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
Create New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Telemetry card -->
|
||||
<div class="m-3 mt-0 bracket panel p-3" style="padding:12px;">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="micro" style="color: var(--accent-amber);">// Telemetry</span>
|
||||
</div>
|
||||
<label class="micro block mb-1">Date</label>
|
||||
<input type="date" value="2025-03-01" class="ipt ipt-num text-[12px]">
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- =========================================================== -->
|
||||
<!-- PARAMS / GPS-DENIED PANEL (~280px) — both modes visible -->
|
||||
<!-- =========================================================== -->
|
||||
<aside id="paramsPanel" class="params-panel shrink-0 overflow-y-auto border-r hair" style="background: var(--surface-1);">
|
||||
|
||||
<!-- Collapsed rail (visible only when .collapsed) -->
|
||||
<div class="collapsed-rail">
|
||||
<button class="rail-btn" onclick="toggleParams()" title="Expand parameters">»</button>
|
||||
<span class="block w-6 h-px" style="background: var(--border-hair);"></span>
|
||||
<button class="dmode dmode-sq dmode-amber active" title="Points">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="1.6" fill="currentColor"/><circle cx="18" cy="6" r="1.6" fill="currentColor"/><circle cx="12" cy="14" r="1.6" fill="currentColor"/><circle cx="6" cy="20" r="1.6" fill="currentColor"/><circle cx="18" cy="20" r="1.6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button class="dmode dmode-sq dmode-green" title="Work Area">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="4 7 12 3 20 7 20 17 12 21 4 17"/></svg>
|
||||
</button>
|
||||
<button class="dmode dmode-sq dmode-red" title="No-Go Zone">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Expanded body -->
|
||||
<div class="panel-body">
|
||||
|
||||
<!-- Mode toggle bar -->
|
||||
<div class="flex items-stretch border-b hair" style="background: var(--surface-0);">
|
||||
<button id="tabFP" onclick="setMode('fp')" class="flex-1 py-2.5 mono text-[10px] uppercase tracking-[0.14em] border-b-2 transition"
|
||||
style="color: var(--text-primary); border-color: var(--accent-amber); background: var(--surface-1);">
|
||||
Flight Params
|
||||
</button>
|
||||
<button id="tabGPS" onclick="setMode('gps')" class="flex-1 py-2.5 mono text-[10px] uppercase tracking-[0.14em] border-b-2 transition"
|
||||
style="color: var(--text-secondary); border-color: transparent;">
|
||||
GPS-Denied
|
||||
</button>
|
||||
<button class="collapse-btn shrink-0 mx-1 self-center" onclick="toggleParams()" title="Collapse">«</button>
|
||||
</div>
|
||||
|
||||
<!-- ============== FLIGHT PARAMETERS ============== -->
|
||||
<section id="flightParams" class="p-4 space-y-5">
|
||||
|
||||
<!-- Draw-mode selector -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="micro" style="color: var(--accent-amber);">// Draw Mode</span>
|
||||
<span class="micro num" style="color: var(--text-muted);">click map to plot</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button class="dmode dmode-amber active" data-mode="points">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="1.6" fill="currentColor"/><circle cx="18" cy="6" r="1.6" fill="currentColor"/><circle cx="12" cy="14" r="1.6" fill="currentColor"/><circle cx="6" cy="20" r="1.6" fill="currentColor"/><circle cx="18" cy="20" r="1.6" fill="currentColor"/><path d="M6 6l6 8 6-8M6 20l6-6 6 6" opacity="0.45"/></svg>
|
||||
<span>Points</span>
|
||||
</button>
|
||||
<button class="dmode dmode-green" data-mode="work">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="4 7 12 3 20 7 20 17 12 21 4 17"/></svg>
|
||||
<span>Work Area</span>
|
||||
</button>
|
||||
<button class="dmode dmode-red" data-mode="nogo">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"/></svg>
|
||||
<span>No-Go Zone</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="section-head">Mission Config</h2>
|
||||
<span class="pill pill-amber"><span class="dot"></span>FL02</span>
|
||||
</header>
|
||||
|
||||
<div class="bracket panel p-3 space-y-3">
|
||||
<div>
|
||||
<label class="micro block mb-1.5">Aircraft</label>
|
||||
<select class="ipt">
|
||||
<option>DJI Mavic 3 Enterprise</option>
|
||||
<option>DJI Matrice 350 RTK</option>
|
||||
<option>Autel EVO Max 4T</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="micro block mb-1.5">Default Height</label>
|
||||
<div class="relative">
|
||||
<input type="number" value="100" class="ipt ipt-num pr-9">
|
||||
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 micro" style="color: var(--text-muted);">M</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="micro block mb-1.5">Focal Length</label>
|
||||
<div class="relative">
|
||||
<input type="number" value="24" class="ipt ipt-num pr-10">
|
||||
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 micro" style="color: var(--text-muted);">MM</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="micro block mb-1.5">Comm Address / Port</label>
|
||||
<input type="text" value="192.168.1.42:8080" class="ipt ipt-num">
|
||||
</div>
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
|
||||
<!-- Waypoints -->
|
||||
<div class="bracket panel p-3">
|
||||
<header class="flex items-center justify-between mb-2.5">
|
||||
<span class="section-head">Waypoints</span>
|
||||
<span class="micro num" style="color: var(--text-muted);">06 PTS</span>
|
||||
</header>
|
||||
|
||||
<div class="space-y-0">
|
||||
<div class="wp-row">
|
||||
<span class="wp-id">00</span>
|
||||
<span class="wp-marker" style="background: var(--accent-green); transform: rotate(45deg);"></span>
|
||||
<span class="mono text-[11px]">START</span>
|
||||
<span class="wp-tag" style="color: var(--accent-green); border-color: var(--accent-green);">ORIGIN</span>
|
||||
</div>
|
||||
<div class="wp-row">
|
||||
<span class="wp-id">01</span>
|
||||
<span class="wp-marker" style="background: transparent; border: 1.5px solid var(--accent-cyan);"></span>
|
||||
<span class="mono text-[11px]">Point 1</span>
|
||||
<span class="wp-tag">TRACK</span>
|
||||
</div>
|
||||
<div class="wp-row">
|
||||
<span class="wp-id">02</span>
|
||||
<span class="wp-marker" style="background: transparent; border: 1.5px solid var(--accent-cyan);"></span>
|
||||
<span class="mono text-[11px]">Point 2</span>
|
||||
<span class="wp-tag" style="color: var(--accent-red); border-color: var(--accent-red);">MIL-VEH</span>
|
||||
</div>
|
||||
<div class="wp-row">
|
||||
<span class="wp-id">03</span>
|
||||
<span class="wp-marker" style="background: transparent; border: 1.5px solid var(--accent-cyan);"></span>
|
||||
<span class="mono text-[11px]">Point 3</span>
|
||||
</div>
|
||||
<div class="wp-row">
|
||||
<span class="wp-id">04</span>
|
||||
<span class="wp-marker" style="background: transparent; border: 1.5px solid var(--accent-cyan);"></span>
|
||||
<span class="mono text-[11px]">Point 4</span>
|
||||
<span class="wp-tag">CONFIRM</span>
|
||||
</div>
|
||||
<div class="wp-row">
|
||||
<span class="wp-id">FN</span>
|
||||
<span class="wp-marker" style="background: var(--accent-red); clip-path: polygon(30% 0, 70% 0, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0 70%, 0 30%);"></span>
|
||||
<span class="mono text-[11px]">FINISH</span>
|
||||
<span class="wp-tag" style="color: var(--accent-red); border-color: var(--accent-red);">TARGET</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button onclick="setMode('gps')" class="btn-secondary" style="color: var(--accent-red); border-color: var(--accent-red);">GPS-Denied</button>
|
||||
<button class="btn-cyan">Upload</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============== GPS-DENIED MODE ============== -->
|
||||
<section id="gpsDenied" class="p-4 space-y-5 hidden">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="section-head" style="color: var(--accent-red);">GPS-Denied // Active</h2>
|
||||
<span class="pill pill-red"><span class="dot pulse"></span>GPS-DENIED ACTIVE</span>
|
||||
</header>
|
||||
|
||||
<!-- Frame with red accent -->
|
||||
<div id="gpsFrame" class="bracket bracket-red panel gps-active-frame p-3">
|
||||
<header class="flex items-center justify-between mb-3">
|
||||
<span class="section-head" style="color: var(--accent-red);">// Orthophoto Upload</span>
|
||||
<span class="micro num" style="color: var(--text-muted);">03 / 12</span>
|
||||
</header>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2.5 border hair px-2.5 py-2" style="background: var(--surface-0);">
|
||||
<span class="w-6 h-6 flex items-center justify-center shrink-0 mono text-[10px]" style="background: var(--accent-cyan); color: #0A0D10; font-weight: 700;">P1</span>
|
||||
<span class="mono text-[11px] flex-1 truncate">ortho_001.jpg</span>
|
||||
<span class="num text-[10px]" style="color: var(--text-secondary);">48.8566, 2.3522</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 border hair px-2.5 py-2" style="background: var(--surface-0);">
|
||||
<span class="w-6 h-6 flex items-center justify-center shrink-0 mono text-[10px]" style="background: var(--accent-cyan); color: #0A0D10; font-weight: 700;">P2</span>
|
||||
<span class="mono text-[11px] flex-1 truncate">ortho_002.jpg</span>
|
||||
<span class="num text-[10px]" style="color: var(--text-secondary);">48.8612, 2.3601</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 border hair px-2.5 py-2" style="background: var(--surface-0);">
|
||||
<span class="w-6 h-6 flex items-center justify-center shrink-0 mono text-[10px]" style="background: var(--accent-cyan); color: #0A0D10; font-weight: 700;">P3</span>
|
||||
<span class="mono text-[11px] flex-1 truncate">ortho_003.jpg</span>
|
||||
<span class="num text-[10px]" style="color: var(--text-secondary);">48.8703, 2.3754</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="w-full mt-2.5 py-2 mono text-[10px] uppercase tracking-[0.12em] border border-dashed flex items-center justify-center gap-2"
|
||||
style="border-color: var(--border-raised); color: var(--text-secondary); background: transparent;">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M5 1 V9 M1 5 H9" stroke="currentColor" stroke-width="1.4"/></svg>
|
||||
Upload Photos
|
||||
</button>
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
|
||||
<!-- Live GPS readout -->
|
||||
<div class="bracket panel p-3">
|
||||
<header class="flex items-center justify-between mb-2.5">
|
||||
<span class="section-head">// Live GPS</span>
|
||||
<span class="pill pill-green"><span class="dot pulse"></span>CONNECTED</span>
|
||||
</header>
|
||||
<div class="space-y-1.5 text-[12px]">
|
||||
<div class="flex items-center justify-between py-1 border-b hair">
|
||||
<span class="micro">Status</span>
|
||||
<span class="mono" style="color: var(--accent-green);">CONNECTED · STREAMING</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-1 border-b hair">
|
||||
<span class="micro">Latitude</span>
|
||||
<span class="num">48.85660° N</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-1 border-b hair">
|
||||
<span class="micro">Longitude</span>
|
||||
<span class="num">02.35220° E</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-1 border-b hair">
|
||||
<span class="micro">Satellites</span>
|
||||
<span class="num" style="color: var(--accent-cyan);">12 / 14</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<span class="micro">Drift</span>
|
||||
<span class="num" style="color: var(--accent-amber);">±2.4 M</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
|
||||
<!-- GPS Correction -->
|
||||
<div class="bracket panel p-3">
|
||||
<header class="flex items-center justify-between mb-2.5">
|
||||
<span class="section-head">// GPS Correction</span>
|
||||
</header>
|
||||
<div class="space-y-2.5">
|
||||
<div>
|
||||
<label class="micro block mb-1.5">Waypoint #</label>
|
||||
<input type="number" value="03" class="ipt ipt-num">
|
||||
</div>
|
||||
<div>
|
||||
<label class="micro block mb-1.5">Corrected GPS</label>
|
||||
<input type="text" value="48.86120, 2.36011" class="ipt ipt-num">
|
||||
</div>
|
||||
<button class="btn-primary w-full">Apply Correction</button>
|
||||
</div>
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
|
||||
<button onclick="setMode('fp')" class="btn-ghost w-full">‹ Back to Flight Params</button>
|
||||
</section>
|
||||
|
||||
</div><!-- /.panel-body -->
|
||||
</aside>
|
||||
|
||||
<!-- =========================================================== -->
|
||||
<!-- MAP VIEW -->
|
||||
<!-- =========================================================== -->
|
||||
<main class="flex-1 relative overflow-hidden map-grid">
|
||||
|
||||
<!-- crosshairs -->
|
||||
<div class="crosshair-x"></div>
|
||||
<div class="crosshair-y"></div>
|
||||
|
||||
<!-- axis labels -->
|
||||
<div class="map-axis-label" style="top: 8px; left: 12px;">SECTOR 04-K // ZOOM 17</div>
|
||||
<div class="map-axis-label" style="top: 8px; left: 50%; transform: translateX(-50%);">— TARGET CORRIDOR —</div>
|
||||
<div class="map-axis-label" style="bottom: 8px; left: 12px;">N 48.8566 // E 02.3522</div>
|
||||
<div class="map-axis-label" style="bottom: 8px; right: 12px;">GRID 60M · WGS-84</div>
|
||||
|
||||
<!-- Compass rosette top-left -->
|
||||
<div class="absolute top-12 left-4 w-20 h-20 flex items-center justify-center border hair bracket panel"
|
||||
style="background: rgba(19,23,28,0.6); backdrop-filter: blur(2px);">
|
||||
<svg width="60" height="60" viewBox="-30 -30 60 60" style="color: var(--accent-amber);">
|
||||
<circle r="24" fill="none" stroke="currentColor" stroke-opacity="0.3" stroke-width="0.7"/>
|
||||
<circle r="20" fill="none" stroke="currentColor" stroke-opacity="0.2" stroke-width="0.5"/>
|
||||
<line x1="0" y1="-26" x2="0" y2="-20" stroke="currentColor" stroke-width="1.5"/>
|
||||
<line x1="0" y1="20" x2="0" y2="26" stroke="currentColor" stroke-opacity="0.4" stroke-width="0.8"/>
|
||||
<line x1="-26" y1="0" x2="-20" y2="0" stroke="currentColor" stroke-opacity="0.4" stroke-width="0.8"/>
|
||||
<line x1="20" y1="0" x2="26" y2="0" stroke="currentColor" stroke-opacity="0.4" stroke-width="0.8"/>
|
||||
<text x="0" y="-12" text-anchor="middle" font-family="JetBrains Mono" font-size="7" fill="currentColor" font-weight="700">N</text>
|
||||
<polygon points="0,-16 -3,-8 0,-10 3,-8" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
|
||||
<!-- SVG paths overlay -->
|
||||
<svg class="absolute inset-0 w-full h-full" viewBox="0 0 800 600" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<marker id="arrowCyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#36D6C5"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<!-- Original (planned) path — red dashed -->
|
||||
<polyline points="150,450 250,350 350,280 450,320 550,250 650,200"
|
||||
fill="none" stroke="#FF4756" stroke-width="1.5"
|
||||
stroke-dasharray="5 4" opacity="0.85"/>
|
||||
<!-- Corrected (live) path — cyan solid -->
|
||||
<polyline points="150,460 255,358 360,290 455,328 555,260 650,210"
|
||||
fill="none" stroke="#36D6C5" stroke-width="2"
|
||||
marker-end="url(#arrowCyan)"/>
|
||||
<!-- Correction ties (thin perpendicular linkers between original/corrected) -->
|
||||
<g stroke="#36D6C5" stroke-width="0.6" stroke-dasharray="2 2" opacity="0.4">
|
||||
<line x1="250" y1="350" x2="255" y2="358"/>
|
||||
<line x1="350" y1="280" x2="360" y2="290"/>
|
||||
<line x1="450" y1="320" x2="455" y2="328"/>
|
||||
<line x1="550" y1="250" x2="555" y2="260"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Waypoint markers on map -->
|
||||
<!-- Start: diamond (green) -->
|
||||
<div class="wp-marker-map" style="left:18.75%; top:75%;">
|
||||
<div class="wp-diamond"></div>
|
||||
<span class="absolute top-3 left-3 mono text-[9px] num" style="color: var(--accent-green); letter-spacing: 0.1em;">WP-00 · START</span>
|
||||
</div>
|
||||
<!-- Intermediate: square handles -->
|
||||
<div class="wp-marker-map" style="left:31.25%; top:58.3%;">
|
||||
<div class="wp-square"></div>
|
||||
<span class="absolute top-3 left-3 mono text-[9px] num" style="color: var(--accent-cyan);">WP-01</span>
|
||||
</div>
|
||||
<div class="wp-marker-map" style="left:43.75%; top:46.7%;">
|
||||
<div class="wp-square"></div>
|
||||
<span class="absolute top-3 left-3 mono text-[9px] num" style="color: var(--accent-cyan);">WP-02</span>
|
||||
</div>
|
||||
<div class="wp-marker-map" style="left:56.25%; top:53.3%;">
|
||||
<div class="wp-square"></div>
|
||||
<span class="absolute top-3 left-3 mono text-[9px] num" style="color: var(--accent-cyan);">WP-03</span>
|
||||
<span class="absolute -top-4 -left-1 mono text-[8px]" style="color: var(--accent-amber); letter-spacing: 0.1em;">CORRECTED</span>
|
||||
</div>
|
||||
<div class="wp-marker-map" style="left:68.75%; top:41.7%;">
|
||||
<div class="wp-square"></div>
|
||||
<span class="absolute top-3 left-3 mono text-[9px] num" style="color: var(--accent-cyan);">WP-04</span>
|
||||
</div>
|
||||
<!-- Finish: octagon (red) -->
|
||||
<div class="wp-marker-map" style="left:81.25%; top:33.3%;">
|
||||
<div class="wp-octagon"></div>
|
||||
<span class="absolute top-3.5 left-3.5 mono text-[9px] num" style="color: var(--accent-red); letter-spacing: 0.1em;">WP-FN · TARGET</span>
|
||||
</div>
|
||||
|
||||
<!-- ============ MAP HUD: TOP-RIGHT STATUS ============ -->
|
||||
<div class="absolute top-4 right-4 w-[240px] bracket panel p-3" style="background: rgba(19,23,28,0.92); backdrop-filter: blur(4px);">
|
||||
<header class="flex items-center justify-between mb-2.5 pb-2 border-b hair">
|
||||
<span class="flex items-center gap-2 mono text-[10px]" style="color: var(--accent-cyan); letter-spacing: 0.14em;">
|
||||
<span class="w-1.5 h-1.5 rounded-full pulse" style="background: var(--accent-cyan);"></span>
|
||||
LIVE · CONNECTED
|
||||
</span>
|
||||
<span class="micro num" style="color: var(--text-muted);">FL02</span>
|
||||
</header>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="micro">Sat</span>
|
||||
<span class="num text-[12px]" style="color: var(--accent-green);">12 / 14</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="micro">Lat</span>
|
||||
<span class="num text-[12px]">48.85660° N</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="micro">Lon</span>
|
||||
<span class="num text-[12px]">02.35220° E</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="micro">Alt</span>
|
||||
<span class="num text-[12px]">320 M / AGL</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="micro">Hdg</span>
|
||||
<span class="num text-[12px]" style="color: var(--accent-amber);">047° NE</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="micro">Spd</span>
|
||||
<span class="num text-[12px]">11.4 M/S</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-1.5 mt-1.5 border-t hair">
|
||||
<span class="micro">Link</span>
|
||||
<span class="num text-[11px]" style="color: var(--accent-green);">RSSI -52 DBM</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
|
||||
<!-- ============ MAP HUD: LEGEND BOTTOM-LEFT ============ -->
|
||||
<div class="absolute bottom-12 left-4 w-[200px] bracket panel p-3" style="background: rgba(19,23,28,0.92);">
|
||||
<header class="mb-2 pb-1.5 border-b hair">
|
||||
<span class="section-head">// Map Legend</span>
|
||||
</header>
|
||||
<div class="space-y-1.5 text-[11px]">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<svg width="22" height="6"><line x1="0" y1="3" x2="22" y2="3" stroke="#FF4756" stroke-width="1.5" stroke-dasharray="3 3"/></svg>
|
||||
<span class="mono uppercase text-[10px]" style="letter-spacing: 0.1em;">Planned · Original</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<svg width="22" height="6"><line x1="0" y1="3" x2="22" y2="3" stroke="#36D6C5" stroke-width="2"/></svg>
|
||||
<span class="mono uppercase text-[10px]" style="letter-spacing: 0.1em;">Corrected · Live</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 pt-1.5 border-t hair">
|
||||
<div style="width:10px; height:10px; background: var(--accent-green); transform: rotate(45deg);"></div>
|
||||
<span class="mono uppercase text-[10px]" style="letter-spacing: 0.1em;">Origin / Start</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div style="width:10px; height:10px; background: transparent; border: 1.5px solid var(--accent-cyan);"></div>
|
||||
<span class="mono uppercase text-[10px]" style="letter-spacing: 0.1em;">Waypoint</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div style="width:11px; height:11px; background: var(--accent-red); clip-path: polygon(30% 0, 70% 0, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0 70%, 0 30%);"></div>
|
||||
<span class="mono uppercase text-[10px]" style="letter-spacing: 0.1em;">Target / Finish</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
|
||||
<!-- ============ MAP TOOLBAR: RIGHT EDGE ============ -->
|
||||
<div class="absolute top-1/2 right-4 -translate-y-1/2 flex flex-col gap-1.5">
|
||||
<button class="w-8 h-8 flex items-center justify-center border hair panel mono text-[11px]" title="Zoom in" style="color: var(--text-primary);">+</button>
|
||||
<button class="w-8 h-8 flex items-center justify-center border hair panel mono text-[11px]" title="Zoom out" style="color: var(--text-primary);">−</button>
|
||||
<div class="w-8 h-px" style="background: var(--border-hair);"></div>
|
||||
<button class="w-8 h-8 flex items-center justify-center border hair panel" title="Recenter" style="color: var(--accent-amber);">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="8"/><line x1="12" y1="2" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="2" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>
|
||||
</button>
|
||||
<button class="w-8 h-8 flex items-center justify-center border hair panel" title="Layers" style="color: var(--text-secondary);">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ BOTTOM STATUS STRIP ============ -->
|
||||
<div class="absolute bottom-0 left-0 right-0 h-7 flex items-center px-3 gap-4 border-t hair"
|
||||
style="background: var(--surface-1);">
|
||||
<span class="pill pill-green"><span class="dot pulse"></span>TELEMETRY · LIVE</span>
|
||||
<span class="micro" style="color: var(--text-muted);">SSE</span>
|
||||
<span class="micro num" style="color: var(--text-secondary);">FRAME 12,847 / 18,400</span>
|
||||
<span class="micro" style="color: var(--text-muted);">·</span>
|
||||
<span class="micro num" style="color: var(--text-secondary);">LAT 48.85660 N · LON 02.35220 E</span>
|
||||
<span class="ml-auto micro num" style="color: var(--text-muted);">LAST PING +0.42S</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function setMode(mode) {
|
||||
const fp = document.getElementById('flightParams');
|
||||
const gps = document.getElementById('gpsDenied');
|
||||
const tabFP = document.getElementById('tabFP');
|
||||
const tabGPS = document.getElementById('tabGPS');
|
||||
if (mode === 'gps') {
|
||||
fp.classList.add('hidden');
|
||||
gps.classList.remove('hidden');
|
||||
tabFP.style.color = 'var(--text-secondary)';
|
||||
tabFP.style.borderColor = 'transparent';
|
||||
tabFP.style.background = 'transparent';
|
||||
tabGPS.style.color = 'var(--text-primary)';
|
||||
tabGPS.style.borderColor = 'var(--accent-red)';
|
||||
tabGPS.style.background = 'var(--surface-1)';
|
||||
} else {
|
||||
gps.classList.add('hidden');
|
||||
fp.classList.remove('hidden');
|
||||
tabGPS.style.color = 'var(--text-secondary)';
|
||||
tabGPS.style.borderColor = 'transparent';
|
||||
tabGPS.style.background = 'transparent';
|
||||
tabFP.style.color = 'var(--text-primary)';
|
||||
tabFP.style.borderColor = 'var(--accent-amber)';
|
||||
tabFP.style.background = 'var(--surface-1)';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleParams() {
|
||||
document.getElementById('paramsPanel').classList.toggle('collapsed');
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,653 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AZAION // SETTINGS</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--surface-0: #0A0D10;
|
||||
--surface-1: #13171C;
|
||||
--surface-2: #1A1F26;
|
||||
--surface-input: #0A0D10;
|
||||
--border-hair: #252B34;
|
||||
--border-raised: #3B4451;
|
||||
--text-primary: #E8ECF1;
|
||||
--text-secondary: #9AA4B2;
|
||||
--text-muted: #5B6573;
|
||||
--accent-amber: #FF9D3D;
|
||||
--accent-cyan: #36D6C5;
|
||||
--accent-red: #FF4756;
|
||||
--accent-green: #3DDC84;
|
||||
--accent-blue: #4E9EFF;
|
||||
}
|
||||
html, body { background: var(--surface-0); color: var(--text-primary); }
|
||||
body {
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.micro {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.section-heading {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
/* Corner brackets — every major panel */
|
||||
.bracket { position: relative; }
|
||||
.bracket::before, .bracket::after,
|
||||
.bracket > .br::before, .bracket > .br::after {
|
||||
content: ''; position: absolute; width: 8px; height: 8px;
|
||||
border-color: var(--accent-amber); border-style: solid; border-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.bracket::before { top: -1px; left: -1px; border-top-width: 1px; border-left-width: 1px; }
|
||||
.bracket::after { top: -1px; right: -1px; border-top-width: 1px; border-right-width: 1px; }
|
||||
.bracket > .br::before { bottom: -1px; left: -1px; border-bottom-width: 1px; border-left-width: 1px; }
|
||||
.bracket > .br::after { bottom: -1px; right: -1px; border-bottom-width: 1px; border-right-width: 1px; }
|
||||
|
||||
.panel {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border-hair);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.inp {
|
||||
width: 100%;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-hair);
|
||||
border-radius: 2px;
|
||||
padding: 6px 10px;
|
||||
height: 32px;
|
||||
color: var(--text-primary);
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
.inp:focus {
|
||||
border-color: var(--accent-amber);
|
||||
box-shadow: 0 0 0 1px var(--accent-amber);
|
||||
}
|
||||
.inp.mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
||||
.inp::placeholder { color: var(--text-muted); }
|
||||
|
||||
/* Path input with folder-icon prefix */
|
||||
.path-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.path-wrap .icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: var(--text-muted);
|
||||
display: flex; align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.path-wrap .inp {
|
||||
padding-left: 30px;
|
||||
padding-right: 70px;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.10em;
|
||||
text-transform: uppercase;
|
||||
padding: 7px 14px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background .12s, color .12s, border-color .12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent-amber);
|
||||
color: #0A0D10;
|
||||
border-color: var(--accent-amber);
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(1.05); }
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--accent-amber);
|
||||
border-color: var(--accent-amber);
|
||||
}
|
||||
.btn-secondary:hover { background: rgba(255,157,61,0.12); }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-hair);
|
||||
}
|
||||
.btn-ghost:hover { 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 { background: rgba(255,71,86,0.08); border-color: var(--accent-red); }
|
||||
|
||||
/* Chips for aircraft type */
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid;
|
||||
background: transparent;
|
||||
}
|
||||
.chip .dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.chip-blue { color: var(--accent-blue); border-color: rgba(78,158,255,0.45); }
|
||||
.chip-blue .dot { background: var(--accent-blue); }
|
||||
.chip-green { color: var(--accent-green); border-color: rgba(61,220,132,0.45); }
|
||||
.chip-green .dot { background: var(--accent-green); }
|
||||
|
||||
/* Segmented language pills */
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border-hair);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-input);
|
||||
}
|
||||
.seg button {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
padding: 7px 18px;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.seg button + button { border-left: 1px solid var(--border-hair); }
|
||||
.seg button:hover { color: var(--text-primary); }
|
||||
.seg button.active {
|
||||
background: var(--accent-amber);
|
||||
color: #0A0D10;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Stars for default aircraft */
|
||||
.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); }
|
||||
|
||||
/* Table */
|
||||
table.ac { width: 100%; border-collapse: collapse; }
|
||||
table.ac thead th {
|
||||
text-align: left;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-hair);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
table.ac tbody td {
|
||||
padding: 0 14px;
|
||||
height: 38px;
|
||||
border-bottom: 1px solid var(--border-hair);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
table.ac tbody tr:last-child td { border-bottom: 0; }
|
||||
table.ac tbody tr:hover td { background: var(--surface-2); }
|
||||
table.ac td.model { font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
||||
table.ac td.center { text-align: center; }
|
||||
|
||||
/* Header */
|
||||
.topbar { height: 48px; border-bottom: 1px solid var(--border-hair); background: var(--surface-1); }
|
||||
.logo {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 700;
|
||||
color: var(--accent-amber);
|
||||
letter-spacing: 0.20em;
|
||||
font-size: 14px;
|
||||
}
|
||||
.flight-pill {
|
||||
height: 28px;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--accent-amber);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-primary);
|
||||
border-radius: 2px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.10em;
|
||||
}
|
||||
.tab {
|
||||
display: inline-flex; align-items: center;
|
||||
height: 48px; padding: 0 14px;
|
||||
font: 500 12px/1 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.10em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab:hover { color: var(--text-primary); }
|
||||
.tab.active { color: var(--text-primary); border-bottom-color: var(--accent-amber); font-weight: 500; }
|
||||
|
||||
/* Live dot for status */
|
||||
.live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-cyan); display: inline-block; animation: pulse 1.6s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .5; transform: scale(.85); } }
|
||||
|
||||
/* Icon buttons in header */
|
||||
.ibtn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px;
|
||||
border: 1px solid var(--border-hair); border-radius: 2px;
|
||||
color: var(--text-secondary); background: transparent;
|
||||
transition: color .12s, border-color .12s, background-color .12s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ibtn:hover { color: var(--text-primary); border-color: var(--border-raised); background: var(--surface-1); }
|
||||
.ibtn.active { color: var(--accent-amber); border-color: var(--accent-amber); background: rgba(255,157,61,0.08); }
|
||||
.ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent-red); background: rgba(255,71,86,0.08); }
|
||||
|
||||
.hairline { background: var(--border-hair); }
|
||||
|
||||
/* Sticky footer */
|
||||
.footer-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(10,13,16,0) 0%, var(--surface-0) 50%);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: var(--surface-0); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-hair); border-radius: 0; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-raised); }
|
||||
|
||||
/* Tiny readout label rows */
|
||||
.field-label {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.field-hint {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Unit suffix overlay for numeric inputs */
|
||||
.num-wrap { position: relative; }
|
||||
.num-wrap .suffix {
|
||||
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.num-wrap .inp { padding-right: 36px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
|
||||
<!-- ============ TOP BAR ============ -->
|
||||
<header class="topbar flex items-center px-4 gap-3 shrink-0">
|
||||
<div class="logo">AZAION</div>
|
||||
|
||||
<span class="micro" style="color: var(--text-muted);">//</span>
|
||||
|
||||
<button class="flight-pill">
|
||||
<span class="live-dot"></span>
|
||||
<span class="mono" style="color: var(--text-primary);">FL-03</span>
|
||||
<span style="color: var(--text-secondary); font-size: 10px;">▾</span>
|
||||
</button>
|
||||
|
||||
<nav class="flex items-center self-stretch ml-3">
|
||||
<a href="flights.html" class="tab flex items-center">Flights</a>
|
||||
<a href="annotations.html" class="tab flex items-center">Annotations</a>
|
||||
<a href="dataset_explorer.html" class="tab flex items-center">Dataset</a>
|
||||
<a href="admin.html" class="tab flex items-center">Admin</a>
|
||||
</nav>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2" style="font: 500 10px/1.4 'JetBrains Mono', monospace; letter-spacing: 0.12em; text-transform: uppercase;">
|
||||
<span class="live-dot"></span>
|
||||
<span style="color: var(--accent-cyan);">LINK</span>
|
||||
<span style="color: var(--border-raised);">|</span>
|
||||
<span style="color: var(--text-secondary); text-transform: none; letter-spacing: 0;">user@azaion.com</span>
|
||||
<span style="color: var(--border-raised); margin: 0 4px;">|</span>
|
||||
<a href="settings.html" class="ibtn active" title="Settings">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
</a>
|
||||
<a href="#" class="ibtn danger" title="Sign out">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ============ MAIN GRID ============ -->
|
||||
<main class="flex-1 pt-5 px-6 pb-6 flex flex-col gap-5 overflow-y-auto">
|
||||
|
||||
<!-- ROW 1: Tenant / Directories / Aircrafts -->
|
||||
<section class="flex gap-5 items-start">
|
||||
|
||||
<!-- TENANT CONFIGURATION -->
|
||||
<div class="w-[300px] shrink-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="section-heading">TENANT CONFIGURATION</span>
|
||||
<span class="micro">01</span>
|
||||
</div>
|
||||
<div class="bracket panel p-4">
|
||||
<div class="space-y-3">
|
||||
|
||||
<div>
|
||||
<div class="field-label">
|
||||
<label class="micro">Military Unit</label>
|
||||
<span class="mono text-[9px] text-[var(--text-muted)]">REQ</span>
|
||||
</div>
|
||||
<input class="inp" type="text" value="72nd Mechanized Brigade">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="field-label">
|
||||
<label class="micro">Name</label>
|
||||
</div>
|
||||
<input class="inp" type="text" value="Alpha Company">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div class="field-label">
|
||||
<label class="micro">Cam Width</label>
|
||||
<span class="mono text-[9px] text-[var(--text-muted)]">PX</span>
|
||||
</div>
|
||||
<div class="num-wrap">
|
||||
<input class="inp mono num" type="text" value="1920">
|
||||
<span class="suffix">px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">
|
||||
<label class="micro">Cam FoV</label>
|
||||
<span class="mono text-[9px] text-[var(--text-muted)]">DEG</span>
|
||||
</div>
|
||||
<div class="num-wrap">
|
||||
<input class="inp mono num" type="text" value="84.0">
|
||||
<span class="suffix">°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIRECTORIES -->
|
||||
<div class="w-[340px] shrink-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="section-heading">DIRECTORIES</span>
|
||||
<span class="micro">02</span>
|
||||
</div>
|
||||
<div class="bracket panel p-4">
|
||||
<div class="space-y-3">
|
||||
|
||||
<div>
|
||||
<div class="field-label">
|
||||
<label class="micro">Images Dir</label>
|
||||
<span class="mono text-[9px] text-[var(--accent-green)]">MOUNTED</span>
|
||||
</div>
|
||||
<div class="path-wrap">
|
||||
<span class="icon">
|
||||
<!-- folder icon -->
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
</span>
|
||||
<input class="inp mono" type="text" value="/data/azaion/images">
|
||||
<button class="browse" type="button">Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="field-label">
|
||||
<label class="micro">Labels Dir</label>
|
||||
<span class="mono text-[9px] text-[var(--accent-green)]">MOUNTED</span>
|
||||
</div>
|
||||
<div class="path-wrap">
|
||||
<span class="icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
</span>
|
||||
<input class="inp mono" type="text" value="/data/azaion/labels">
|
||||
<button class="browse" type="button">Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="field-label">
|
||||
<label class="micro">Thumbnails Dir</label>
|
||||
<span class="mono text-[9px] text-[var(--accent-amber)]">CACHE</span>
|
||||
</div>
|
||||
<div class="path-wrap">
|
||||
<span class="icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
</span>
|
||||
<input class="inp mono" type="text" value="/var/cache/azaion/thumbs">
|
||||
<button class="browse" type="button">Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 pt-3 border-t border-[var(--border-hair)] flex items-center justify-between">
|
||||
<span class="micro">Storage Free</span>
|
||||
<span class="mono num text-[11px] text-[var(--text-primary)]">412.8 / 960.0 GB</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AIRCRAFTS -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="section-heading">AIRCRAFTS</span>
|
||||
<span class="micro">03</span>
|
||||
<span class="mono text-[10px] text-[var(--text-muted)]">· 4 REGISTERED</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button">
|
||||
<span class="text-[14px] leading-none">+</span>
|
||||
<span>Add Aircraft</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bracket panel overflow-hidden">
|
||||
<table class="ac">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-[44%]">Model</th>
|
||||
<th>Type</th>
|
||||
<th class="text-center w-24">Default</th>
|
||||
<th class="w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="model">DJI Mavic 3 Enterprise</td>
|
||||
<td><span class="chip chip-green"><span class="dot"></span>Copter</span></td>
|
||||
<td class="center"><button class="star active" title="Default">★</button></td>
|
||||
<td class="center"><span class="mono text-[var(--text-muted)]">⋯</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="model">Matrice 300 RTK</td>
|
||||
<td><span class="chip chip-green"><span class="dot"></span>Copter</span></td>
|
||||
<td class="center"><button class="star" title="Set default">☆</button></td>
|
||||
<td class="center"><span class="mono text-[var(--text-muted)]">⋯</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="model">Fixed-Wing Scout Mk.II</td>
|
||||
<td><span class="chip chip-blue"><span class="dot"></span>Plane</span></td>
|
||||
<td class="center"><button class="star" title="Set default">☆</button></td>
|
||||
<td class="center"><span class="mono text-[var(--text-muted)]">⋯</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="model">Leleka-100</td>
|
||||
<td><span class="chip chip-blue"><span class="dot"></span>Plane</span></td>
|
||||
<td class="center"><button class="star" title="Set default">☆</button></td>
|
||||
<td class="center"><span class="mono text-[var(--text-muted)]">⋯</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- ROW 2: Language + Session -->
|
||||
<section class="flex gap-5 items-start">
|
||||
|
||||
<!-- LANGUAGE -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="section-heading">LANGUAGE</span>
|
||||
<span class="micro">04</span>
|
||||
</div>
|
||||
<span class="micro">Locale · <span class="text-[var(--text-primary)]">EN-US</span></span>
|
||||
</div>
|
||||
<div class="bracket panel p-4">
|
||||
<div class="flex items-center gap-6 flex-wrap">
|
||||
<div class="seg" role="tablist">
|
||||
<button class="active" type="button">EN</button>
|
||||
<button type="button">UA</button>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="micro">Affects all UI text</span>
|
||||
<span class="mono text-[10px] text-[var(--text-muted)] mt-1">Detection class names also use the localized field from seed data.</span>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2 mono text-[10px] text-[var(--text-muted)]">
|
||||
<span class="live-dot" style="background:var(--accent-green)"></span>
|
||||
<span>i18n BUNDLE <span class="text-[var(--text-secondary)] num">v2.4.1</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SESSION -->
|
||||
<div class="w-[380px] shrink-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="section-heading">SESSION</span>
|
||||
<span class="micro">05</span>
|
||||
</div>
|
||||
<span class="micro text-[var(--accent-cyan)]">ACTIVE</span>
|
||||
</div>
|
||||
<div class="bracket panel p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="micro">Last Login</span>
|
||||
<span class="mono num text-[12px] text-[var(--text-primary)] mt-1">2026-05-16 · 08:42:11 UTC</span>
|
||||
<span class="mono text-[10px] text-[var(--text-muted)] mt-0.5 truncate">SRC 10.42.13.7 · TOKEN …f3a9c1</span>
|
||||
</div>
|
||||
<button class="btn btn-danger-ghost shrink-0" type="button">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
Sign out everywhere
|
||||
</button>
|
||||
</div>
|
||||
<span class="br"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- ============ STICKY FOOTER ============ -->
|
||||
<div class="footer-bar mt-auto">
|
||||
<div class="flex items-center gap-4 pt-4 border-t border-[var(--border-hair)]">
|
||||
<div class="flex items-center gap-2 mono text-[10px] text-[var(--text-muted)] uppercase tracking-[0.14em]">
|
||||
<span class="live-dot"></span>
|
||||
<span>Unsaved changes detected in <span class="text-[var(--accent-amber)]">TENANT</span></span>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<button class="btn btn-ghost" type="button">Cancel</button>
|
||||
<button class="btn btn-primary" type="button">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,348 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>AZAION TACTICAL OPS - ADMIN</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface0: "#0A0D10",
|
||||
surface1: "#13171C",
|
||||
surface2: "#1A1F26",
|
||||
hairline: "#252B34",
|
||||
raised: "#3B4451",
|
||||
amber: "#FF9D3D",
|
||||
cyan: "#36D6C5",
|
||||
red: "#FF4756",
|
||||
green: "#3DDC84",
|
||||
blue: "#4E9EFF",
|
||||
textPrimary: "#E8ECF1",
|
||||
textSecondary: "#9AA4B2",
|
||||
textMuted: "#5B6573"
|
||||
},
|
||||
fontFamily: {
|
||||
headline: ["JetBrains Mono", "monospace"],
|
||||
mono: ["JetBrains Mono", "monospace"],
|
||||
body: ["IBM Plex Sans", "sans-serif"]
|
||||
},
|
||||
letterSpacing: {
|
||||
micro: "0.12em"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #0A0D10;
|
||||
color: #E8ECF1;
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
.mono-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.bracket {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-color: #3B4451;
|
||||
}
|
||||
.bracket-tl { top: -1px; left: -1px; border-top: 1px solid; border-left: 1px solid; }
|
||||
.bracket-tr { top: -1px; right: -1px; border-top: 1px solid; border-right: 1px solid; }
|
||||
.bracket-bl { bottom: -1px; left: -1px; border-bottom: 1px solid; border-left: 1px solid; }
|
||||
.bracket-br { bottom: -1px; right: -1px; border-bottom: 1px solid; border-right: 1px solid; }
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex flex-col h-screen overflow-hidden">
|
||||
<!-- TopAppBar -->
|
||||
<header class="h-12 flex justify-between items-center px-4 z-50 bg-[#0A0D10] border-b border-[#252B34]">
|
||||
<div class="flex items-center gap-6">
|
||||
<span class="font-headline font-bold text-lg tracking-widest text-[#FF9D3D]">AZAION</span>
|
||||
<div class="flex items-center gap-1 px-2 py-1 bg-surface2 border border-hairline rounded cursor-pointer">
|
||||
<span class="mono-label text-amber">FL02</span>
|
||||
<span class="material-symbols-outlined text-amber">arrow_drop_down</span>
|
||||
</div>
|
||||
<nav class="flex gap-6 h-full">
|
||||
<a class="text-[#9AA4B2] font-mono text-[10px] tracking-[0.12em] hover:text-[#E8ECF1] flex items-center h-full" href="#">FLIGHTS</a>
|
||||
<a class="text-[#9AA4B2] font-mono text-[10px] tracking-[0.12em] hover:text-[#E8ECF1] flex items-center h-full" href="#">ANNOTATIONS</a>
|
||||
<a class="text-[#9AA4B2] font-mono text-[10px] tracking-[0.12em] hover:text-[#E8ECF1] flex items-center h-full" href="#">DATASET</a>
|
||||
<a class="text-[#FF9D3D] border-b-2 border-[#FF9D3D] pb-1 font-mono text-[10px] tracking-[0.12em] flex items-center h-full mt-[2px]" href="#">ADMIN</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative w-64">
|
||||
<input class="w-full bg-surface0 border border-hairline h-8 px-8 mono-label focus:border-amber focus:ring-0" placeholder="GLOBAL_SEARCH" type="text"/>
|
||||
<span class="material-symbols-outlined absolute left-2 top-1.5 text-textMuted">search</span>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-textSecondary hover:text-amber cursor-pointer">notifications</span>
|
||||
<span class="material-symbols-outlined text-textSecondary hover:text-amber cursor-pointer">settings</span>
|
||||
<div class="w-8 h-8 rounded-full bg-surface2 border border-hairline overflow-hidden">
|
||||
<img alt="OPERATOR_AVATAR" class="w-full h-full object-cover" data-alt="A professional headshot of a focused military drone operator in a high-tech control room environment. The lighting is low-key with cool blue and cyan accents reflected on his face from nearby monitors. He wears a tactical dark uniform. The aesthetic is clean, sharp, and highly technical, fitting a mission-critical command center atmosphere." src="https://lh3.googleusercontent.com/aida-public/AB6AXuBU5gvFwmb64UKSwL3Ij5pvazF60_m-h5ToNkDk0ZxBh-lKJJ_zcYTnt8CXFwykIaNV9ixI4LGYLsLBAZ_fXJ50IKjvIXutgApi3PcZHqYlJ_G9g7uArAAB1aY_2w3kTzJZQt1LeIu_8Tq5tBbmTkvt5noMKmA1bYt9TsAOLG8p4Xf-Hr0n0Vtd90FS4BI2-oIIzchTu-7Q-kw7XNzVlMJmIUs4dxQuznF-lVTHx5yfQttz8VjA2iAuimfey1NfHoid9LeeOtCHxzKe"/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex flex-1 overflow-hidden">
|
||||
<!-- LEFT COLUMN: DETECTION CLASSES -->
|
||||
<aside class="w-[340px] border-r border-hairline bg-surface1 flex flex-col">
|
||||
<div class="p-4 border-b border-hairline flex justify-between items-center">
|
||||
<h2 class="mono-label font-bold text-textPrimary">DETECTION CLASSES</h2>
|
||||
<button class="bg-amber text-surface0 px-3 py-1.5 rounded-sm mono-label font-bold hover:opacity-90 active:scale-95 transition-all">
|
||||
+ ADD CLASS
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 border-b border-hairline">
|
||||
<div class="relative">
|
||||
<input class="w-full bg-surface0 border border-hairline h-8 px-8 mono-label focus:border-amber focus:ring-0" placeholder="SEARCH_CLASSES..." type="text"/>
|
||||
<span class="material-symbols-outlined absolute left-2 top-1.5 text-textMuted text-sm">filter_list</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<tbody class="mono-label tabular-nums">
|
||||
<!-- Rows -->
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group px-4">
|
||||
<td class="pl-4 text-textMuted w-12">00</td>
|
||||
<td class="text-textPrimary">ArmorVehicle</td>
|
||||
<td class="w-8"><div class="w-3 h-3 bg-red"></div></td>
|
||||
<td class="pr-4 text-right opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span class="material-symbols-outlined text-textMuted hover:text-amber cursor-pointer mr-2">edit</span>
|
||||
<span class="material-symbols-outlined text-textMuted hover:text-red cursor-pointer">delete</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- TRUCK (Inline Edit Mode) -->
|
||||
<tr class="h-10 border-b border-hairline bg-surface2 border-l-2 border-l-amber">
|
||||
<td class="pl-4 text-amber w-12">01</td>
|
||||
<td>
|
||||
<input class="bg-surface0 border border-amber h-7 px-2 text-textPrimary text-[10px] w-32 mono-label focus:ring-0" type="text" value="Truck"/>
|
||||
</td>
|
||||
<td class="w-8"><div class="w-3 h-3 bg-amber"></div></td>
|
||||
<td class="pr-4 text-right">
|
||||
<span class="material-symbols-outlined text-amber cursor-pointer mr-2">check</span>
|
||||
<span class="material-symbols-outlined text-textMuted cursor-pointer">close</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Rest of the 19 rows -->
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">02</td><td class="text-textPrimary">Vehicle</td><td><div class="w-3 h-3 bg-blue"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">03</td><td class="text-textPrimary">Artillery</td><td><div class="w-3 h-3 bg-cyan"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">04</td><td class="text-textPrimary">Shadow</td><td><div class="w-3 h-3 bg-raised"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">05</td><td class="text-textPrimary">Trenches</td><td><div class="w-3 h-3 bg-textMuted"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">06</td><td class="text-textPrimary">MilitaryMan</td><td><div class="w-3 h-3 bg-green"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">07</td><td class="text-textPrimary">TyreTracks</td><td><div class="w-3 h-3 bg-raised"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">08</td><td class="text-textPrimary">AdditionArmoredTank</td><td><div class="w-3 h-3 bg-red"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">09</td><td class="text-textPrimary">Smoke</td><td><div class="w-3 h-3 bg-white"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">10</td><td class="text-textPrimary">Plane</td><td><div class="w-3 h-3 bg-blue"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">11</td><td class="text-textPrimary">Moto</td><td><div class="w-3 h-3 bg-amber"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">12</td><td class="text-textPrimary">CamouflageNet</td><td><div class="w-3 h-3 bg-green"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">13</td><td class="text-textPrimary">CamouflageBranches</td><td><div class="w-3 h-3 bg-green"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">14</td><td class="text-textPrimary">Roof</td><td><div class="w-3 h-3 bg-textSecondary"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">15</td><td class="text-textPrimary">Building</td><td><div class="w-3 h-3 bg-textSecondary"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">16</td><td class="text-textPrimary">Caponier</td><td><div class="w-3 h-3 bg-raised"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">17</td><td class="text-textPrimary">Ammo</td><td><div class="w-3 h-3 bg-red"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
<tr class="h-10 border-b border-hairline hover:bg-surface2 group"><td class="pl-4 text-textMuted">18</td><td class="text-textPrimary">Protect.Struct</td><td><div class="w-3 h-3 bg-cyan"></div></td><td class="pr-4 text-right opacity-0 group-hover:opacity-100"><span class="material-symbols-outlined text-textMuted mr-2">edit</span><span class="material-symbols-outlined text-textMuted">delete</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- CENTER COLUMN: MAIN SETTINGS -->
|
||||
<section class="flex-1 overflow-y-auto bg-surface0 p-6 flex flex-col gap-6">
|
||||
<!-- AI RECOGNITION SETTINGS -->
|
||||
<div class="bg-surface1 border border-hairline p-6 relative">
|
||||
<div class="bracket bracket-tl"></div><div class="bracket bracket-tr"></div><div class="bracket bracket-bl"></div><div class="bracket bracket-br"></div>
|
||||
<h3 class="mono-label text-textPrimary font-bold mb-6 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-amber">psychology</span>
|
||||
AI RECOGNITION SETTINGS
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="flex justify-between items-center border-b border-hairline pb-4">
|
||||
<span class="mono-label text-textSecondary"># FRAMES_PER_SEC</span>
|
||||
<input class="w-16 bg-surface0 border border-hairline h-8 px-2 mono-label text-right focus:border-amber focus:ring-0" type="number" value="4"/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center border-b border-hairline pb-4">
|
||||
<span class="mono-label text-textSecondary">MIN_SECONDS</span>
|
||||
<input class="w-16 bg-surface0 border border-hairline h-8 px-2 mono-label text-right focus:border-amber focus:ring-0" type="number" value="2"/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center border-b border-hairline pb-4">
|
||||
<span class="mono-label text-textSecondary">MIN_CONFIDENCE</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<input class="w-16 bg-surface0 border border-hairline h-8 px-2 mono-label text-right focus:border-amber focus:ring-0" type="number" value="25"/>
|
||||
<span class="mono-label text-textMuted">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- GPS DEVICE SETTINGS -->
|
||||
<div class="bg-surface1 border border-hairline p-6 relative">
|
||||
<div class="bracket bracket-tl"></div><div class="bracket bracket-tr"></div><div class="bracket bracket-bl"></div><div class="bracket bracket-br"></div>
|
||||
<h3 class="mono-label text-textPrimary font-bold mb-6 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-cyan">location_on</span>
|
||||
GPS DEVICE SETTINGS
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="mono-label text-textMuted">IP_ADDRESS</span>
|
||||
<input class="w-full bg-surface0 border border-hairline h-8 px-3 mono-label focus:border-amber focus:ring-0 tabular-nums" type="text" value="192.168.1.100"/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="mono-label text-textMuted">PORT</span>
|
||||
<input class="w-full bg-surface0 border border-hairline h-8 px-3 mono-label focus:border-amber focus:ring-0 tabular-nums" type="text" value="9001"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="mono-label text-textMuted">PROTOCOL_SELECTION</span>
|
||||
<div class="flex gap-2">
|
||||
<button class="bg-amber text-surface0 px-4 py-1.5 mono-label font-bold border border-amber">NMEA</button>
|
||||
<button class="bg-surface0 text-textSecondary px-4 py-1.5 mono-label border border-hairline hover:border-raised">UBX</button>
|
||||
<button class="bg-surface0 text-textSecondary px-4 py-1.5 mono-label border border-hairline hover:border-raised">MAVLINK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- USER MANAGEMENT -->
|
||||
<div class="bg-surface1 border border-hairline flex-1 relative flex flex-col min-h-[300px]">
|
||||
<div class="bracket bracket-tl"></div><div class="bracket bracket-tr"></div><div class="bracket bracket-bl"></div><div class="bracket bracket-br"></div>
|
||||
<div class="p-6 border-b border-hairline flex justify-between items-center">
|
||||
<h3 class="mono-label text-textPrimary font-bold flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-textMuted">group</span>
|
||||
USER MANAGEMENT
|
||||
</h3>
|
||||
<button class="border border-amber text-amber px-3 py-1.5 rounded-sm mono-label hover:bg-amber/10 transition-all">
|
||||
+ CREATE USER
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr class="bg-surface2 border-b border-hairline">
|
||||
<th class="px-6 py-3 mono-label text-textMuted font-medium">NAME</th>
|
||||
<th class="px-6 py-3 mono-label text-textMuted font-medium">EMAIL</th>
|
||||
<th class="px-6 py-3 mono-label text-textMuted font-medium">ROLE</th>
|
||||
<th class="px-6 py-3 mono-label text-textMuted font-medium text-right">STATUS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-hairline mono-label">
|
||||
<tr class="hover:bg-surface2 transition-colors">
|
||||
<td class="px-6 py-3 text-textPrimary">COMMANDER_ALPHA</td>
|
||||
<td class="px-6 py-3 text-textSecondary">alpha@azaion.mil</td>
|
||||
<td class="px-6 py-3">
|
||||
<span class="px-2 py-0.5 border border-red text-red rounded-full text-[9px]">ADMIN</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green mr-1"></span>
|
||||
<span class="text-green">ONLINE</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-surface2 transition-colors">
|
||||
<td class="px-6 py-3 text-textPrimary">OPERATOR_72</td>
|
||||
<td class="px-6 py-3 text-textSecondary">op72@azaion.mil</td>
|
||||
<td class="px-6 py-3">
|
||||
<span class="px-2 py-0.5 border border-amber text-amber rounded-full text-[9px]">OPERATOR</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green mr-1"></span>
|
||||
<span class="text-green">ONLINE</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-surface2 transition-colors">
|
||||
<td class="px-6 py-3 text-textPrimary">ANALYST_KAPPA</td>
|
||||
<td class="px-6 py-3 text-textSecondary">kappa@azaion.mil</td>
|
||||
<td class="px-6 py-3">
|
||||
<span class="px-2 py-0.5 border border-hairline text-textMuted rounded-full text-[9px]">VIEWER</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-textMuted mr-1"></span>
|
||||
<span class="text-textMuted">OFFLINE</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- RIGHT COLUMN: DEFAULT AIRCRAFTS -->
|
||||
<aside class="w-[280px] border-l border-hairline bg-surface1 flex flex-col">
|
||||
<div class="p-4 border-b border-hairline">
|
||||
<h2 class="mono-label font-bold text-textPrimary">DEFAULT AIRCRAFTS</h2>
|
||||
</div>
|
||||
<div class="flex-1 p-2 flex flex-col gap-2">
|
||||
<!-- Aircraft Rows -->
|
||||
<div class="bg-surface2 border border-hairline p-3 group relative hover:border-raised transition-all cursor-pointer">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span class="px-1.5 py-0.5 bg-blue text-surface0 text-[9px] font-bold rounded-sm">P</span>
|
||||
<span class="material-symbols-outlined text-amber tabular-nums" style="font-variation-settings: 'FILL' 1;">star</span>
|
||||
</div>
|
||||
<div class="mono-label text-textPrimary font-bold mb-1">REAPER-MQ9</div>
|
||||
<div class="mono-label text-[9px] text-textMuted uppercase tracking-wider">LONG_RANGE_STRIKE</div>
|
||||
</div>
|
||||
<div class="bg-surface2 border border-hairline p-3 group relative hover:border-raised transition-all cursor-pointer">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span class="px-1.5 py-0.5 bg-green text-surface0 text-[9px] font-bold rounded-sm">C</span>
|
||||
<span class="material-symbols-outlined text-textMuted tabular-nums">star</span>
|
||||
</div>
|
||||
<div class="mono-label text-textPrimary font-bold mb-1">MAVIC_3_PRO</div>
|
||||
<div class="mono-label text-[9px] text-textMuted uppercase tracking-wider">TACTICAL_RECON</div>
|
||||
</div>
|
||||
<div class="bg-surface2 border border-hairline p-3 group relative hover:border-raised transition-all cursor-pointer">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span class="px-1.5 py-0.5 bg-amber text-surface0 text-[9px] font-bold rounded-sm">F</span>
|
||||
<span class="material-symbols-outlined text-textMuted tabular-nums">star</span>
|
||||
</div>
|
||||
<div class="mono-label text-textPrimary font-bold mb-1">SWITCHBLADE_600</div>
|
||||
<div class="mono-label text-[9px] text-textMuted uppercase tracking-wider">LOITERING_MUNITION</div>
|
||||
</div>
|
||||
<button class="w-full mt-4 border border-dashed border-hairline py-4 mono-label text-textMuted hover:text-amber hover:border-amber transition-all">
|
||||
+ ADD AIRCRAFT
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 mt-auto border-t border-hairline bg-surface0/50">
|
||||
<div class="flex justify-between items-center mono-label text-[9px] mb-2">
|
||||
<span class="text-textMuted">SYSTEM_STATUS</span>
|
||||
<span class="text-green">OPTIMAL</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mono-label text-[9px] mb-2">
|
||||
<span class="text-textMuted">STORAGE_USE</span>
|
||||
<span class="text-textPrimary">42.8 GB / 100 GB</span>
|
||||
</div>
|
||||
<div class="w-full bg-surface2 h-1 rounded-full overflow-hidden">
|
||||
<div class="bg-amber h-full w-[42%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
<!-- Footer Bar / Status -->
|
||||
<footer class="h-6 bg-surface2 border-t border-hairline flex items-center justify-between px-4">
|
||||
<div class="flex gap-4">
|
||||
<span class="mono-label text-[8px] text-textMuted">LAT: 48.8584° N</span>
|
||||
<span class="mono-label text-[8px] text-textMuted">LON: 2.2945° E</span>
|
||||
<span class="mono-label text-[8px] text-textMuted">ALT: 1,420M MSL</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="mono-label text-[8px] text-cyan flex items-center gap-1">
|
||||
<span class="w-1.5 h-1.5 bg-cyan rounded-full"></span>
|
||||
LIVE_FEED_SYNCED
|
||||
</span>
|
||||
<span class="mono-label text-[8px] text-textMuted">VER: 2.4.0-STABLE</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body></html>
|
||||
@@ -0,0 +1,389 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>AZAION - ANNOTATIONS MISSION CONTROL</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=IBM+Plex+Sans:wght@300;400;600&family=Public+Sans:wght@400;700;900&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: {
|
||||
0: "#0A0D10",
|
||||
1: "#13171C",
|
||||
2: "#1A1F26"
|
||||
},
|
||||
hairline: "#252B34",
|
||||
raised: "#3B4451",
|
||||
amber: "#FF9D3D",
|
||||
cyan: "#36D6C5",
|
||||
red: "#FF4756",
|
||||
green: "#3DDC84",
|
||||
blue: "#4E9EFF",
|
||||
onSurface: "#E8ECF1",
|
||||
onSurfaceMuted: "#9AA4B2",
|
||||
onSurfaceDim: "#5B6573"
|
||||
},
|
||||
borderRadius: {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
fontFamily: {
|
||||
headline: ["JetBrains Mono", "monospace"],
|
||||
mono: ["JetBrains Mono", "monospace"],
|
||||
body: ["IBM Plex Sans", "sans-serif"],
|
||||
display: ["Public Sans", "sans-serif"],
|
||||
label: ["JetBrains Mono", "monospace"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
font-size: 18px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tabular-nums { font-variant-numeric: tabular-nums; }
|
||||
.grid-overlay {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255,255,255,0.03) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
.corner-br-tl { position: absolute; top: 0; left: 0; width: 8px; height: 8px; border-top: 1px solid #FF9D3D; border-left: 1px solid #FF9D3D; }
|
||||
.corner-br-tr { position: absolute; top: 0; right: 0; width: 8px; height: 8px; border-top: 1px solid #FF9D3D; border-right: 1px solid #FF9D3D; }
|
||||
.corner-br-bl { position: absolute; bottom: 0; left: 0; width: 8px; height: 8px; border-bottom: 1px solid #FF9D3D; border-left: 1px solid #FF9D3D; }
|
||||
.corner-br-br { position: absolute; bottom: 0; right: 0; width: 8px; height: 8px; border-bottom: 1px solid #FF9D3D; border-right: 1px solid #FF9D3D; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface-0 text-onSurface font-body selection:bg-amber selection:text-surface-0">
|
||||
<!-- TOP APP BAR -->
|
||||
<header class="flex justify-between items-center w-full px-4 h-12 z-50 bg-surface-0 border-b border-hairline sticky top-0">
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-headline font-bold text-lg tracking-widest text-amber">AZAION</span>
|
||||
<div class="flex items-center bg-surface-1 border border-hairline px-2 py-0.5 rounded gap-2 hover:bg-surface-2 cursor-pointer transition-colors">
|
||||
<span class="font-mono text-[10px] tracking-[0.12em] text-cyan">FL03</span>
|
||||
<span class="material-symbols-outlined text-onSurfaceMuted text-xs">arrow_drop_down</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex h-full items-center gap-6">
|
||||
<a class="text-onSurfaceMuted font-mono text-[10px] tracking-[0.12em] hover:text-onSurface transition-colors" href="#">FLIGHTS</a>
|
||||
<a class="text-amber border-b-2 border-amber pb-1 font-mono text-[10px] tracking-[0.12em]" href="#">ANNOTATIONS</a>
|
||||
<a class="text-onSurfaceMuted font-mono text-[10px] tracking-[0.12em] hover:text-onSurface transition-colors" href="#">DATASET</a>
|
||||
<a class="text-onSurfaceMuted font-mono text-[10px] tracking-[0.12em] hover:text-onSurface transition-colors" href="#">ADMIN</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="material-symbols-outlined text-onSurfaceMuted hover:text-amber transition-colors">notifications</button>
|
||||
<button class="material-symbols-outlined text-onSurfaceMuted hover:text-amber transition-colors">settings</button>
|
||||
</div>
|
||||
<div class="h-8 w-8 rounded-full border border-hairline overflow-hidden">
|
||||
<img alt="OPERATOR_AVATAR" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuASYqj8bWeEeCca3bmY7NxlGYCVcmdnDq3yHr_pfZTBas40iXPGGKH9abX9DL_udecDU2eIzbJ8XUvC59UxCerboKPAY33bxx8skyI6h4wuSW7R-PwRrOUAsU9v_yb6cLJAXxMHrIKdFoOPnSG-7ABapnWZNPrC2j95duK6YKey-O8E6cFlE1zVZVqHyemxjiI8oc7x73Fv8W64PvBPzgzVDBw6kYjiaNtdbO5jhoai44fer1uuD3ExqtUErNwL-BYI_qzO00RgvEO2"/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex h-[calc(100vh-48px)] overflow-hidden">
|
||||
<!-- LEFT SIDEBAR: MEDIA FILES & CLASSES -->
|
||||
<aside class="w-[250px] bg-surface-1 border-r border-hairline flex flex-col shrink-0 overflow-y-auto">
|
||||
<!-- MEDIA FILES SECTION -->
|
||||
<section class="p-4 border-b border-hairline relative">
|
||||
<div class="corner-br-tl"></div>
|
||||
<div class="corner-br-tr"></div>
|
||||
<h3 class="font-headline text-[10px] tracking-[0.12em] text-onSurfaceDim uppercase mb-4">MEDIA FILES</h3>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-surface-2 transition-colors cursor-pointer text-xs group">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-1 border border-blue text-blue text-[8px] font-mono rounded-sm">PHOTO</span>
|
||||
<span class="font-body text-onSurfaceMuted group-hover:text-onSurface">Aerial_01</span>
|
||||
</div>
|
||||
<span class="font-mono text-[9px] text-onSurfaceDim tabular-nums">00:00</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-2 py-1.5 bg-surface-2 border-l-2 border-amber transition-colors cursor-pointer text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-1 border border-amber text-amber text-[8px] font-mono rounded-sm">VIDEO</span>
|
||||
<span class="font-body text-onSurface">Video 02</span>
|
||||
</div>
|
||||
<span class="font-mono text-[9px] text-amber tabular-nums">02:14</span>
|
||||
</div>
|
||||
<!-- Mock more rows -->
|
||||
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-surface-2 transition-colors cursor-pointer text-xs group">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-1 border border-amber text-amber text-[8px] font-mono rounded-sm">VIDEO</span>
|
||||
<span class="font-body text-onSurfaceMuted group-hover:text-onSurface">Recon_Unit_B</span>
|
||||
</div>
|
||||
<span class="font-mono text-[9px] text-onSurfaceDim tabular-nums">05:41</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-surface-2 transition-colors cursor-pointer text-xs group">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-1 border border-blue text-blue text-[8px] font-mono rounded-sm">PHOTO</span>
|
||||
<span class="font-body text-onSurfaceMuted group-hover:text-onSurface">Border_P_44</span>
|
||||
</div>
|
||||
<span class="font-mono text-[9px] text-onSurfaceDim tabular-nums">00:00</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-surface-2 transition-colors cursor-pointer text-xs group">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-1 border border-amber text-amber text-[8px] font-mono rounded-sm">VIDEO</span>
|
||||
<span class="font-body text-onSurfaceMuted group-hover:text-onSurface">Strike_Log_09</span>
|
||||
</div>
|
||||
<span class="font-mono text-[9px] text-onSurfaceDim tabular-nums">01:12</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-surface-2 transition-colors cursor-pointer text-xs group">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-1 border border-amber text-amber text-[8px] font-mono rounded-sm">VIDEO</span>
|
||||
<span class="font-body text-onSurfaceMuted group-hover:text-onSurface">Thermal_HD</span>
|
||||
</div>
|
||||
<span class="font-mono text-[9px] text-onSurfaceDim tabular-nums">00:45</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 relative">
|
||||
<input class="w-full bg-surface-0 border border-hairline text-xs font-mono px-3 py-2 focus:ring-1 focus:ring-amber focus:border-amber outline-none placeholder-onSurfaceDim text-onSurface" placeholder="SEARCH ASSETS..." type="text"/>
|
||||
<span class="material-symbols-outlined absolute right-2 top-2 text-onSurfaceDim text-sm">search</span>
|
||||
</div>
|
||||
</section>
|
||||
<!-- DETECTION CLASSES -->
|
||||
<section class="p-4 border-b border-hairline">
|
||||
<h3 class="font-headline text-[10px] tracking-[0.12em] text-onSurfaceDim uppercase mb-4">DETECTION CLASSES</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between group cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 bg-red"></div>
|
||||
<span class="text-xs font-mono text-onSurfaceMuted group-hover:text-onSurface">MilVeh</span>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono text-onSurfaceDim">1</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between group cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 bg-green"></div>
|
||||
<span class="text-xs font-mono text-onSurfaceMuted group-hover:text-onSurface">Truck</span>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono text-onSurfaceDim">2</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between group cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 bg-blue"></div>
|
||||
<span class="text-xs font-mono text-onSurfaceMuted group-hover:text-onSurface">Vehicle</span>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono text-onSurfaceDim">3</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between group cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 bg-yellow-400"></div>
|
||||
<span class="text-xs font-mono text-onSurfaceMuted group-hover:text-onSurface">Artillery</span>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono text-onSurfaceDim">4</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between group cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 bg-magenta-500 bg-fuchsia-600"></div>
|
||||
<span class="text-xs font-mono text-onSurfaceMuted group-hover:text-onSurface">Shadow</span>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono text-onSurfaceDim">5</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between group cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 bg-cyan"></div>
|
||||
<span class="text-xs font-mono text-onSurfaceMuted group-hover:text-onSurface">Trenches</span>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono text-onSurfaceDim">6</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- PHOTO MODE -->
|
||||
<section class="p-4 mt-auto">
|
||||
<h3 class="font-headline text-[10px] tracking-[0.12em] text-onSurfaceDim uppercase mb-2">PHOTOMODE</h3>
|
||||
<div class="flex border border-hairline overflow-hidden h-8">
|
||||
<button class="flex-1 bg-amber text-surface-0 font-mono text-[9px] font-bold tracking-wider">REGULAR</button>
|
||||
<button class="flex-1 bg-surface-1 text-onSurfaceDim font-mono text-[9px] border-l border-hairline hover:bg-surface-2 transition-colors">WINTER</button>
|
||||
<button class="flex-1 bg-surface-1 text-onSurfaceDim font-mono text-[9px] border-l border-hairline hover:bg-surface-2 transition-colors">NIGHT</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
<!-- MAIN VIEWER -->
|
||||
<section class="flex-1 flex flex-col bg-surface-0 relative">
|
||||
<!-- VIEWER AREA -->
|
||||
<div class="flex-1 relative overflow-hidden group cursor-crosshair">
|
||||
<img class="w-full h-full object-cover grayscale-[0.2]" data-alt="A top-down aerial satellite view of a muddy dirt track winding through a dense coniferous forest with dark green pine trees. The image has a tactical drone-feed aesthetic with a subtle digital noise overlay and a technical grid. High-contrast lighting highlights the textures of the mud and the individual needles of the evergreens. Minimalist but detailed, following a military-grade intelligence visual style." src="https://lh3.googleusercontent.com/aida-public/AB6AXuACEEDvgvY6EghK5wwUjyhV-MloxdbkAm6e6WWU6rFHfmfSM0PjLeVbyxe_oP4sk1JjaKSGE0znfRfEiW6q8WsNGvP7e5iH1eUueipOVFk8bDUFA7GdIOW3E2gxKSxc4zyv2lwVfXmABFesr8RD50odvKWtfGIS93sldZYrbZxcJ_hzEsYAVJtKGZG5rkOtcdy5AFGGHqsae8FkjhkNyR7--CHoNYgUPMsWphF6yBuS4m9Ya9QJ4o5ZsTd691ZXlE56XFDP-xuIxg9R"/>
|
||||
<div class="absolute inset-0 grid-overlay pointer-events-none"></div>
|
||||
<!-- Bounding Box 1 (Friendly/MilVeh) -->
|
||||
<div class="absolute top-[20%] left-[30%] w-[120px] h-[80px] border-2 border-cyan pointer-events-none">
|
||||
<div class="absolute -top-7 left-0 flex items-center gap-1.5 whitespace-nowrap bg-surface-0/80 px-2 py-0.5 border border-cyan/30">
|
||||
<svg fill="none" height="12" stroke="#36D6C5" stroke-width="2" viewbox="0 0 24 24" width="12">
|
||||
<rect height="12" rx="1" width="20" x="2" y="6"></rect>
|
||||
<path d="M12 6v12M2 12h20"></path>
|
||||
</svg>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-green animate-pulse"></div>
|
||||
<span class="font-mono text-[10px] text-cyan tabular-nums uppercase">Mil. vehicle 87%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bounding Box 2 (Hostile/Truck) -->
|
||||
<div class="absolute top-[55%] left-[60%] w-[150px] h-[100px] border-2 border-red pointer-events-none">
|
||||
<div class="absolute -top-7 left-0 flex items-center gap-1.5 whitespace-nowrap bg-surface-0/80 px-2 py-0.5 border border-red/30">
|
||||
<svg fill="none" height="12" stroke="#FF4756" stroke-width="2" viewbox="0 0 24 24" width="12">
|
||||
<path d="M12 2L2 12l10 10 10-10L12 2z"></path>
|
||||
<path d="M12 7v10M7 12h10"></path>
|
||||
</svg>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-green"></div>
|
||||
<span class="font-mono text-[10px] text-red tabular-nums uppercase">Truck 94%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Cursor Label -->
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<div class="w-6 h-6 border-t border-l border-amber opacity-50 absolute -top-4 -left-4"></div>
|
||||
<div class="w-6 h-6 border-b border-r border-amber opacity-50 absolute -bottom-4 -right-4"></div>
|
||||
<div class="ml-4 -mt-4 px-2 py-0.5 bg-amber/20 border border-amber/40">
|
||||
<span class="font-mono text-[9px] text-amber font-bold tracking-widest">MilVeh</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- AI Running Banner -->
|
||||
<div class="absolute top-4 right-4 bg-surface-1/90 border border-hairline p-3 min-w-[240px]">
|
||||
<div class="corner-br-tl"></div><div class="corner-br-tr"></div><div class="corner-br-bl"></div><div class="corner-br-br"></div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="w-2 h-2 rounded-full bg-cyan animate-ping"></div>
|
||||
<span class="font-headline text-[10px] text-onSurface font-bold tracking-widest">AI DETECTION RUNNING</span>
|
||||
</div>
|
||||
<div class="font-mono text-[9px] text-onSurfaceMuted tabular-nums">23/50 FRAMES ANALYZED</div>
|
||||
<div class="font-mono text-[8px] text-onSurfaceDim mt-1 overflow-hidden truncate">LOG: SECTOR_B // THREAD_ID_771 // SIG_LOCK</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- VIDEO TOOLBAR -->
|
||||
<div class="bg-surface-1 border-t border-hairline h-24 flex flex-col">
|
||||
<div class="flex-1 flex items-center px-4 justify-between">
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-4 text-onSurfaceMuted">
|
||||
<button class="material-symbols-outlined hover:text-onSurface">skip_previous</button>
|
||||
<button class="material-symbols-outlined hover:text-onSurface">fast_rewind</button>
|
||||
<button class="material-symbols-outlined text-amber scale-125">play_arrow</button>
|
||||
<button class="material-symbols-outlined hover:text-onSurface">fast_forward</button>
|
||||
<button class="material-symbols-outlined hover:text-onSurface">skip_next</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 border-l border-hairline pl-6">
|
||||
<span class="text-[9px] font-mono text-onSurfaceDim">FRAME STEP:</span>
|
||||
<div class="flex gap-1">
|
||||
<button class="px-2 py-0.5 bg-surface-2 border border-hairline text-[10px] font-mono hover:border-amber transition-colors">1</button>
|
||||
<button class="px-2 py-0.5 bg-surface-2 border border-hairline text-[10px] font-mono hover:border-amber transition-colors">5</button>
|
||||
<button class="px-2 py-0.5 bg-amber text-surface-0 border border-amber text-[10px] font-mono font-bold">10</button>
|
||||
<button class="px-2 py-0.5 bg-surface-2 border border-hairline text-[10px] font-mono hover:border-amber transition-colors">30</button>
|
||||
<button class="px-2 py-0.5 bg-surface-2 border border-hairline text-[10px] font-mono hover:border-amber transition-colors">60</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="px-4 py-1.5 border border-hairline text-[10px] font-mono hover:bg-surface-2 transition-all">SAVE</button>
|
||||
<button class="px-4 py-1.5 border border-hairline text-[10px] font-mono hover:bg-red/10 hover:text-red transition-all">DELETE</button>
|
||||
<button class="px-4 py-1.5 border border-hairline text-[10px] font-mono hover:bg-red/10 hover:text-red transition-all">DELETE ALL</button>
|
||||
<button class="px-4 py-1.5 bg-amber text-surface-0 border border-amber text-[10px] font-mono font-bold hover:opacity-90 transition-all">AI DETECT</button>
|
||||
<div class="flex items-center gap-2 ml-4 border-l border-hairline pl-4">
|
||||
<span class="material-symbols-outlined text-onSurfaceDim text-sm">volume_up</span>
|
||||
<div class="w-16 h-1 bg-hairline relative">
|
||||
<div class="absolute left-0 top-0 h-full w-[70%] bg-onSurfaceMuted"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- STATUS BAR & SCRUBBER -->
|
||||
<div class="h-8 border-t border-hairline bg-surface-0 flex items-center px-4 justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-mono text-[10px] text-amber tabular-nums">00:12 / 02:14</span>
|
||||
<span class="text-[9px] text-onSurfaceDim font-body uppercase">Press 1–9 to select class · space to pause</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 border border-green px-2 py-0.5 rounded-full">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-green"></div>
|
||||
<span class="font-mono text-[9px] text-green font-bold">READY</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress Scrubber -->
|
||||
<div class="h-1 bg-surface-1 relative cursor-pointer">
|
||||
<div class="absolute h-full bg-amber w-[35%] z-10 shadow-[0_0_10px_rgba(255,157,61,0.5)]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- RIGHT SIDEBAR: ANNOTATIONS -->
|
||||
<aside class="w-[220px] bg-surface-1 border-l border-hairline flex flex-col shrink-0 overflow-y-auto">
|
||||
<div class="p-4 border-b border-hairline flex justify-between items-center">
|
||||
<h3 class="font-headline text-[10px] tracking-[0.12em] text-onSurfaceDim uppercase">ANNOTATIONS</h3>
|
||||
<span class="font-mono text-[10px] text-onSurfaceDim">128</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<!-- Annotation Rows -->
|
||||
<div class="group flex items-center gap-3 px-3 py-2 border-b border-hairline hover:bg-surface-2 transition-colors cursor-pointer relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-red/10 to-transparent pointer-events-none"></div>
|
||||
<span class="font-mono text-[10px] text-red tabular-nums shrink-0">00:08</span>
|
||||
<span class="font-mono text-[10px] text-onSurface truncate">MilVeh_A</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-3 px-3 py-2 border-b border-hairline hover:bg-surface-2 transition-colors cursor-pointer relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-red/10 to-transparent pointer-events-none"></div>
|
||||
<span class="font-mono text-[10px] text-red tabular-nums shrink-0">00:09</span>
|
||||
<span class="font-mono text-[10px] text-onSurface truncate">MilVeh_B</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-3 px-3 py-2 border-b border-hairline bg-surface-2 border-l-2 border-amber transition-colors cursor-pointer relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-yellow-500/10 to-transparent pointer-events-none"></div>
|
||||
<span class="font-mono text-[10px] text-yellow-400 tabular-nums shrink-0">00:12</span>
|
||||
<span class="font-mono text-[10px] text-onSurface font-bold truncate">00:12 — Artillery</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-3 px-3 py-2 border-b border-hairline hover:bg-surface-2 transition-colors cursor-pointer relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-green/10 to-transparent pointer-events-none"></div>
|
||||
<span class="font-mono text-[10px] text-green tabular-nums shrink-0">00:15</span>
|
||||
<span class="font-mono text-[10px] text-onSurface truncate">Truck_01</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-3 px-3 py-2 border-b border-hairline hover:bg-surface-2 transition-colors cursor-pointer relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-green/10 to-transparent pointer-events-none"></div>
|
||||
<span class="font-mono text-[10px] text-green tabular-nums shrink-0">00:15</span>
|
||||
<span class="font-mono text-[10px] text-onSurface truncate">Truck_02</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-3 px-3 py-2 border-b border-hairline hover:bg-surface-2 transition-colors cursor-pointer relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-cyan/10 to-transparent pointer-events-none"></div>
|
||||
<span class="font-mono text-[10px] text-cyan tabular-nums shrink-0">00:22</span>
|
||||
<span class="font-mono text-[10px] text-onSurface truncate">Trench_Alpha</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-3 px-3 py-2 border-b border-hairline hover:bg-surface-2 transition-colors cursor-pointer relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue/10 to-transparent pointer-events-none"></div>
|
||||
<span class="font-mono text-[10px] text-blue tabular-nums shrink-0">00:28</span>
|
||||
<span class="font-mono text-[10px] text-onSurface truncate">Civ_Vehicle</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-3 px-3 py-2 border-b border-hairline hover:bg-surface-2 transition-colors cursor-pointer relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-fuchsia-600/10 to-transparent pointer-events-none"></div>
|
||||
<span class="font-mono text-[10px] text-fuchsia-400 tabular-nums shrink-0">00:31</span>
|
||||
<span class="font-mono text-[10px] text-onSurface truncate">Unknown_Shadow</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-3 px-3 py-2 border-b border-hairline hover:bg-surface-2 transition-colors cursor-pointer relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-red/10 to-transparent pointer-events-none"></div>
|
||||
<span class="font-mono text-[10px] text-red tabular-nums shrink-0">00:45</span>
|
||||
<span class="font-mono text-[10px] text-onSurface truncate">MilVeh_C</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 border-t border-hairline mt-auto">
|
||||
<button class="w-full border border-hairline py-2 text-[10px] font-mono text-onSurfaceDim hover:text-onSurface hover:bg-surface-2 transition-all uppercase tracking-widest">
|
||||
EXPORT DATA (.JSON)
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
<!-- FOOTER PANEL OVERLAY -->
|
||||
<div class="fixed bottom-12 right-6 flex flex-col gap-2 pointer-events-none">
|
||||
<div class="bg-surface-1/90 border border-hairline p-2 pr-8 relative pointer-events-auto">
|
||||
<div class="corner-br-tl"></div><div class="corner-br-tr"></div><div class="corner-br-bl"></div><div class="corner-br-br"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-[8px] text-onSurfaceDim">GPS:</span>
|
||||
<span class="font-mono text-[9px] text-cyan tabular-nums">48.2082° N, 16.3738° E</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-[8px] text-onSurfaceDim">ALT:</span>
|
||||
<span class="font-mono text-[9px] text-cyan tabular-nums">1,240m AMSL</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
@@ -0,0 +1,369 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>AZAION OPS - DATASET EXPLORER</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: {
|
||||
0: "#0A0D10",
|
||||
1: "#13171C",
|
||||
2: "#1A1F26"
|
||||
},
|
||||
hairline: "#252B34",
|
||||
raised: "#3B4451",
|
||||
amber: "#FF9D3D",
|
||||
cyan: "#36D6C5",
|
||||
red: "#FF4756",
|
||||
green: "#3DDC84",
|
||||
blue: "#4E9EFF",
|
||||
text: {
|
||||
primary: "#E8ECF1",
|
||||
secondary: "#9AA4B2",
|
||||
muted: "#5B6573"
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
headline: ["JetBrains Mono", "monospace"],
|
||||
display: ["JetBrains Mono", "monospace"],
|
||||
body: ["IBM Plex Sans", "sans-serif"],
|
||||
label: ["JetBrains Mono", "monospace"]
|
||||
},
|
||||
letterSpacing: {
|
||||
'technical': '0.12em',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #0A0D10;
|
||||
color: #E8ECF1;
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
}
|
||||
.font-mono-tabular {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.bracket-tl::before { content: ''; position: absolute; top: 0; left: 0; width: 8px; height: 8px; border-top: 1px solid #FF9D3D; border-left: 1px solid #FF9D3D; }
|
||||
.bracket-tr::before { content: ''; position: absolute; top: 0; right: 0; width: 8px; height: 8px; border-top: 1px solid #FF9D3D; border-right: 1px solid #FF9D3D; }
|
||||
.bracket-bl::before { content: ''; position: absolute; bottom: 0; left: 0; width: 8px; height: 8px; border-bottom: 1px solid #FF9D3D; border-left: 1px solid #FF9D3D; }
|
||||
.bracket-br::before { content: ''; position: absolute; bottom: 0; right: 0; width: 8px; height: 8px; border-bottom: 1px solid #FF9D3D; border-right: 1px solid #FF9D3D; }
|
||||
|
||||
.scanline {
|
||||
background: linear-gradient(to bottom, transparent 50%, rgba(255, 255, 255, 0.02) 50%);
|
||||
background-size: 100% 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-screen flex flex-col overflow-hidden">
|
||||
<!-- TopNavBar -->
|
||||
<header class="flex justify-between items-center px-4 w-full h-[48px] bg-[#0A0D10] border-b border-[#252B34] z-50">
|
||||
<div class="flex items-center gap-6">
|
||||
<span class="font-headline font-bold text-[#FF9D3D] tracking-widest text-lg">AZAION OPS</span>
|
||||
<div class="bg-surface-2 border border-hairline px-2 py-0.5 flex items-center gap-2 cursor-pointer hover:border-amber transition-colors">
|
||||
<span class="font-headline text-[10px] text-amber tracking-technical">FL03</span>
|
||||
<span class="material-symbols-outlined text-[14px] text-text-secondary">arrow_drop_down</span>
|
||||
</div>
|
||||
<nav class="flex gap-6 h-[48px] items-center">
|
||||
<a class="font-headline text-[10px] tracking-technical uppercase text-[#9AA4B2] hover:text-[#FF9D3D] transition-colors h-full flex items-center" href="#">FLIGHTS</a>
|
||||
<a class="font-headline text-[10px] tracking-technical uppercase text-[#9AA4B2] hover:text-[#FF9D3D] transition-colors h-full flex items-center" href="#">ANNOTATIONS</a>
|
||||
<a class="font-headline text-[10px] tracking-technical uppercase text-[#FF9D3D] border-b-2 border-[#FF9D3D] h-full flex items-center" href="#">DATASET</a>
|
||||
<a class="font-headline text-[10px] tracking-technical uppercase text-[#9AA4B2] hover:text-[#FF9D3D] transition-colors h-full flex items-center" href="#">ADMIN</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2 px-3 py-1 bg-amber/10 border border-amber/30">
|
||||
<span class="w-2 h-2 rounded-full bg-amber animate-pulse"></span>
|
||||
<span class="font-headline text-[10px] text-amber tracking-technical">MISSION READY</span>
|
||||
</div>
|
||||
<div class="flex gap-3 text-text-secondary">
|
||||
<span class="material-symbols-outlined cursor-pointer hover:text-amber text-[20px]">notifications</span>
|
||||
<span class="material-symbols-outlined cursor-pointer hover:text-amber text-[20px]">settings</span>
|
||||
<span class="material-symbols-outlined cursor-pointer hover:text-amber text-[20px]">account_circle</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- SideNavBar / Left Sidebar -->
|
||||
<aside class="w-64 bg-[#13171C] border-r border-[#252B34] flex flex-col h-full shrink-0">
|
||||
<div class="p-4 border-b border-hairline">
|
||||
<h3 class="font-headline text-[10px] tracking-technical text-text-muted mb-4 uppercase">DETECTION CLASSES</h3>
|
||||
<div class="space-y-2">
|
||||
<!-- Class Items -->
|
||||
<div class="flex items-center justify-between group cursor-crosshair">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 bg-cyan"></div>
|
||||
<span class="font-headline text-[11px] text-text-primary uppercase">MilVeh</span>
|
||||
</div>
|
||||
<span class="font-mono-tabular text-[11px] text-text-secondary bg-surface-2 px-1 border border-hairline">124</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between group cursor-crosshair">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 bg-amber"></div>
|
||||
<span class="font-headline text-[11px] text-text-primary uppercase">Truck</span>
|
||||
</div>
|
||||
<span class="font-mono-tabular text-[11px] text-text-secondary bg-surface-2 px-1 border border-hairline">087</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between group cursor-crosshair">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 bg-green"></div>
|
||||
<span class="font-headline text-[11px] text-text-primary uppercase">Vehicle</span>
|
||||
</div>
|
||||
<span class="font-mono-tabular text-[11px] text-text-secondary bg-surface-2 px-1 border border-hairline">061</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between group cursor-crosshair opacity-50">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 bg-red"></div>
|
||||
<span class="font-headline text-[11px] text-text-primary uppercase">Artillery</span>
|
||||
</div>
|
||||
<span class="font-mono-tabular text-[11px] text-text-secondary bg-surface-2 px-1 border border-hairline">032</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between group cursor-crosshair">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 bg-raised"></div>
|
||||
<span class="font-headline text-[11px] text-text-primary uppercase">Shadow</span>
|
||||
</div>
|
||||
<span class="font-mono-tabular text-[11px] text-text-secondary bg-surface-2 px-1 border border-hairline">214</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between group cursor-crosshair">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 bg-blue"></div>
|
||||
<span class="font-headline text-[11px] text-text-primary uppercase">Trenches</span>
|
||||
</div>
|
||||
<span class="font-mono-tabular text-[11px] text-text-secondary bg-surface-2 px-1 border border-hairline">019</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 border-b border-hairline">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="font-headline text-[10px] tracking-technical text-text-secondary uppercase">Objects Only</span>
|
||||
<button class="w-8 h-4 bg-surface-0 border border-hairline relative">
|
||||
<div class="absolute top-0 right-0 w-4 h-[14px] bg-amber"></div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<span class="material-symbols-outlined absolute left-2 top-1/2 -translate-y-1/2 text-text-muted text-[16px]">search</span>
|
||||
<input class="w-full bg-surface-0 border border-hairline h-8 pl-8 font-headline text-[10px] text-text-primary focus:ring-1 focus:ring-amber focus:border-amber outline-none" placeholder="FILTER BY ID..." type="text"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 flex-1">
|
||||
<div class="relative p-4 border border-hairline bg-surface-2 overflow-hidden">
|
||||
<div class="bracket-tl"></div><div class="bracket-tr"></div><div class="bracket-bl"></div><div class="bracket-br"></div>
|
||||
<h4 class="font-headline text-[10px] tracking-technical text-amber mb-3 uppercase">QUICK STATS</h4>
|
||||
<div class="space-y-2 font-mono-tabular text-[10px]">
|
||||
<div class="flex justify-between border-b border-hairline pb-1">
|
||||
<span class="text-text-muted">TOTAL</span>
|
||||
<span class="text-text-primary">01,842</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-hairline pb-1">
|
||||
<span class="text-text-muted">VALIDATED</span>
|
||||
<span class="text-text-primary text-green">01,504</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-hairline pb-1">
|
||||
<span class="text-text-muted">PENDING</span>
|
||||
<span class="text-text-primary text-amber">00,338</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 border-t border-hairline flex flex-col gap-2">
|
||||
<div class="flex items-center gap-3 px-3 py-2 hover:bg-surface-2 text-text-muted hover:text-text-primary transition-all cursor-pointer">
|
||||
<span class="material-symbols-outlined text-[18px]">build</span>
|
||||
<span class="font-headline text-[10px] tracking-technical">DIAGNOSTICS</span>
|
||||
</div>
|
||||
<button class="w-full border border-red text-red font-headline text-[10px] py-2 tracking-technical hover:bg-red/10 transition-all">TERMINATE SESSION</button>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-1 flex flex-col bg-surface-0 relative overflow-hidden">
|
||||
<!-- Filter Bar -->
|
||||
<div class="h-12 border-b border-hairline bg-surface-1 flex items-center px-4 justify-between shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center border border-hairline bg-surface-0 h-7 px-2">
|
||||
<span class="font-mono-tabular text-[11px] text-text-primary uppercase">2025-02-09</span>
|
||||
<span class="mx-2 text-text-muted">—</span>
|
||||
<span class="font-mono-tabular text-[11px] text-text-primary uppercase">2025-02-11</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 border border-hairline bg-surface-0 h-7 px-3 cursor-pointer">
|
||||
<span class="w-2 h-2 rounded-full bg-amber"></span>
|
||||
<span class="font-headline text-[11px] text-text-primary">FL-03</span>
|
||||
<span class="material-symbols-outlined text-[14px]">arrow_drop_down</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-hairline"></div>
|
||||
<div class="flex gap-2">
|
||||
<span class="px-2 h-6 border border-hairline text-text-muted font-headline text-[10px] flex items-center tracking-technical">NONE</span>
|
||||
<span class="px-2 h-6 border border-amber/30 bg-amber/10 text-amber font-headline text-[10px] flex items-center tracking-technical">CREATED</span>
|
||||
<span class="px-2 h-6 border border-blue text-blue font-headline text-[10px] flex items-center tracking-technical">EDITED</span>
|
||||
<span class="px-2 h-6 border border-green bg-green/10 text-green font-headline text-[10px] flex items-center tracking-technical">VALIDATED</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button class="w-8 h-8 flex items-center justify-center border border-hairline text-text-secondary hover:border-amber"><span class="material-symbols-outlined text-[18px]">grid_view</span></button>
|
||||
<button class="w-8 h-8 flex items-center justify-center border border-hairline text-text-secondary hover:border-amber"><span class="material-symbols-outlined text-[18px]">list</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab Strip -->
|
||||
<div class="flex border-b border-hairline bg-surface-1 px-4">
|
||||
<button class="h-10 px-6 font-headline text-[11px] tracking-technical uppercase text-amber border-b-2 border-amber">ANNOTATIONS</button>
|
||||
<button class="h-10 px-6 font-headline text-[11px] tracking-technical uppercase text-text-muted hover:text-text-primary transition-colors">EDITOR</button>
|
||||
<button class="h-10 px-6 font-headline text-[11px] tracking-technical uppercase text-text-muted hover:text-text-primary transition-colors">CLASS DISTRIBUTION</button>
|
||||
</div>
|
||||
<!-- Annotation Grid -->
|
||||
<div class="flex-1 overflow-y-auto p-4 scrollbar-thin scrollbar-thumb-raised">
|
||||
<div class="grid grid-cols-6 gap-2">
|
||||
<!-- SELECTED TILE 1 -->
|
||||
<div class="aspect-square bg-surface-1 border-2 border-amber relative group cursor-pointer overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-5 h-5 bg-amber flex items-center justify-center z-10">
|
||||
<span class="material-symbols-outlined text-surface-0 text-[14px]" style="font-variation-settings: 'FILL' 1;">check</span>
|
||||
</div>
|
||||
<div class="absolute top-1 right-1 px-1 bg-surface-1/80 text-text-muted font-mono-tabular text-[9px] z-10">12 MAY · RD</div>
|
||||
<div class="w-full h-full bg-gradient-to-br from-emerald-900/40 to-emerald-950/80 p-4">
|
||||
<img class="w-full h-full object-cover mix-blend-overlay opacity-50" data-alt="Overhead satellite imagery view of a tactical forest environment with dense pine trees and forest clearings, captured in a high-contrast cinematic military aesthetic with deep emerald and forest green tones. The lighting is diffused and moody, suggesting late afternoon surveillance conditions." src="https://lh3.googleusercontent.com/aida-public/AB6AXuAZwHF0AGwGxdwnLxfsEd3dpitJogOaQpNG9slAfON3bmZ4RJaRwEUqFug_t_9_jBBontbW--0jIzc3JP3FNa54HzGWTAW-YEyhtStHld5Y6fESKmeG1T0kMLcyUufABqLmiOHkbPTkrUTqd_SCbl9frdThLUJKzTCifR7e-P4Pp4Fth5EKHCuhQF6-G9iSFmBQSHhIwztSXdFc8icy9Hc78XowZg7ApF3FUb9J58fr_9tG1C0CMsQHQRxeibwqIL1wWjFL8JQX_clL"/>
|
||||
</div>
|
||||
<div class="absolute bottom-1 left-1">
|
||||
<div class="flex items-center gap-1 border border-green bg-green/10 px-1 py-0.5">
|
||||
<span class="w-1 h-1 rounded-full bg-green"></span>
|
||||
<span class="font-headline text-[8px] text-green uppercase">VALIDATED</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SEED ANNOTATION TILE -->
|
||||
<div class="aspect-square bg-surface-1 border border-red relative group cursor-pointer overflow-hidden">
|
||||
<div class="absolute top-1 right-1 px-1 bg-surface-1/80 text-text-muted font-mono-tabular text-[9px] z-10">12 MAY · RD</div>
|
||||
<div class="w-full h-full bg-gradient-to-br from-slate-700/40 to-slate-900/80 p-4">
|
||||
<img class="w-full h-full object-cover mix-blend-overlay opacity-50" data-alt="High-altitude aerial reconnaissance photo of an industrial urban gray logistics yard with large warehouse buildings and parked military trucks, styled in a monochromatic tactical console aesthetic with cold gray and steel blue highlights. Hard shadows define the sharp geometric edges of the structures." src="https://lh3.googleusercontent.com/aida-public/AB6AXuDoU_a9p0-IJp50fhCLTE-DwYSPqqwg7OpqZvedAnd9dt_IHLoKUqBlwqbMqAXh16APb9_SsVYqX8D5sTeN3YUgKCjS02xq0KQyJe8JZhzWcmIUt-0BEkJmYm7mC-GhbOgpBwJOzb_nW0v-dXd1jG8J8x3VN_vs1UB0rWTcKDej0DCD-Pu0G8l70gMrfS6YiYw3AFmeBkeHIkdhTG2p9R9AbNrw1TSOZ-dX3Ug4H58KFSSJSWIFOTK_zUpEe1Wt0qR5Ad9cc2KDyj3B"/>
|
||||
</div>
|
||||
<div class="absolute bottom-1 left-1">
|
||||
<div class="flex items-center gap-1 border border-amber/30 bg-amber/10 px-1 py-0.5">
|
||||
<span class="w-1 h-1 rounded-full bg-amber"></span>
|
||||
<span class="font-headline text-[8px] text-amber uppercase">CREATED</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- STANDARD TILES (Loop representation) -->
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative group cursor-pointer overflow-hidden">
|
||||
<div class="absolute top-1 right-1 px-1 bg-surface-1/80 text-text-muted font-mono-tabular text-[9px] z-10">12 MAY · RD</div>
|
||||
<div class="w-full h-full bg-gradient-to-br from-orange-900/40 to-orange-950/80 p-4">
|
||||
<img class="w-full h-full object-cover mix-blend-overlay opacity-50" data-alt="Top-down thermal scan perspective of a vast desert expanse with shifting sand dunes and scattered brush, rendered in tactical desert tan and warm brown hues. The visual style is grainy and technical, mimicking a low-altitude drone feed under harsh midday sun." src="https://lh3.googleusercontent.com/aida-public/AB6AXuD0pqdeg1e8c_3U4DtQ-ZOfV6BmqEiXafEZh7NIYNbZQH9wvAvvhkK-yIHxXA9YW0qeX6pbNw5828CaeEEohxAslUJoxCCQDZctcD116r3hjk3xd2XfcWPjpsuwzAAncZ7Rn1G8X0NaStgmavXFXSU2GvygcODvB9WRZ810ECwdYNjG3Ta4Djwt8dQNPTggoYFKXKrQUmjKHy2tEVPpKFtAR2dlJvsWKUinJz45wbHNmYZrqF8y2C81Ir_-3CK_FO8IEaqkD6uxeJGV"/>
|
||||
</div>
|
||||
<div class="absolute bottom-1 left-1">
|
||||
<div class="flex items-center gap-1 border border-blue px-1 py-0.5">
|
||||
<span class="w-1 h-1 rounded-full bg-blue"></span>
|
||||
<span class="font-headline text-[8px] text-blue uppercase">EDITED</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Repeat for 18+ items -->
|
||||
<!-- tile 4 -->
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative group cursor-pointer overflow-hidden">
|
||||
<div class="absolute top-1 right-1 px-1 bg-surface-1/80 text-text-muted font-mono-tabular text-[9px] z-10">11 MAY · XC</div>
|
||||
<div class="w-full h-full bg-gradient-to-br from-pink-950/40 to-black p-4">
|
||||
<img class="w-full h-full object-cover mix-blend-overlay opacity-50" data-alt="Electronic surveillance view of a rocky coastline at dusk, featuring dark pink and deep purple lighting highlights on jagged cliff faces. The style is that of a specialized tactical sensor array with visible noise patterns and technical overlay characteristics." src="https://lh3.googleusercontent.com/aida-public/AB6AXuBmWx_3z5QEWlHjjyY9V_44FP6IJeBOXAf_PNaQOG_1Czq3nV1-1VmC7F8c2s0DSTu22-fYpYBtpSIfW-kaw-0Vh7R04HgP4WMfiKLyQbkKB_hMJOACRRC-842y00IulZlEc8k0pgwhqEuuB05ryZSh9Ka-CPwOyyjk5-mrWSP-IQia7iOqNHAeUcBGrtBYlQ2KEroHs_hEUMo7O-0Lg7wAGSslxK-jY20kIpuU_Fg7_XXP-0l54aJdVetKR3RKX864vzk1CUJO00sK"/>
|
||||
</div>
|
||||
<div class="absolute bottom-1 left-1">
|
||||
<div class="flex items-center gap-1 border border-hairline px-1 py-0.5">
|
||||
<span class="w-1 h-1 rounded-full bg-text-muted"></span>
|
||||
<span class="font-headline text-[8px] text-text-muted uppercase">NONE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SELECTED TILE 2 -->
|
||||
<div class="aspect-square bg-surface-1 border-2 border-amber relative group cursor-pointer overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-5 h-5 bg-amber flex items-center justify-center z-10">
|
||||
<span class="material-symbols-outlined text-surface-0 text-[14px]" style="font-variation-settings: 'FILL' 1;">check</span>
|
||||
</div>
|
||||
<div class="absolute top-1 right-1 px-1 bg-surface-1/80 text-text-muted font-mono-tabular text-[9px] z-10">10 MAY · RD</div>
|
||||
<div class="w-full h-full bg-gradient-to-br from-blue-900/40 to-slate-900/80 p-4">
|
||||
<img class="w-full h-full object-cover mix-blend-overlay opacity-50" data-alt="Nadir drone view of a frozen arctic plain with deep snow drifts and blue ice fissures, styled in a cold white and cyan military imagery aesthetic. The lighting is bright and flat, characteristic of overcast polar surveillance missions." src="https://lh3.googleusercontent.com/aida-public/AB6AXuB4f1LSl-0OM7MAyUiSgDYQmqdSYe1togt8aSpmiSzl2z3MvkEMbslpDsFEL5ySzBDwBCaDb5SrRZcQDtv11duF2tPo86SkHD6HxnHZWHktpUtN67S3lGiIoJvbPzhTj4gdEbzvOzH2E8mTzvNQs8g6lz9KkpNwCFCN-CyzW0SoOJmHvaM3XKBgE7iNKQroGTnyqImiWOemd8pfBujP5djPswarBzfKgzNbmEU3KgXofVA0ZFb2oPZ5cDc5HWfGCad60NhTf906Ots_"/>
|
||||
</div>
|
||||
<div class="absolute bottom-1 left-1">
|
||||
<div class="flex items-center gap-1 border border-green bg-green/10 px-1 py-0.5">
|
||||
<span class="w-1 h-1 rounded-full bg-green"></span>
|
||||
<span class="font-headline text-[8px] text-green uppercase">VALIDATED</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- tile 6 -->
|
||||
<div class="aspect-square bg-surface-1 border-2 border-amber relative group cursor-pointer overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-5 h-5 bg-amber flex items-center justify-center z-10">
|
||||
<span class="material-symbols-outlined text-surface-0 text-[14px]" style="font-variation-settings: 'FILL' 1;">check</span>
|
||||
</div>
|
||||
<div class="absolute top-1 right-1 px-1 bg-surface-1/80 text-text-muted font-mono-tabular text-[9px] z-10">09 MAY · RD</div>
|
||||
<div class="w-full h-full bg-gradient-to-br from-gray-700/40 to-gray-900/80 p-4">
|
||||
<img class="w-full h-full object-cover mix-blend-overlay opacity-50" data-alt="Aerial drone camera feed showing an abandoned rural farming area with dilapidated barns and overgrown fields, captured in a stark urban gray and muted olive palette. Technical metadata overlays might be inferred by the precision framing and tactical perspective." src="https://lh3.googleusercontent.com/aida-public/AB6AXuDd_sJhVwnkVBWWrM9DIzpU1MQUy2fRutHktUF4nU7H60J5RlwUJ3uETjgy9Q-TLgZGHgb6qujRL75JHJ4b-YfMr3Rwg0rDSX9XhC2jN-4eWu4aGpcvVqOe838jdKwWsmN8Xs8r1i5aZe5ThoJHgWkT4YzG9LO6wqYAe4Eut88IFfxDtW6QGCI4GmMFf9rwpNzgL1F1SNuBzG5FX_oSIuHPgBFm-0uMX21IU4Ni4erv85cVseLLT9nNNwuLl1R_JYwz63-6kD2acRp1"/>
|
||||
</div>
|
||||
<div class="absolute bottom-1 left-1">
|
||||
<div class="flex items-center gap-1 border border-blue px-1 py-0.5">
|
||||
<span class="w-1 h-1 rounded-full bg-blue"></span>
|
||||
<span class="font-headline text-[8px] text-blue uppercase">EDITED</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fill grid with generic stylized tiles -->
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative group overflow-hidden">
|
||||
<div class="absolute top-1 right-1 px-1 bg-surface-1/80 text-text-muted font-mono-tabular text-[9px] z-10">08 MAY · RD</div>
|
||||
<div class="w-full h-full bg-surface-2 flex items-center justify-center">
|
||||
<div class="w-full h-full opacity-10 scanline absolute inset-0"></div>
|
||||
<span class="font-headline text-[8px] text-text-muted">IMG_DATA_007</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Repeating pattern -->
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-emerald-900/30 to-black"></div></div>
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-orange-900/30 to-black"></div></div>
|
||||
<div class="aspect-square bg-surface-1 border border-red relative overflow-hidden">
|
||||
<div class="w-full h-full bg-gradient-to-br from-slate-700/30 to-black"></div>
|
||||
<div class="absolute top-1 left-1 bg-red/20 px-1 font-headline text-[7px] text-red">SEED</div>
|
||||
</div>
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-gray-800/30 to-black"></div></div>
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-blue-900/30 to-black"></div></div>
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-emerald-900/30 to-black"></div></div>
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-orange-900/30 to-black"></div></div>
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-slate-700/30 to-black"></div></div>
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-gray-800/30 to-black"></div></div>
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-blue-900/30 to-black"></div></div>
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-emerald-900/30 to-black"></div></div>
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-orange-900/30 to-black"></div></div>
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-slate-700/30 to-black"></div></div>
|
||||
<div class="aspect-square bg-surface-1 border border-hairline relative overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-gray-800/30 to-black"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom Status Bar -->
|
||||
<footer class="h-12 border-t border-hairline bg-surface-1 flex items-center px-4 justify-between shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="bg-amber text-surface-0 font-headline text-[10px] h-8 px-4 font-bold tracking-technical flex items-center gap-2 hover:opacity-90 active:scale-95 transition-all">
|
||||
VALIDATE (3)
|
||||
</button>
|
||||
<button class="border border-hairline text-text-secondary font-headline text-[10px] h-8 px-4 tracking-technical hover:text-amber transition-colors">
|
||||
REFRESH THUMBNAILS
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="font-mono-tabular text-[11px] text-text-primary tracking-wide">ann_0247_FL03_117.jpg</span>
|
||||
<div class="w-32 h-0.5 bg-hairline mt-1 relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-amber w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-[14px] text-text-muted">schedule</span>
|
||||
<span class="font-mono-tabular text-[10px] text-text-muted uppercase">Last scan: 14:22</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-hairline"></div>
|
||||
<span class="font-mono-tabular text-[10px] text-amber">3 SELECTED</span>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</body></html>
|
||||
@@ -0,0 +1,338 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>AZAION Tactical Ops - FLIGHTS</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
background-color: #0A0D10;
|
||||
color: #E8ECF1;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.font-headline { font-family: 'JetBrains Mono', monospace; }
|
||||
.tabular-nums { font-variant-numeric: tabular-nums; }
|
||||
.scanline-overlay {
|
||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.03));
|
||||
background-size: 100% 2px, 3px 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.grid-bg {
|
||||
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
/* Corner Brackets */
|
||||
.corner-bracket {
|
||||
position: relative;
|
||||
}
|
||||
.corner-bracket::before, .corner-bracket::after,
|
||||
.corner-bracket > .bracket-bottom::before, .corner-bracket > .bracket-bottom::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-color: #FF9D3D;
|
||||
border-style: solid;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Top Left */
|
||||
.corner-bracket::before { top: 0; left: 0; border-width: 1px 0 0 1px; }
|
||||
/* Top Right */
|
||||
.corner-bracket::after { top: 0; right: 0; border-width: 1px 1px 0 0; }
|
||||
/* Bottom Left */
|
||||
.bracket-bottom::before { bottom: 0; left: 0; border-width: 0 0 1px 1px; }
|
||||
/* Bottom Right */
|
||||
.bracket-bottom::after { bottom: 0; right: 0; border-width: 0 1px 1px 0; }
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: #13171C; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #252B34; }
|
||||
</style>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: {
|
||||
0: "#0A0D10",
|
||||
1: "#13171C",
|
||||
2: "#1A1F26"
|
||||
},
|
||||
hairline: "#252B34",
|
||||
amber: "#FF9D3D",
|
||||
cyan: "#36D6C5",
|
||||
red: "#FF4756",
|
||||
green: "#3DDC84"
|
||||
},
|
||||
fontFamily: {
|
||||
headline: ["JetBrains Mono", "monospace"],
|
||||
body: ["IBM Plex Sans", "sans-serif"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="h-screen flex flex-col">
|
||||
<!-- TopAppBar -->
|
||||
<header class="bg-[#13171C] border-b border-[#252B34] h-12 flex justify-between items-center px-4 z-50">
|
||||
<div class="flex items-center gap-6">
|
||||
<span class="font-headline text-lg font-bold tracking-tighter text-[#FF9D3D]">AZAION</span>
|
||||
<div class="flex items-center border border-amber px-2 py-0.5 rounded-sm gap-2 bg-surface-2 cursor-pointer">
|
||||
<span class="font-headline text-[10px] tracking-[0.12em] text-amber">FL02</span>
|
||||
<span class="material-symbols-outlined text-amber text-xs">arrow_drop_down</span>
|
||||
</div>
|
||||
<nav class="flex h-12 items-center">
|
||||
<a class="text-[#FF9D3D] border-b-2 border-[#FF9D3D] h-full flex items-center px-4 font-headline text-[10px] tracking-[0.12em] uppercase transition-colors duration-150" href="#">FLIGHTS</a>
|
||||
<a class="text-[#5B6573] hover:text-[#E8ECF1] hover:bg-[#1A1F26] h-full flex items-center px-4 font-headline text-[10px] tracking-[0.12em] uppercase transition-colors duration-150" href="#">ANNOTATIONS</a>
|
||||
<a class="text-[#5B6573] hover:text-[#E8ECF1] hover:bg-[#1A1F26] h-full flex items-center px-4 font-headline text-[10px] tracking-[0.12em] uppercase transition-colors duration-150" href="#">DATASET</a>
|
||||
<a class="text-[#5B6573] hover:text-[#E8ECF1] hover:bg-[#1A1F26] h-full flex items-center px-4 font-headline text-[10px] tracking-[0.12em] uppercase transition-colors duration-150" href="#">ADMIN</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-headline text-[10px] tracking-[0.12em] text-cyan">SYSTEM_STATUS: OK</span>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-cyan shadow-[0_0_4px_#36D6C5]"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-[#5B6573] text-lg hover:text-white cursor-pointer">settings</span>
|
||||
<span class="material-symbols-outlined text-[#5B6573] text-lg hover:text-white cursor-pointer">notifications</span>
|
||||
<div class="flex items-center gap-2 pl-2 border-l border-hairline">
|
||||
<span class="font-headline text-[10px] text-secondary">OPERATOR_042</span>
|
||||
<span class="material-symbols-outlined text-[#5B6573] text-xl">account_circle</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<!-- Column 1: Flights Sidebar -->
|
||||
<aside class="w-[200px] bg-surface-1 border-r border-hairline flex flex-col p-4 corner-bracket">
|
||||
<div class="bracket-bottom"></div>
|
||||
<h2 class="font-headline text-[10px] tracking-[0.12em] text-amber mb-4">FLIGHTS_INDEX</h2>
|
||||
<div class="flex-1 space-y-1 overflow-y-auto custom-scrollbar">
|
||||
<div class="px-3 py-2 border border-transparent hover:bg-surface-2 cursor-pointer transition-colors">
|
||||
<div class="font-headline text-xs text-white">FL01</div>
|
||||
<div class="font-headline text-[9px] text-muted tracking-tighter">2023-11-24 08:30</div>
|
||||
</div>
|
||||
<div class="px-3 py-2 bg-surface-2 border-l-2 border-amber cursor-pointer">
|
||||
<div class="font-headline text-xs text-amber">FL02</div>
|
||||
<div class="font-headline text-[9px] text-amber/60 tracking-tighter">2023-11-24 10:15</div>
|
||||
</div>
|
||||
<div class="px-3 py-2 border border-transparent hover:bg-surface-2 cursor-pointer">
|
||||
<div class="font-headline text-xs text-white">FL03</div>
|
||||
<div class="font-headline text-[9px] text-muted tracking-tighter">2023-11-24 14:00</div>
|
||||
</div>
|
||||
<div class="px-3 py-2 border border-transparent hover:bg-surface-2 cursor-pointer">
|
||||
<div class="font-headline text-xs text-white">FL04</div>
|
||||
<div class="font-headline text-[9px] text-muted tracking-tighter">2023-11-25 09:12</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-hairline">
|
||||
<h3 class="font-headline text-[10px] tracking-[0.12em] text-muted mb-2">TELEMETRY_LOG</h3>
|
||||
<div class="bg-surface-0 border border-hairline p-2 flex items-center justify-between cursor-pointer">
|
||||
<span class="font-headline text-[10px] text-secondary">24_NOV_2023</span>
|
||||
<span class="material-symbols-outlined text-xs text-muted">calendar_today</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="mt-6 w-full border border-amber py-2 font-headline text-[10px] tracking-[0.12em] text-amber hover:bg-amber/10 transition-colors uppercase">
|
||||
+ NEW FLIGHT
|
||||
</button>
|
||||
</aside>
|
||||
<!-- Column 2: Parameters & Waypoints -->
|
||||
<aside class="w-[260px] bg-surface-1 border-r border-hairline flex flex-col p-4 corner-bracket">
|
||||
<div class="bracket-bottom"></div>
|
||||
<h2 class="font-headline text-[10px] tracking-[0.12em] text-amber mb-4">FLIGHT_PARAMETERS</h2>
|
||||
<div class="space-y-4 mb-6">
|
||||
<div>
|
||||
<label class="font-headline text-[10px] text-muted tracking-widest block mb-1">AIRCRAFT</label>
|
||||
<div class="bg-surface-0 border border-hairline px-2 py-1.5 text-xs text-white">DJI Mavic 3 Enterprise</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-headline text-[10px] text-muted tracking-widest block mb-1">DEFAULT_HEIGHT</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-surface-0 border border-hairline px-2 py-1.5 text-xs text-white flex-1 tabular-nums">100</div>
|
||||
<span class="font-headline text-[10px] text-muted">M</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label class="font-headline text-[9px] text-muted block mb-1">FOCAL</label>
|
||||
<div class="bg-surface-0 border border-hairline p-1 text-[10px] tabular-nums text-center">24MM</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-headline text-[9px] text-muted block mb-1">SENSOR</label>
|
||||
<div class="bg-surface-0 border border-hairline p-1 text-[10px] tabular-nums text-center">17.3MM</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-headline text-[9px] text-muted block mb-1">ALT</label>
|
||||
<div class="bg-surface-0 border border-hairline p-1 text-[10px] tabular-nums text-center">45M</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-headline text-[10px] text-muted tracking-widest block mb-1">COMM_ADDR</label>
|
||||
<div class="bg-surface-0 border border-hairline px-2 py-1.5 text-xs text-white font-headline tabular-nums">192.168.1.1:8080</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<h2 class="font-headline text-[10px] tracking-[0.12em] text-amber mb-2">WAYPOINTS_V1</h2>
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar border border-hairline">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead class="bg-surface-2 sticky top-0">
|
||||
<tr>
|
||||
<th class="font-headline text-[9px] p-2 border-b border-hairline text-muted">ID</th>
|
||||
<th class="font-headline text-[9px] p-2 border-b border-hairline text-muted">LABEL</th>
|
||||
<th class="font-headline text-[9px] p-2 border-b border-hairline text-muted">STATUS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-[10px] font-headline">
|
||||
<tr class="border-b border-hairline hover:bg-surface-2 cursor-pointer">
|
||||
<td class="p-2 text-green">A1</td>
|
||||
<td class="p-2">START_POINT</td>
|
||||
<td class="p-2 text-green">LOCKED</td>
|
||||
</tr>
|
||||
<tr class="border-b border-hairline hover:bg-surface-2 cursor-pointer">
|
||||
<td class="p-2 text-amber">A2</td>
|
||||
<td class="p-2">TRANS_01</td>
|
||||
<td class="p-2 text-amber">READY</td>
|
||||
</tr>
|
||||
<tr class="border-b border-hairline hover:bg-surface-2 cursor-pointer">
|
||||
<td class="p-2 text-amber">A3</td>
|
||||
<td class="p-2">TRANS_02</td>
|
||||
<td class="p-2 text-amber">READY</td>
|
||||
</tr>
|
||||
<tr class="border-b border-hairline hover:bg-surface-2 cursor-pointer">
|
||||
<td class="p-2 text-amber">A4</td>
|
||||
<td class="p-2">TRANS_03</td>
|
||||
<td class="p-2 text-muted">PENDING</td>
|
||||
</tr>
|
||||
<tr class="border-b border-hairline hover:bg-surface-2 cursor-pointer">
|
||||
<td class="p-2 text-amber">A5</td>
|
||||
<td class="p-2">TRANS_04</td>
|
||||
<td class="p-2 text-muted">PENDING</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-surface-2 cursor-pointer">
|
||||
<td class="p-2 text-red">A6</td>
|
||||
<td class="p-2">FINISH_LINE</td>
|
||||
<td class="p-2 text-muted">PENDING</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mt-4">
|
||||
<button class="border border-red text-red font-headline text-[10px] py-2 hover:bg-red/10 transition-colors">GPS-DENIED</button>
|
||||
<button class="border border-green text-green font-headline text-[10px] py-2 hover:bg-green/10 transition-colors">UPLOAD</button>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Column 3: Map View -->
|
||||
<section class="flex-1 relative bg-surface-0 grid-bg overflow-hidden">
|
||||
<div class="absolute inset-0 scanline-overlay"></div>
|
||||
<!-- Map Simulation (SVG Path) -->
|
||||
<svg class="absolute inset-0 w-full h-full opacity-60">
|
||||
<!-- Original Path (Red Dashed) -->
|
||||
<path d="M 200,600 L 400,450 L 550,500 L 700,300 L 900,350 L 1100,200" fill="none" stroke="#FF4756" stroke-dasharray="8,4" stroke-width="2"></path>
|
||||
<!-- Corrected Path (Cyan Solid) -->
|
||||
<path d="M 200,600 L 420,430 L 580,480 L 720,280 L 930,330 L 1100,200" fill="none" stroke="#36D6C5" stroke-width="2"></path>
|
||||
</svg>
|
||||
<!-- Waypoint Markers -->
|
||||
<div class="absolute" style="top: 600px; left: 200px; transform: translate(-50%, -50%);">
|
||||
<div class="w-4 h-4 bg-green border-2 border-white"></div>
|
||||
<span class="absolute top-6 left-1/2 -translate-x-1/2 font-headline text-[9px] text-green">START</span>
|
||||
</div>
|
||||
<div class="absolute" style="top: 430px; left: 420px; transform: translate(-50%, -50%);">
|
||||
<div class="w-3 h-3 bg-amber border border-white"></div>
|
||||
<span class="absolute top-4 left-1/2 -translate-x-1/2 font-headline text-[9px] text-amber">A2</span>
|
||||
</div>
|
||||
<div class="absolute" style="top: 480px; left: 580px; transform: translate(-50%, -50%);">
|
||||
<div class="w-3 h-3 bg-amber border border-white"></div>
|
||||
<span class="absolute top-4 left-1/2 -translate-x-1/2 font-headline text-[9px] text-amber">A3</span>
|
||||
</div>
|
||||
<div class="absolute" style="top: 280px; left: 720px; transform: translate(-50%, -50%);">
|
||||
<div class="w-3 h-3 bg-amber border border-white"></div>
|
||||
</div>
|
||||
<div class="absolute" style="top: 330px; left: 930px; transform: translate(-50%, -50%);">
|
||||
<div class="w-3 h-3 bg-amber border border-white"></div>
|
||||
</div>
|
||||
<div class="absolute" style="top: 200px; left: 1100px; transform: translate(-50%, -50%);">
|
||||
<div class="w-4 h-4 bg-red rotate-45 border-2 border-white"></div>
|
||||
<span class="absolute top-6 left-1/2 -translate-x-1/2 font-headline text-[9px] text-red">FINISH</span>
|
||||
</div>
|
||||
<!-- HUD (Top-Right) -->
|
||||
<div class="absolute top-6 right-6 p-4 bg-surface-1/80 border border-hairline corner-bracket backdrop-blur-sm min-w-[180px]">
|
||||
<div class="bracket-bottom"></div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-2 h-2 rounded-full bg-cyan animate-pulse"></div>
|
||||
<span class="font-headline text-[10px] tracking-widest text-white">LIVE • CONNECTED</span>
|
||||
</div>
|
||||
<div class="space-y-1 font-headline text-[11px] tabular-nums">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted">LAT</span>
|
||||
<span class="text-white">48.856621</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted">LON</span>
|
||||
<span class="text-white">2.352212</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted">SAT</span>
|
||||
<span class="text-white">12_ACTIVE</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-hairline pt-1 mt-1">
|
||||
<span class="text-muted">ALT</span>
|
||||
<span class="text-cyan">45.28M</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Map Controls Overlay -->
|
||||
<div class="absolute top-6 left-6 flex flex-col gap-2">
|
||||
<button class="w-8 h-8 bg-surface-1 border border-hairline flex items-center justify-center hover:bg-surface-2">
|
||||
<span class="material-symbols-outlined text-sm">add</span>
|
||||
</button>
|
||||
<button class="w-8 h-8 bg-surface-1 border border-hairline flex items-center justify-center hover:bg-surface-2">
|
||||
<span class="material-symbols-outlined text-sm">remove</span>
|
||||
</button>
|
||||
<button class="w-8 h-8 bg-surface-1 border border-hairline flex items-center justify-center hover:bg-surface-2 mt-4">
|
||||
<span class="material-symbols-outlined text-sm">layers</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Legend (Bottom-Left) -->
|
||||
<div class="absolute bottom-6 left-6 p-3 bg-surface-1/90 border border-hairline text-[10px] font-headline flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-red"></div>
|
||||
<span class="text-muted uppercase">Original path</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-cyan"></div>
|
||||
<span class="text-muted uppercase">Corrected path</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Compass Overlay -->
|
||||
<div class="absolute bottom-6 right-6 opacity-40">
|
||||
<svg height="80" viewbox="0 0 80 80" width="80">
|
||||
<circle cx="40" cy="40" fill="none" r="38" stroke="#252B34" stroke-width="1"></circle>
|
||||
<text fill="#5B6573" font-family="JetBrains Mono" font-size="8" text-anchor="middle" x="40" y="12">N</text>
|
||||
<path d="M 40,20 L 45,40 L 40,60 L 35,40 Z" fill="#FF9D3D"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- Contextual Footer / Status Bar -->
|
||||
<footer class="h-6 bg-[#13171C] border-t border-[#252B34] flex justify-between items-center px-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-headline text-[9px] text-muted">LOG_BUFFER: 100%</span>
|
||||
<span class="font-headline text-[9px] text-muted">FRAME_RATE: 60FPS</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-headline text-[9px] text-muted">SECTOR_7_ACTIVE</span>
|
||||
<span class="font-headline text-[9px] text-amber uppercase">Security level: ALPHA</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body></html>
|
||||
@@ -0,0 +1,346 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700;800&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
body {
|
||||
background-color: #0A0D10;
|
||||
color: #E8ECF1;
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||
.font-headline { font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
.corner-bracket {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-color: #FF9D3D;
|
||||
}
|
||||
.bracket-tl { top: 0; left: 0; border-top: 1px solid; border-left: 1px solid; }
|
||||
.bracket-tr { top: 0; right: 0; border-top: 1px solid; border-right: 1px solid; }
|
||||
.bracket-bl { bottom: 0; left: 0; border-bottom: 1px solid; border-left: 1px solid; }
|
||||
.bracket-br { bottom: 0; right: 0; border-bottom: 1px solid; border-right: 1px solid; }
|
||||
|
||||
.scanline {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: rgba(255, 157, 61, 0.03);
|
||||
position: absolute;
|
||||
animation: scan 8s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes scan {
|
||||
from { top: 0; }
|
||||
to { top: 100%; }
|
||||
}
|
||||
|
||||
.tabular-nums { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: #0A0D10; }
|
||||
::-webkit-scrollbar-thumb { background: #252B34; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #3B4451; }
|
||||
</style>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: {
|
||||
0: "#0A0D10",
|
||||
1: "#13171C",
|
||||
2: "#1A1F26"
|
||||
},
|
||||
hairline: "#252B34",
|
||||
raised: "#3B4451",
|
||||
primary: "#FF9D3D",
|
||||
cyan: "#36D6C5",
|
||||
red: "#FF4756",
|
||||
green: "#3DDC84",
|
||||
blue: "#4E9EFF",
|
||||
"on-primary": "#0A0D10"
|
||||
},
|
||||
borderRadius: {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
fontFamily: {
|
||||
headline: ["JetBrains Mono"],
|
||||
body: ["IBM Plex Sans"],
|
||||
mono: ["JetBrains Mono"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[#0A0D10] text-[#E8ECF1] antialiased min-h-screen pb-24">
|
||||
<!-- TopAppBar Shell -->
|
||||
<header class="fixed top-0 w-full h-[48px] z-50 bg-[#0A0D10] border-b border-[#252B34] flex justify-between items-center px-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-headline font-black text-lg tracking-tighter text-[#FF9D3D]">AZAION</span>
|
||||
<div class="flex items-center bg-[#13171C] border border-[#252B34] px-2 py-0.5 rounded-sm cursor-pointer hover:border-[#FF9D3D] transition-colors">
|
||||
<span class="font-mono text-[10px] tracking-widest text-[#FF9D3D]">FL02</span>
|
||||
<span class="material-symbols-outlined text-[14px] text-[#FF9D3D] ml-1">arrow_drop_down</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="hidden md:flex h-full items-center">
|
||||
<a class="text-[#5B6573] hover:text-[#E8ECF1] h-full flex items-center px-4 transition-colors font-headline font-mono uppercase tracking-[0.12em] text-[10px] antialiased" href="#">FLIGHTS</a>
|
||||
<a class="text-[#5B6573] hover:text-[#E8ECF1] h-full flex items-center px-4 transition-colors font-headline font-mono uppercase tracking-[0.12em] text-[10px] antialiased" href="#">ANNOTATIONS</a>
|
||||
<a class="text-[#5B6573] hover:text-[#E8ECF1] h-full flex items-center px-4 transition-colors font-headline font-mono uppercase tracking-[0.12em] text-[10px] antialiased" href="#">DATASET</a>
|
||||
<a class="text-[#FF9D3D] border-b-2 border-[#FF9D3D] h-full flex items-center px-4 font-headline font-mono uppercase tracking-[0.12em] text-[10px] antialiased" href="#">ADMIN</a>
|
||||
</nav>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-mono text-[10px] text-[#9AA4B2] hidden sm:block">USER@AZAION.MIL</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="p-1 text-[#FF9D3D] active:opacity-80 transition-opacity">
|
||||
<span class="material-symbols-outlined text-[20px]" data-weight="fill">settings</span>
|
||||
</button>
|
||||
<button class="p-1 text-[#5B6573] hover:text-[#FF4756] active:opacity-80 transition-opacity">
|
||||
<span class="material-symbols-outlined text-[20px]">power_settings_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="mt-16 px-5 max-w-[1600px] mx-auto">
|
||||
<div class="scanline"></div>
|
||||
<!-- Row 1: Configurations -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-5 mb-5">
|
||||
<!-- 01 - Tenant Config -->
|
||||
<section class="lg:col-span-3 bg-[#13171C] border border-[#252B34] p-4 relative">
|
||||
<div class="corner-bracket bracket-tl"></div>
|
||||
<div class="corner-bracket bracket-tr"></div>
|
||||
<div class="corner-bracket bracket-bl"></div>
|
||||
<div class="corner-bracket bracket-br"></div>
|
||||
<h2 class="font-mono text-[10px] tracking-[0.12em] text-[#FF9D3D] mb-4 uppercase">01 — TENANT CONFIGURATION</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="font-mono text-[10px] text-[#5B6573] uppercase">MILITARY UNIT</label>
|
||||
<input class="bg-[#0A0D10] border border-[#252B34] text-[#E8ECF1] h-8 px-2 text-sm focus:border-[#FF9D3D] focus:ring-0 outline-none font-body" type="text" value="72nd Brigade"/>
|
||||
<span class="text-[9px] text-[#5B6573] font-mono">USED IN PDF EXPORT HEADERS</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="font-mono text-[10px] text-[#5B6573] uppercase">UNIT NAME</label>
|
||||
<input class="bg-[#0A0D10] border border-[#252B34] text-[#E8ECF1] h-8 px-2 text-sm focus:border-[#FF9D3D] focus:ring-0 outline-none font-body" type="text" value="Alpha Company"/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="font-mono text-[10px] text-[#5B6573] uppercase">DEF. WIDTH</label>
|
||||
<input class="bg-[#0A0D10] border border-[#252B34] text-[#E8ECF1] h-8 px-2 text-sm focus:border-[#FF9D3D] focus:ring-0 outline-none font-mono tabular-nums" type="text" value="1920"/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="font-mono text-[10px] text-[#5B6573] uppercase">DEF. FOV</label>
|
||||
<input class="bg-[#0A0D10] border border-[#252B34] text-[#E8ECF1] h-8 px-2 text-sm focus:border-[#FF9D3D] focus:ring-0 outline-none font-mono tabular-nums" type="text" value="84"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 02 - Directories -->
|
||||
<section class="lg:col-span-3 bg-[#13171C] border border-[#252B34] p-4 relative">
|
||||
<div class="corner-bracket bracket-tl"></div>
|
||||
<div class="corner-bracket bracket-tr"></div>
|
||||
<div class="corner-bracket bracket-bl"></div>
|
||||
<div class="corner-bracket bracket-br"></div>
|
||||
<h2 class="font-mono text-[10px] tracking-[0.12em] text-[#FF9D3D] mb-4 uppercase">02 — DIRECTORIES</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="font-mono text-[10px] text-[#5B6573] uppercase">IMAGES PATH</label>
|
||||
<div class="flex">
|
||||
<div class="bg-[#0A0D10] border border-[#252B34] border-r-0 flex-1 h-8 px-2 text-xs flex items-center text-[#9AA4B2] font-mono overflow-hidden">
|
||||
<span class="material-symbols-outlined text-[14px] mr-2 text-[#5B6573]">folder</span>
|
||||
/mnt/nas/azaion/images/
|
||||
</div>
|
||||
<button class="bg-[#1A1F26] border border-[#252B34] hover:bg-[#3B4451] transition-colors text-[9px] font-mono px-3 text-[#E8ECF1]">BROWSE</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 mt-1">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-[#3DDC84]"></div>
|
||||
<span class="text-[9px] text-[#3DDC84] font-mono">MOUNTED (NVME_01)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="font-mono text-[10px] text-[#5B6573] uppercase">LABELS PATH</label>
|
||||
<div class="flex">
|
||||
<div class="bg-[#0A0D10] border border-[#252B34] border-r-0 flex-1 h-8 px-2 text-xs flex items-center text-[#9AA4B2] font-mono overflow-hidden">
|
||||
<span class="material-symbols-outlined text-[14px] mr-2 text-[#5B6573]">folder</span>
|
||||
/mnt/nas/azaion/labels/
|
||||
</div>
|
||||
<button class="bg-[#1A1F26] border border-[#252B34] hover:bg-[#3B4451] transition-colors text-[9px] font-mono px-3 text-[#E8ECF1]">BROWSE</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="font-mono text-[10px] text-[#5B6573] uppercase">THUMBNAILS</label>
|
||||
<div class="flex">
|
||||
<div class="bg-[#0A0D10] border border-[#252B34] border-r-0 flex-1 h-8 px-2 text-xs flex items-center text-[#9AA4B2] font-mono overflow-hidden">
|
||||
<span class="material-symbols-outlined text-[14px] mr-2 text-[#5B6573]">folder</span>
|
||||
/var/www/azaion/thumbs/
|
||||
</div>
|
||||
<button class="bg-[#1A1F26] border border-[#252B34] hover:bg-[#3B4451] transition-colors text-[9px] font-mono px-3 text-[#E8ECF1]">BROWSE</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 03 - Aircrafts -->
|
||||
<section class="lg:col-span-6 bg-[#13171C] border border-[#252B34] p-4 relative flex flex-col">
|
||||
<div class="corner-bracket bracket-tl"></div>
|
||||
<div class="corner-bracket bracket-tr"></div>
|
||||
<div class="corner-bracket bracket-bl"></div>
|
||||
<div class="corner-bracket bracket-br"></div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="font-mono text-[10px] tracking-[0.12em] text-[#FF9D3D] uppercase">03 — AIRCRAFTS</h2>
|
||||
<button class="bg-[#FF9D3D] text-[#0A0D10] font-mono font-bold text-[9px] px-3 py-1 rounded-sm hover:opacity-90 active:scale-95 transition-all flex items-center gap-1">
|
||||
<span class="material-symbols-outlined text-[14px]">add</span>
|
||||
ADD AIRCRAFT
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-[#252B34]">
|
||||
<th class="font-mono text-[10px] text-[#5B6573] py-2 uppercase">MODEL</th>
|
||||
<th class="font-mono text-[10px] text-[#5B6573] py-2 uppercase text-center">TYPE</th>
|
||||
<th class="font-mono text-[10px] text-[#5B6573] py-2 uppercase text-right">DEFAULT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-sm">
|
||||
<tr class="hover:bg-[#1A1F26] transition-colors group">
|
||||
<td class="py-3 font-medium text-[#E8ECF1]">DJI Mavic 3</td>
|
||||
<td class="py-3">
|
||||
<div class="flex justify-center">
|
||||
<div class="flex items-center gap-1.5 px-2 py-0.5 border border-[#4E9EFF] rounded-full">
|
||||
<div class="w-1 h-1 rounded-full bg-[#4E9EFF]"></div>
|
||||
<span class="text-[9px] font-mono text-[#4E9EFF]">PLANE</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 text-right">
|
||||
<button class="text-[#FF9D3D]">
|
||||
<span class="material-symbols-outlined text-[18px]" data-weight="fill">star</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-[#1A1F26] transition-colors group">
|
||||
<td class="py-3 font-medium text-[#E8ECF1]">Matrice 300 RTK</td>
|
||||
<td class="py-3">
|
||||
<div class="flex justify-center">
|
||||
<div class="flex items-center gap-1.5 px-2 py-0.5 border border-[#3DDC84] rounded-full">
|
||||
<div class="w-1 h-1 rounded-full bg-[#3DDC84]"></div>
|
||||
<span class="text-[9px] font-mono text-[#3DDC84]">COPTER</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 text-right">
|
||||
<button class="text-[#5B6573] hover:text-[#FF9D3D]">
|
||||
<span class="material-symbols-outlined text-[18px]">star</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-[#1A1F26] transition-colors group">
|
||||
<td class="py-3 font-medium text-[#E8ECF1]">Autel EVO II Dual</td>
|
||||
<td class="py-3">
|
||||
<div class="flex justify-center">
|
||||
<div class="flex items-center gap-1.5 px-2 py-0.5 border border-[#3DDC84] rounded-full">
|
||||
<div class="w-1 h-1 rounded-full bg-[#3DDC84]"></div>
|
||||
<span class="text-[9px] font-mono text-[#3DDC84]">COPTER</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 text-right">
|
||||
<button class="text-[#5B6573] hover:text-[#FF9D3D]">
|
||||
<span class="material-symbols-outlined text-[18px]">star</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Row 2: Misc -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<!-- 04 - Language -->
|
||||
<section class="bg-[#13171C] border border-[#252B34] p-4 relative">
|
||||
<div class="corner-bracket bracket-tl"></div>
|
||||
<div class="corner-bracket bracket-tr"></div>
|
||||
<div class="corner-bracket bracket-bl"></div>
|
||||
<div class="corner-bracket bracket-br"></div>
|
||||
<h2 class="font-mono text-[10px] tracking-[0.12em] text-[#FF9D3D] mb-4 uppercase">04 — LANGUAGE</h2>
|
||||
<div class="flex border border-[#252B34] w-fit">
|
||||
<button class="px-6 py-2 font-mono text-xs bg-[#FF9D3D] text-[#0A0D10] font-bold">EN</button>
|
||||
<button class="px-6 py-2 font-mono text-xs text-[#9AA4B2] hover:bg-[#1A1F26] transition-colors">UA</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 05 - Session -->
|
||||
<section class="bg-[#13171C] border border-[#252B34] p-4 relative">
|
||||
<div class="corner-bracket bracket-tl"></div>
|
||||
<div class="corner-bracket bracket-tr"></div>
|
||||
<div class="corner-bracket bracket-bl"></div>
|
||||
<div class="corner-bracket bracket-br"></div>
|
||||
<h2 class="font-mono text-[10px] tracking-[0.12em] text-[#FF9D3D] mb-4 uppercase">05 — SESSION</h2>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<button class="border border-[#FF4756] text-[#FF4756] font-mono text-[10px] px-4 py-2 hover:bg-[#FF4756] hover:text-[#0A0D10] transition-all uppercase">
|
||||
Sign out everywhere
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-mono text-[9px] text-[#5B6573] uppercase">LAST LOGIN: 2023-10-24 14:32:01</p>
|
||||
<p class="font-mono text-[9px] text-[#5B6573] uppercase">IP: 192.168.1.104 (LOCAL)</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Footer Shell -->
|
||||
<footer class="fixed bottom-0 left-0 right-0 z-50 bg-[#0A0D10] flex flex-row-reverse items-center gap-4 p-4 border-t border-[#252B34] h-14">
|
||||
<button class="bg-[#FF9D3D] text-[#0A0D10] font-bold px-6 py-1.5 rounded-sm font-headline font-mono text-[10px] tracking-[0.12em] uppercase active:scale-[0.98] transition-transform">
|
||||
SAVE CHANGES
|
||||
</button>
|
||||
<button class="border border-[#252B34] text-[#9AA4B2] px-6 py-1.5 rounded-sm font-headline font-mono text-[10px] tracking-[0.12em] uppercase hover:border-[#3B4451] hover:text-[#E8ECF1] active:scale-[0.98] transition-transform">
|
||||
CANCEL
|
||||
</button>
|
||||
<div class="mr-auto">
|
||||
<div class="flex items-center gap-2 border border-[#FF9D3D] bg-transparent px-3 py-1 rounded-full">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-[#FF9D3D] animate-pulse"></div>
|
||||
<span class="font-mono text-[9px] text-[#FF9D3D] uppercase font-bold tracking-wider">UNSAVED CHANGES IN TENANT</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden lg:block">
|
||||
<span class="font-mono text-[9px] text-[#5B6573] uppercase tracking-[0.12em]">SYSTEM STATUS: OPTIMAL // ENCRYPTION AES-256</span>
|
||||
</div>
|
||||
</footer>
|
||||
<script>
|
||||
// Subtle atmosphere: Interactive input highlights
|
||||
const inputs = document.querySelectorAll('input');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('focus', () => {
|
||||
input.parentElement.closest('section').style.borderColor = '#FF9D3D';
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.parentElement.closest('section').style.borderColor = '#252B34';
|
||||
});
|
||||
});
|
||||
|
||||
// Simulating unsaved changes logic
|
||||
const originalValues = Array.from(inputs).map(i => i.value);
|
||||
inputs.forEach((input, idx) => {
|
||||
input.addEventListener('input', () => {
|
||||
const statusPill = document.querySelector('.mr-auto .border');
|
||||
if(input.value !== originalValues[idx]) {
|
||||
statusPill.classList.remove('opacity-0');
|
||||
statusPill.classList.add('opacity-100');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
+7
-1
@@ -4,8 +4,14 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AZAION</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-[#1e1e1e] text-[#adb5bd]">
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, ProtectedRoute } from './auth'
|
||||
import { Header, FlightProvider } from './components'
|
||||
import { Header, FlightProvider, SavedAnnotationsProvider } from './components'
|
||||
import { LoginPage } from './features/login'
|
||||
import { FlightsPage } from './features/flights'
|
||||
import { AnnotationsPage } from './features/annotations'
|
||||
@@ -18,6 +18,7 @@ export default function App() {
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FlightProvider>
|
||||
<SavedAnnotationsProvider>
|
||||
<div className="flex flex-col h-screen">
|
||||
<Header />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
@@ -31,6 +32,7 @@ export default function App() {
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</SavedAnnotationsProvider>
|
||||
</FlightProvider>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
||||
@@ -55,6 +55,26 @@ describe('AZ-486 endpoints — wire-contract URLs', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.class(42)).toBe('/api/admin/classes/42')
|
||||
})
|
||||
|
||||
it('admin.aiSettings', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.aiSettings()).toBe('/api/admin/ai-settings')
|
||||
})
|
||||
|
||||
it('admin.gpsSettings', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.gpsSettings()).toBe('/api/admin/gps-settings')
|
||||
})
|
||||
|
||||
it('admin.gpsPing', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.gpsPing()).toBe('/api/admin/gps-settings/ping')
|
||||
})
|
||||
|
||||
it('admin.gpsReconnect', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.gpsReconnect()).toBe('/api/admin/gps-settings/reconnect')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1: annotations', () => {
|
||||
|
||||
@@ -33,6 +33,11 @@ export const endpoints = {
|
||||
// DetectionClass.id is `number` in the type system; widened to accept
|
||||
// string for forward-compat if the backend switches the column to UUID.
|
||||
class: (id: string | number) => `/api/admin/classes/${id}`,
|
||||
// v2 admin page — mocked via MSW until the backend lands the endpoints.
|
||||
aiSettings: () => '/api/admin/ai-settings',
|
||||
gpsSettings: () => '/api/admin/gps-settings',
|
||||
gpsPing: () => '/api/admin/gps-settings/ping',
|
||||
gpsReconnect: () => '/api/admin/gps-settings/reconnect',
|
||||
},
|
||||
annotations: {
|
||||
classes: () => '/api/annotations/classes',
|
||||
|
||||
@@ -24,19 +24,33 @@ export function useAuth() {
|
||||
// AZ-510 spec.
|
||||
let bootstrapInflight: Promise<AuthUser | null> | null = null
|
||||
|
||||
/**
|
||||
* Test-only hook to clear the module-scoped in-flight bootstrap promise
|
||||
* between Vitest tests. Production never imports this — it exists because
|
||||
* Vitest does not reset module state between tests, so a test that mocks the
|
||||
* bootstrap to never-resolve would otherwise leak a permanently-pending
|
||||
* promise that subsequent tests would await forever. Wired into
|
||||
* `tests/setup.ts` afterEach. Safe-no-op when nothing is in flight.
|
||||
*/
|
||||
export function __resetBootstrapInflightForTests(): void {
|
||||
bootstrapInflight = null
|
||||
}
|
||||
|
||||
// Dev-only escape hatch: `VITE_DEV_AUTH_BYPASS=true` skips the backend round
|
||||
// trip and injects a fake admin user so the SPA renders authenticated. Lives
|
||||
// in this file so the bypass is gated by the same effect that owns auth state;
|
||||
// the import.meta.env check is also tree-shaken out of production builds when
|
||||
// the flag is unset at build time.
|
||||
const DEV_BYPASS_USER: AuthUser = {
|
||||
id: 'dev-bypass',
|
||||
email: 'dev@azaion.local',
|
||||
name: 'Dev Bypass',
|
||||
role: 'admin',
|
||||
// Permission codes are short identifiers checked via hasPermission(code) —
|
||||
// currently used by the Header to gate the nav tabs (FL, ANN, DATASET, ADM).
|
||||
permissions: ['FL', 'ANN', 'DATASET', 'ADM'],
|
||||
}
|
||||
|
||||
async function runBootstrap(): Promise<AuthUser | null> {
|
||||
// Gated on import.meta.env.DEV so a leaked VITE_DEV_AUTH_BYPASS=true in a
|
||||
// production build cannot grant admin access. Vite tree-shakes the entire
|
||||
// branch when DEV is false at build time.
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEV_AUTH_BYPASS === 'true') {
|
||||
setToken('dev-bypass-token')
|
||||
return DEV_BYPASS_USER
|
||||
}
|
||||
// POST refresh with credentials — the whole point of the consolidation. Goes
|
||||
// through fetch() directly (not api.post) because api.post does not thread
|
||||
// credentials:'include'; widening api.post would change CORS posture for
|
||||
|
||||
@@ -22,3 +22,11 @@ export function getClassNameFallback(classNum: number): string {
|
||||
const base = classNum % 20
|
||||
return FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length] ?? `#${classNum}`
|
||||
}
|
||||
|
||||
export function hexToRgba(hex: string, alpha: number): string {
|
||||
const h = hex.replace('#', '')
|
||||
const r = parseInt(h.slice(0, 2), 16)
|
||||
const g = parseInt(h.slice(2, 4), 16)
|
||||
const b = parseInt(h.slice(4, 6), 16)
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@ export {
|
||||
getClassColor,
|
||||
getPhotoModeSuffix,
|
||||
getClassNameFallback,
|
||||
hexToRgba,
|
||||
FALLBACK_CLASS_NAMES,
|
||||
} from './classColors'
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md'
|
||||
import { FaRegSnowflake } from 'react-icons/fa'
|
||||
import { api, endpoints } from '../api'
|
||||
// classColors lives under 06_annotations until F3 moves it to its own home.
|
||||
// Importing through the 06_annotations barrel would create a cycle
|
||||
@@ -60,44 +58,72 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
|
||||
}
|
||||
}, [classes, photoMode, selectedClassNum, onSelect])
|
||||
|
||||
const modeClasses = classes.filter(c => c.photoMode === photoMode)
|
||||
|
||||
const modes = [
|
||||
{ value: 0, label: t('annotations.regular'), icon: <MdOutlineWbSunny />, activeClass: 'bg-az-orange text-white', iconColor: 'text-az-orange' },
|
||||
{ value: 20, label: t('annotations.winter'), icon: <FaRegSnowflake />, activeClass: 'bg-az-blue text-white', iconColor: 'text-az-blue' },
|
||||
{ value: 40, label: t('annotations.night'), icon: <MdOutlineNightlightRound />, activeClass: 'bg-purple-600 text-white', iconColor: 'text-purple-400' },
|
||||
{ value: 0, label: t('annotations.regular') },
|
||||
{ value: 20, label: t('annotations.winter') },
|
||||
{ value: 40, label: t('annotations.night') },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="border-t border-az-border p-2">
|
||||
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.classes')}</div>
|
||||
<div className="space-y-0.5 max-h-48 overflow-y-auto mb-2">
|
||||
{classes.filter(c => c.photoMode === photoMode).map((c, i) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onSelect(c.id)}
|
||||
className={`w-full flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs text-left ${
|
||||
selectedClassNum === c.id ? 'bg-az-border text-white' : 'text-az-text hover:bg-az-bg'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: getClassColor(c.id) }} />
|
||||
<span className="text-az-muted">{i + 1}.</span>
|
||||
<span className="truncate">{c.name}</span>
|
||||
<span className="text-az-muted ml-auto">{c.shortName}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-border-hair">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="sect-head">{t('annotations.classes')}</span>
|
||||
<span className="mono text-[10px] text-text-muted">{modeClasses.length.toString().padStart(2, '0')}</span>
|
||||
</div>
|
||||
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.photoMode')}</div>
|
||||
<div className="flex gap-1">
|
||||
</div>
|
||||
|
||||
{/* Column headers */}
|
||||
<div className="grid grid-cols-[28px_1fr_auto] px-3 h-6 items-center border-b border-border-hair gap-2">
|
||||
<span className="micro">{t('annotations.colNum')}</span>
|
||||
<span className="micro">{t('annotations.colName')}</span>
|
||||
<span className="micro">{t('annotations.colKey')}</span>
|
||||
</div>
|
||||
|
||||
{/* Class rows */}
|
||||
<div>
|
||||
{modeClasses.map((c, i) => {
|
||||
const isActive = selectedClassNum === c.id
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(c.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onSelect(c.id) }}
|
||||
className={`class-row${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<span className="swatch" style={{ background: getClassColor(c.id) }} />
|
||||
<span className={`truncate${isActive ? ' text-text-primary font-medium' : ' text-text-primary'}`}>
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="kbd">{i + 1}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* PhotoMode segmented control */}
|
||||
<div className="p-3 border-t border-border-hair">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="micro">{t('annotations.photoMode')}</span>
|
||||
</div>
|
||||
<div className="seg" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', width: '100%' }}>
|
||||
{modes.map(m => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
className={`seg-btn${photoMode === m.value ? ' active' : ''}`}
|
||||
onClick={() => onPhotoModeChange(m.value)}
|
||||
title={m.label}
|
||||
className={`flex-1 flex items-center justify-center px-2 py-1 rounded text-base ${photoMode === m.value ? m.activeClass : `bg-az-bg ${m.iconColor} hover:brightness-125`}`}
|
||||
>
|
||||
{m.icon}
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+96
-36
@@ -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')}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@ export { default as HelpModal } from './HelpModal'
|
||||
export { default as ConfirmDialog } from './ConfirmDialog'
|
||||
export { default as DetectionClasses } from './DetectionClasses'
|
||||
export { FlightProvider, useFlight } from './FlightContext'
|
||||
export { SavedAnnotationsProvider, useSavedAnnotations } from './SavedAnnotationsContext'
|
||||
|
||||
+634
-237
@@ -1,39 +1,137 @@
|
||||
import { useState, useEffect, type KeyboardEvent } from 'react'
|
||||
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { ConfirmDialog } from '../../components'
|
||||
import type { DetectionClass, Aircraft, User } from '../../types'
|
||||
import type { DetectionClass, Aircraft, GpsProtocol } from '../../types'
|
||||
import { useAiSettings } from './useAiSettings'
|
||||
import { useGpsSettings } from './useGpsSettings'
|
||||
import { Modal } from './Modal'
|
||||
import { NumberStepper } from './NumberStepper'
|
||||
import { ClassEditRow } from './ClassEditRow'
|
||||
|
||||
type EditForm = { name: string; shortName: string; color: string; maxSizeM: number }
|
||||
type EditErrorKind = 'nameRequired' | 'maxSizeMustBePositive' | 'updateFailed'
|
||||
type EditErrorKind = 'nameRequired' | 'updateFailed'
|
||||
// editingId === ADDING_ID switches Save from PATCH to POST.
|
||||
const ADDING_ID = -1
|
||||
const NEW_CLASS_DEFAULTS: EditForm = { name: '', shortName: '', color: '#FF9D3D', maxSizeM: 7 }
|
||||
|
||||
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 PROTOCOLS: GpsProtocol[] = ['NMEA', 'UBX', 'MAVLINK']
|
||||
const RESOLUTIONS = ['HD', '1080P', '4K', '6K'] as const
|
||||
const FALLBACK = '—'
|
||||
|
||||
const TYPE_COLORS: Record<Aircraft['type'], string> = {
|
||||
Plane: 'var(--accent-blue)',
|
||||
Copter: 'var(--accent-green)',
|
||||
FixedWing: 'var(--accent-amber)',
|
||||
}
|
||||
const TYPE_LETTERS: Record<Aircraft['type'], 'P' | 'C' | 'F'> = {
|
||||
Plane: 'P', Copter: 'C', FixedWing: 'F',
|
||||
}
|
||||
const TYPE_LEGEND_KEY: Record<Aircraft['type'], 'legendPlane' | 'legendCopter' | 'legendFixedW'> = {
|
||||
Plane: 'legendPlane', Copter: 'legendCopter', FixedWing: 'legendFixedW',
|
||||
}
|
||||
|
||||
function PencilIcon() {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function StarIcon({ filled }: { filled: boolean }) {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth={filled ? 1 : 1.4}>
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRunTime(iso: string | null): string {
|
||||
if (!iso) return FALLBACK
|
||||
// HH:MM:SSZ rendering, mockup-style.
|
||||
const m = iso.match(/T(\d{2}:\d{2}:\d{2})/)
|
||||
return m ? `${m[1]}Z` : FALLBACK
|
||||
}
|
||||
|
||||
|
||||
export default function AdminPage() {
|
||||
const { t } = useTranslation()
|
||||
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [newClass, setNewClass] = useState({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
|
||||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'Annotator' })
|
||||
const [deactivateId, setDeactivateId] = useState<string | null>(null)
|
||||
// AZ-512 — inline edit state. Single `editingId` (not per-row) so opening
|
||||
// one row's editor implicitly closes any other (Risk 3 mitigation).
|
||||
const [classFilter, setClassFilter] = useState('')
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editForm, setEditForm] = useState<EditForm>({ name: '', shortName: '', color: '#FF0000', maxSizeM: 0 })
|
||||
const [editForm, setEditForm] = useState<EditForm>(NEW_CLASS_DEFAULTS)
|
||||
const [editError, setEditError] = useState<EditErrorKind | null>(null)
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
|
||||
const [aircraftModalOpen, setAircraftModalOpen] = useState(false)
|
||||
const [aircraftDraft, setAircraftDraft] = useState<AircraftDraft>(NEW_AIRCRAFT_DEFAULTS)
|
||||
const [aircraftSaving, setAircraftSaving] = useState(false)
|
||||
const [aircraftError, setAircraftError] = useState<string | null>(null)
|
||||
|
||||
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 => [...prev, created])
|
||||
setAircraftModalOpen(false)
|
||||
} catch {
|
||||
setAircraftError('saveFailed')
|
||||
} finally {
|
||||
setAircraftSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const ai = useAiSettings()
|
||||
const gps = useGpsSettings()
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
|
||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||
api.get<User[]>(endpoints.admin.users()).then(setUsers).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleAddClass = async () => {
|
||||
if (!newClass.name) return
|
||||
await api.post(endpoints.admin.classes(), newClass)
|
||||
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||
setClasses(updated)
|
||||
setNewClass({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
|
||||
const filteredClasses = useMemo(() => {
|
||||
const q = classFilter.trim().toLowerCase()
|
||||
if (!q) return classes
|
||||
return classes.filter(c => c.name.toLowerCase().includes(q))
|
||||
}, [classes, classFilter])
|
||||
|
||||
const handleStartAdd = () => {
|
||||
setEditingId(ADDING_ID)
|
||||
setEditForm({ ...NEW_CLASS_DEFAULTS })
|
||||
setEditError(null)
|
||||
setEditSaving(false)
|
||||
}
|
||||
|
||||
const handleDeleteClass = async (id: number) => {
|
||||
@@ -54,18 +152,19 @@ export default function AdminPage() {
|
||||
setEditSaving(false)
|
||||
}
|
||||
|
||||
const handleUpdateClass = async () => {
|
||||
const handleSaveClass = async () => {
|
||||
if (editingId === null || editSaving) return
|
||||
if (!editForm.name.trim()) { setEditError('nameRequired'); return }
|
||||
if (!(editForm.maxSizeM > 0)) { setEditError('maxSizeMustBePositive'); return }
|
||||
setEditError(null)
|
||||
setEditSaving(true)
|
||||
try {
|
||||
// Risk 2 mitigation — always send the complete form so backend PATCH
|
||||
// semantics (full-replace vs partial-merge) don't matter.
|
||||
await api.patch(endpoints.admin.class(editingId), editForm)
|
||||
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||
setClasses(updated)
|
||||
if (editingId === ADDING_ID) {
|
||||
const created = await api.post<DetectionClass>(endpoints.admin.classes(), editForm)
|
||||
setClasses(prev => [...prev, created])
|
||||
} else {
|
||||
const updated = await api.patch<DetectionClass>(endpoints.admin.class(editingId), editForm)
|
||||
setClasses(prev => prev.map(c => c.id === editingId ? updated : c))
|
||||
}
|
||||
setEditingId(null)
|
||||
} catch {
|
||||
setEditError('updateFailed')
|
||||
@@ -75,244 +174,542 @@ export default function AdminPage() {
|
||||
}
|
||||
|
||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLElement>) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); void handleUpdateClass() }
|
||||
if (e.key === 'Enter') { e.preventDefault(); void handleSaveClass() }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); handleCancelEdit() }
|
||||
}
|
||||
|
||||
const handleAddUser = async () => {
|
||||
if (!newUser.email || !newUser.password) return
|
||||
await api.post(endpoints.admin.users(), newUser)
|
||||
const updated = await api.get<User[]>(endpoints.admin.users())
|
||||
setUsers(updated)
|
||||
setNewUser({ name: '', email: '', password: '', role: 'Annotator' })
|
||||
}
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
if (!deactivateId) return
|
||||
await api.patch(endpoints.admin.user(deactivateId), { isActive: false })
|
||||
setUsers(prev => prev.map(u => u.id === deactivateId ? { ...u, isActive: false } : u))
|
||||
setDeactivateId(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))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-y-auto p-4 gap-4">
|
||||
{/* Detection classes */}
|
||||
<div className="w-[340px] shrink-0">
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes.title')}</h2>
|
||||
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-az-border text-az-muted">
|
||||
<th className="px-2 py-1 text-left">#</th>
|
||||
<th className="px-2 py-1 text-left">Name</th>
|
||||
<th className="px-2 py-1">Color</th>
|
||||
<th className="px-2 py-1"></th>
|
||||
<main className="flex h-full overflow-hidden" style={{ background: 'var(--surface-0)' }}>
|
||||
|
||||
{/* ===== LEFT: DETECTION CLASSES (340px) ===== */}
|
||||
<aside
|
||||
className="shrink-0 flex flex-col"
|
||||
style={{ width: 340, background: 'var(--surface-1)', borderRight: '1px solid var(--border-hair)' }}
|
||||
>
|
||||
<div
|
||||
className="px-4 pt-4 pb-3 flex items-center justify-between"
|
||||
style={{ borderBottom: '1px solid var(--border-hair)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="sect-head">{t('admin.classes.title')}</span>
|
||||
<span className="mono tnum" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
||||
[{String(classes.length).padStart(2, '0')}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search + Add */}
|
||||
<div
|
||||
className="px-4 py-3 flex items-center gap-2"
|
||||
style={{ borderBottom: '1px solid var(--border-hair)' }}
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<svg className="absolute left-2 top-1/2 -translate-y-1/2" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ color: 'var(--text-muted)' }}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('admin.classes.search')}
|
||||
className="inp"
|
||||
value={classFilter}
|
||||
onChange={e => setClassFilter(e.target.value)}
|
||||
style={{ paddingLeft: 26, height: 28, fontSize: 11 }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleStartAdd}
|
||||
type="button"
|
||||
disabled={editingId === ADDING_ID}
|
||||
>
|
||||
<span>{t('admin.classes.add')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full tabular">
|
||||
<thead className="sticky top-0" style={{ background: 'var(--surface-1)' }}>
|
||||
<tr style={{ borderBottom: '1px solid var(--border-hair)' }}>
|
||||
<th className="text-left px-3 py-2 micro" style={{ width: 36 }}>#</th>
|
||||
<th className="text-left px-2 py-2 micro">{t('admin.classes.colName')}</th>
|
||||
<th className="text-center px-2 py-2 micro" style={{ width: 30 }}>{t('admin.classes.colHex')}</th>
|
||||
<th className="text-right px-3 py-2 micro" style={{ width: 60 }}>{t('admin.classes.colOps')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classes.map(c => c.id === editingId ? (
|
||||
<tr key={c.id} className="border-b border-az-border text-az-text bg-az-bg/40" data-editing-row={c.id}>
|
||||
<td className="px-2 py-1 align-top">{c.id}</td>
|
||||
<td colSpan={3} className="px-2 py-1">
|
||||
<div className="flex flex-wrap gap-1 items-center" onKeyDown={handleEditKeyDown}>
|
||||
<input
|
||||
autoFocus
|
||||
data-field="name"
|
||||
value={editForm.name}
|
||||
onChange={e => setEditForm(p => ({ ...p, name: e.target.value }))}
|
||||
className="flex-1 min-w-[80px] bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
||||
{editingId === ADDING_ID && (
|
||||
<ClassEditRow
|
||||
idCell="+"
|
||||
rowId="new"
|
||||
form={editForm}
|
||||
onChange={setEditForm}
|
||||
onSave={() => void handleSaveClass()}
|
||||
onCancel={handleCancelEdit}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
saving={editSaving}
|
||||
errorMessage={editError ? t(`admin.classes.${editError}`) : null}
|
||||
placeholderName="Name"
|
||||
/>
|
||||
<input
|
||||
data-field="shortName"
|
||||
value={editForm.shortName}
|
||||
onChange={e => setEditForm(p => ({ ...p, shortName: e.target.value }))}
|
||||
className="w-12 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
data-field="color"
|
||||
value={editForm.color}
|
||||
onChange={e => setEditForm(p => ({ ...p, color: e.target.value }))}
|
||||
className="w-7 h-6 border-0 bg-transparent cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
data-field="maxSizeM"
|
||||
value={editForm.maxSizeM}
|
||||
onChange={e => setEditForm(p => ({ ...p, maxSizeM: Number(e.target.value) }))}
|
||||
className="w-14 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
||||
)}
|
||||
{filteredClasses.map(c => c.id === editingId ? (
|
||||
<ClassEditRow
|
||||
key={c.id}
|
||||
idCell={c.id}
|
||||
rowId={c.id}
|
||||
form={editForm}
|
||||
onChange={setEditForm}
|
||||
onSave={() => void handleSaveClass()}
|
||||
onCancel={handleCancelEdit}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
saving={editSaving}
|
||||
errorMessage={editError ? t(`admin.classes.${editError}`) : null}
|
||||
/>
|
||||
) : (
|
||||
<tr key={c.id} className="row-hover" style={{ borderBottom: '1px solid var(--border-hair)', height: 32 }}>
|
||||
<td className="px-3 mono tnum" style={{ color: 'var(--text-muted)', fontSize: 12 }}>{c.id}</td>
|
||||
<td className="px-2"><span style={{ fontSize: 12 }}>{c.name}</span></td>
|
||||
<td className="px-2 text-center"><span className="swatch" style={{ background: c.color }} /></td>
|
||||
<td className="px-3 text-right">
|
||||
<span className="reveal inline-flex gap-1">
|
||||
<button
|
||||
onClick={() => void handleUpdateClass()}
|
||||
disabled={editSaving}
|
||||
className="bg-az-orange text-white px-2 py-0.5 rounded disabled:opacity-50"
|
||||
type="button"
|
||||
onClick={() => handleStartEdit(c)}
|
||||
className="ibtn edit"
|
||||
aria-label={t('admin.classes.edit')}
|
||||
title={t('admin.classes.edit')}
|
||||
>
|
||||
{t('admin.classes.save')}
|
||||
<PencilIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
disabled={editSaving}
|
||||
className="bg-az-bg border border-az-border text-az-text px-2 py-0.5 rounded disabled:opacity-50"
|
||||
type="button"
|
||||
onClick={() => handleDeleteClass(c.id)}
|
||||
className="ibtn danger"
|
||||
aria-label="×"
|
||||
title={t('admin.classes.delete')}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ===== CENTER ===== */}
|
||||
<section className="flex-1 overflow-y-auto grid-bg">
|
||||
<div className="max-w-[920px] mx-auto p-6 space-y-6">
|
||||
|
||||
{/* AI RECOGNITION ENGINE */}
|
||||
<div>
|
||||
<div className="flex items-end justify-between mb-3">
|
||||
<div>
|
||||
<div className="sect-head">{t('admin.aiEngine.title')}</div>
|
||||
<div className="hint mt-1">{t('admin.aiEngine.subtitle')}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 micro">
|
||||
<span style={{ color: 'var(--text-muted)' }}>{t('admin.aiEngine.model')}</span>
|
||||
<span className="mono tnum" style={{ color: 'var(--text-primary)' }}>
|
||||
{ai.telemetry ? `${ai.telemetry.model} · ${ai.telemetry.checkpoint}` : FALLBACK}
|
||||
</span>
|
||||
<span className="pill pill-cyan"><span className="dot live" />{t('admin.aiEngine.loaded')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bracket panel p-5">
|
||||
<span className="br" />
|
||||
|
||||
<div className="grid grid-cols-3 gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<label className="micro block mb-1">{t('admin.aiEngine.framesToRecognize')}</label>
|
||||
<div className="hint mb-2">{t('admin.aiEngine.framesHint')}</div>
|
||||
<NumberStepper
|
||||
value={ai.draft.framesToRecognize}
|
||||
min={1}
|
||||
step={1}
|
||||
suffix={t('admin.aiEngine.unitFR')}
|
||||
onChange={v => ai.setDraft({ ...ai.draft, framesToRecognize: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="micro block mb-1">{t('admin.aiEngine.minSeconds')}</label>
|
||||
<div className="hint mb-2">{t('admin.aiEngine.minSecondsHint')}</div>
|
||||
<NumberStepper
|
||||
value={ai.draft.minSecondsBetween}
|
||||
min={0}
|
||||
step={1}
|
||||
suffix={t('admin.aiEngine.unitSec')}
|
||||
onChange={v => ai.setDraft({ ...ai.draft, minSecondsBetween: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="micro block mb-1">{t('admin.aiEngine.minConfidence')}</label>
|
||||
<div className="hint mb-2">{t('admin.aiEngine.minConfidenceHint')}</div>
|
||||
<NumberStepper
|
||||
value={ai.draft.minConfidence}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
suffix="%"
|
||||
onChange={v => ai.setDraft({ ...ai.draft, minConfidence: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-5 pt-4 flex items-center justify-between"
|
||||
style={{ borderTop: '1px dashed var(--border-hair)' }}
|
||||
>
|
||||
<div className="flex items-center gap-5 micro">
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
{t('admin.aiEngine.lastRun')}{' '}
|
||||
<span className="mono tnum" style={{ color: 'var(--text-secondary)' }}>
|
||||
{formatRunTime(ai.telemetry?.lastRunAt ?? null)}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
{t('admin.aiEngine.frames')}{' '}
|
||||
<span className="mono tnum" style={{ color: 'var(--text-secondary)' }}>
|
||||
{ai.telemetry ? ai.telemetry.frames.toLocaleString() : FALLBACK}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
{t('admin.aiEngine.avgConf')}{' '}
|
||||
<span className="mono tnum" style={{ color: 'var(--accent-green)' }}>
|
||||
{ai.telemetry ? `${ai.telemetry.avgConfidence.toFixed(1)}%` : FALLBACK}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="btn btn-ghost" onClick={ai.reset}>
|
||||
{t('admin.aiEngine.reset')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => void ai.save()}
|
||||
disabled={ai.status === 'saving'}
|
||||
>
|
||||
{t('admin.aiEngine.apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ai.error && (
|
||||
<div role="alert" className="mt-2" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
|
||||
{ai.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GPS DEVICE LINK */}
|
||||
<div>
|
||||
<div className="flex items-end justify-between mb-3">
|
||||
<div>
|
||||
<div className="sect-head">{t('admin.gpsDevice.title')}</div>
|
||||
<div className="hint mt-1">{t('admin.gpsDevice.subtitle')}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 micro">
|
||||
<span style={{ color: 'var(--text-muted)' }}>{t('admin.gpsDevice.socket')}</span>
|
||||
<span className="mono tnum" style={{ color: 'var(--text-primary)' }}>
|
||||
{gps.telemetry?.socket ?? FALLBACK}
|
||||
</span>
|
||||
<span className={`pill ${gps.telemetry?.connected ? 'pill-green' : 'pill-red'}`}>
|
||||
<span className="dot" />
|
||||
{t('admin.gpsDevice.connected')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bracket panel p-5">
|
||||
<span className="br" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<label className="micro block mb-1">{t('admin.gpsDevice.address')}</label>
|
||||
<div className="hint mb-2">{t('admin.gpsDevice.addressHint')}</div>
|
||||
<input
|
||||
className="inp inp-mono"
|
||||
value={gps.draft.address}
|
||||
placeholder="0.0.0.0"
|
||||
onChange={e => gps.setDraft({ ...gps.draft, address: e.target.value })}
|
||||
aria-label={t('admin.gpsDevice.address')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="micro block mb-1">{t('admin.gpsDevice.port')}</label>
|
||||
<div className="hint mb-2">{t('admin.gpsDevice.portHint')}</div>
|
||||
<input
|
||||
className="inp inp-mono"
|
||||
type="number"
|
||||
value={gps.draft.port}
|
||||
onChange={e => gps.setDraft({ ...gps.draft, port: Number(e.target.value) })}
|
||||
style={{ textAlign: 'right' }}
|
||||
aria-label={t('admin.gpsDevice.port')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="micro block mb-1">{t('admin.gpsDevice.protocol')}</label>
|
||||
<div className="hint mb-2">{t('admin.gpsDevice.protocolHint')}</div>
|
||||
<div className="seg" role="group" aria-label={t('admin.gpsDevice.protocol')}>
|
||||
{PROTOCOLS.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => gps.setDraft({ ...gps.draft, protocol: p })}
|
||||
className={`seg-btn${gps.draft.protocol === p ? ' active' : ''}`}
|
||||
aria-pressed={gps.draft.protocol === p}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-5 pt-4 flex items-center justify-between"
|
||||
style={{ borderTop: '1px dashed var(--border-hair)' }}
|
||||
>
|
||||
<div className="flex items-center gap-5 micro">
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
{t('admin.gpsDevice.fix')}{' '}
|
||||
<span className="mono tnum" style={{ color: 'var(--accent-green)' }}>
|
||||
{gps.telemetry ? `${gps.telemetry.fix} · ${gps.telemetry.satellites} SAT` : FALLBACK}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
{t('admin.gpsDevice.hdop')}{' '}
|
||||
<span className="mono tnum" style={{ color: 'var(--text-secondary)' }}>
|
||||
{gps.telemetry ? gps.telemetry.hdop.toFixed(2) : FALLBACK}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
{t('admin.gpsDevice.lastPkt')}{' '}
|
||||
<span className="mono tnum" style={{ color: 'var(--text-secondary)' }}>
|
||||
{gps.telemetry ? `+${gps.telemetry.lastPacketMs}ms` : FALLBACK}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void gps.ping()} disabled={gps.status === 'pinging'}>
|
||||
{t('admin.gpsDevice.ping')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void gps.reconnect()} disabled={gps.status === 'reconnecting'}>
|
||||
{t('admin.gpsDevice.reconnect')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => void gps.save()}
|
||||
disabled={gps.status === 'saving'}
|
||||
>
|
||||
{t('admin.gpsDevice.apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{gps.error && (
|
||||
<div role="alert" className="mt-2" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
|
||||
{gps.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== RIGHT: DEFAULT AIRCRAFTS (280px) ===== */}
|
||||
<aside
|
||||
className="shrink-0 flex flex-col"
|
||||
style={{ width: 280, background: 'var(--surface-1)', borderLeft: '1px solid var(--border-hair)' }}
|
||||
>
|
||||
<div
|
||||
className="px-4 pt-4 pb-3 flex items-center justify-between"
|
||||
style={{ borderBottom: '1px solid var(--border-hair)' }}
|
||||
>
|
||||
<span className="sect-head">{t('admin.aircrafts.title')}</span>
|
||||
<span className="mono tnum" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
||||
[{String(aircrafts.length).padStart(2, '0')}]
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="px-4 py-2.5 flex items-center gap-3 micro"
|
||||
style={{ borderBottom: '1px solid var(--border-hair)', background: 'var(--surface-0)' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="type-sq" style={{ background: TYPE_COLORS.Plane }}>P</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{t('admin.aircrafts.legendPlane')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="type-sq" style={{ background: TYPE_COLORS.Copter }}>C</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{t('admin.aircrafts.legendCopter')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="type-sq" style={{ background: TYPE_COLORS.FixedWing }}>F</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{t('admin.aircrafts.legendFixedW')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{aircrafts.map(a => (
|
||||
<div
|
||||
key={a.id}
|
||||
data-aircraft-id={a.id}
|
||||
className="row-hover flex items-center gap-3 px-4 py-2.5"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--border-hair)',
|
||||
background: a.isDefault ? 'var(--surface-2)' : 'transparent',
|
||||
borderLeft: a.isDefault ? '2px solid var(--accent-amber)' : '2px solid transparent',
|
||||
}}
|
||||
>
|
||||
<span className="type-sq" style={{ background: TYPE_COLORS[a.type] }}>{TYPE_LETTERS[a.type]}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div style={{ fontSize: 12.5 }}>{a.model}</div>
|
||||
<div className="mono tnum" style={{ fontSize: 10.5, color: 'var(--text-muted)' }}>
|
||||
{a.id} · {a.resolution ?? FALLBACK} · {a.maxMinutes ?? FALLBACK}MIN
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleToggleDefault(a)}
|
||||
className={a.isDefault ? 'star' : 'star-off ibtn'}
|
||||
aria-label={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')}
|
||||
aria-pressed={a.isDefault}
|
||||
title={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')}
|
||||
style={a.isDefault ? { background: 'transparent', border: 0, cursor: 'pointer' } : undefined}
|
||||
>
|
||||
<StarIcon filled={a.isDefault} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="px-4 py-3"
|
||||
style={{ borderTop: '1px solid var(--border-hair)', background: 'var(--surface-0)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary w-full justify-center"
|
||||
onClick={openAircraftModal}
|
||||
>
|
||||
{t('admin.aircrafts.add')}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
{editError && (
|
||||
<div role="alert" className="mt-1 text-az-red">
|
||||
{t(`admin.classes.${editError}`)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={c.id} className="border-b border-az-border text-az-text">
|
||||
<td className="px-2 py-1">{c.id}</td>
|
||||
<td className="px-2 py-1">{c.name}</td>
|
||||
<td className="px-2 py-1 text-center"><span className="inline-block w-3 h-3 rounded-full" style={{ backgroundColor: c.color }} /></td>
|
||||
<td className="px-2 py-1 text-right whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleStartEdit(c)}
|
||||
aria-label={t('admin.classes.edit')}
|
||||
className="text-az-muted hover:text-az-orange mr-1"
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => void saveAircraft()}
|
||||
disabled={aircraftSaving}
|
||||
>
|
||||
{'\u270E'}
|
||||
{t('admin.aircrafts.addTitle')}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="p-2 flex gap-1 border-t border-az-border">
|
||||
<input value={newClass.name} onChange={e => setNewClass(p => ({ ...p, name: e.target.value }))} placeholder="Name" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
|
||||
<input type="color" value={newClass.color} onChange={e => setNewClass(p => ({ ...p, color: e.target.value }))} className="w-8 h-7 border-0 bg-transparent cursor-pointer" />
|
||||
<button onClick={handleAddClass} className="bg-az-orange text-white text-xs px-2 py-1 rounded">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: AI + GPS settings */}
|
||||
<div className="flex-1 space-y-4 max-w-md">
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.aiSettings')}</h2>
|
||||
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2 text-xs">
|
||||
<div>
|
||||
<label className="text-az-muted">Frame Period Recognition</label>
|
||||
<input type="number" defaultValue={5} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-az-muted">Frame Recognition Seconds</label>
|
||||
<input type="number" defaultValue={1} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-az-muted">Probability Threshold</label>
|
||||
<input type="number" defaultValue={0.5} step={0.05} min={0} max={1} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
|
||||
</div>
|
||||
<button className="bg-az-orange text-white text-xs px-3 py-1 rounded">{t('common.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.gpsSettings')}</h2>
|
||||
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2 text-xs">
|
||||
<div>
|
||||
<label className="text-az-muted">Device Address</label>
|
||||
<input defaultValue="192.168.1.100" className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-az-muted">Port</label>
|
||||
<input type="number" defaultValue={5535} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-az-muted">Protocol</label>
|
||||
<select className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text">
|
||||
<option>TCP</option>
|
||||
<option>UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="bg-az-orange text-white text-xs px-3 py-1 rounded">{t('common.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.users')}</h2>
|
||||
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-az-border text-az-muted">
|
||||
<th className="px-2 py-1 text-left">Name</th>
|
||||
<th className="px-2 py-1 text-left">Email</th>
|
||||
<th className="px-2 py-1">Role</th>
|
||||
<th className="px-2 py-1">Status</th>
|
||||
<th className="px-2 py-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="border-b border-az-border text-az-text">
|
||||
<td className="px-2 py-1">{u.name}</td>
|
||||
<td className="px-2 py-1">{u.email}</td>
|
||||
<td className="px-2 py-1 text-center">{u.role}</td>
|
||||
<td className="px-2 py-1 text-center">
|
||||
<span className={`px-1 rounded ${u.isActive ? 'text-az-green' : 'text-az-red'}`}>
|
||||
{u.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
{u.isActive && (
|
||||
<button onClick={() => setDeactivateId(u.id)} className="text-az-muted hover:text-az-red text-xs">
|
||||
{t('admin.deactivate')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="p-2 flex gap-1 border-t border-az-border">
|
||||
<input value={newUser.name} onChange={e => setNewUser(p => ({ ...p, name: e.target.value }))} placeholder="Name" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
|
||||
<input value={newUser.email} onChange={e => setNewUser(p => ({ ...p, email: e.target.value }))} placeholder="Email" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
|
||||
<input value={newUser.password} onChange={e => setNewUser(p => ({ ...p, password: e.target.value }))} placeholder="Password" type="password" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
|
||||
<select value={newUser.role} onChange={e => setNewUser(p => ({ ...p, role: e.target.value }))} className="bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text">
|
||||
<option>Annotator</option>
|
||||
<option>Admin</option>
|
||||
<option>Viewer</option>
|
||||
</select>
|
||||
<button onClick={handleAddUser} className="bg-az-orange text-white text-xs px-2 py-1 rounded">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aircrafts sidebar */}
|
||||
<div className="w-[280px] shrink-0">
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.aircrafts')}</h2>
|
||||
<div className="bg-az-panel border border-az-border rounded p-2 space-y-1">
|
||||
{aircrafts.map(a => (
|
||||
<div key={a.id} onClick={() => handleToggleDefault(a)} className="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-az-bg text-xs text-az-text">
|
||||
<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 === 'Plane' ? 'P' : 'C'}
|
||||
</span>
|
||||
<span className="flex-1">{a.model}</span>
|
||||
<span className={`text-sm ${a.isDefault ? 'text-az-orange' : 'text-az-muted'}`}>★</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deactivateId}
|
||||
title={t('admin.deactivate')}
|
||||
message="Deactivate this user?"
|
||||
onConfirm={handleDeactivate}
|
||||
onCancel={() => setDeactivateId(null)}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Fragment, useRef, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type EditFormShape = { name: string; shortName: string; color: string; maxSizeM: number }
|
||||
|
||||
interface ClassEditRowProps {
|
||||
/** Cell content for the leftmost `#` column (e.g. `+` for new, row id for edit). */
|
||||
idCell: ReactNode
|
||||
/** Stable identifier for the row's data-editing-row attribute. */
|
||||
rowId: number | 'new'
|
||||
form: EditFormShape
|
||||
onChange: (form: EditFormShape) => void
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
onKeyDown: (e: KeyboardEvent<HTMLElement>) => void
|
||||
saving: boolean
|
||||
/** Optional inline error key (already translated by the caller's t() if provided as message). */
|
||||
errorMessage: string | null
|
||||
placeholderName?: string
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClassEditRow({
|
||||
idCell, rowId, form, onChange, onSave, onCancel, onKeyDown,
|
||||
saving, errorMessage, placeholderName,
|
||||
}: ClassEditRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const colorInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<tr
|
||||
className="row-hover"
|
||||
data-editing-row={rowId}
|
||||
style={{ borderBottom: '1px solid var(--accent-amber)', height: 32, background: 'rgba(255,157,61,0.06)' }}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<td className="px-3 mono tnum" style={{ color: 'var(--accent-amber)', fontSize: 12 }}>{idCell}</td>
|
||||
<td className="px-2">
|
||||
<input
|
||||
autoFocus
|
||||
data-field="name"
|
||||
value={form.name}
|
||||
onChange={e => onChange({ ...form, name: e.target.value })}
|
||||
placeholder={placeholderName}
|
||||
className="inp inp-mono"
|
||||
style={{ height: 22, padding: '0 6px', fontSize: 11 }}
|
||||
aria-label={t('admin.classes.colName')}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
className="inline-flex items-center justify-center cursor-pointer"
|
||||
aria-label={t('admin.classes.colHex')}
|
||||
style={{ background: 'transparent', border: 0, padding: 0 }}
|
||||
>
|
||||
<span
|
||||
className="swatch"
|
||||
style={{ background: form.color, boxShadow: '0 0 0 1px var(--accent-amber)' }}
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
data-field="color"
|
||||
value={form.color}
|
||||
onChange={e => onChange({ ...form, color: e.target.value })}
|
||||
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 text-right">
|
||||
<span className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="ibtn cyan"
|
||||
aria-label={t('admin.classes.save')}
|
||||
title={t('admin.classes.save')}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
className="ibtn"
|
||||
aria-label={t('admin.classes.cancel')}
|
||||
title={t('admin.classes.cancel')}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{errorMessage && (
|
||||
<tr style={{ background: 'rgba(255,157,61,0.06)' }}>
|
||||
<td />
|
||||
<td colSpan={3} className="px-2 pb-2">
|
||||
<div role="alert" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, type ReactNode, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean
|
||||
title: ReactNode
|
||||
onClose: () => void
|
||||
width?: number
|
||||
footer?: ReactNode
|
||||
children: ReactNode
|
||||
closeLabel?: string
|
||||
}
|
||||
|
||||
export function Modal({ open, title, onClose, width = 420, footer, children, closeLabel = 'Close' }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
// Lock body scroll while the modal is open.
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.body.style.overflow = prev
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const onBackdropClick = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}
|
||||
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
// Stop Escape from bubbling to other key handlers in the page; the
|
||||
// document listener above already handles closing.
|
||||
if (e.key === 'Escape') e.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={typeof title === 'string' ? title : undefined}
|
||||
onClick={onBackdropClick}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 100,
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bracket panel"
|
||||
onKeyDown={onPanelKey}
|
||||
style={{ width, padding: 20 }}
|
||||
>
|
||||
<span className="br" />
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="sect-head">{title}</span>
|
||||
<button type="button" onClick={onClose} className="ibtn" aria-label={closeLabel} title={closeLabel}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">{children}</div>
|
||||
|
||||
{footer && (
|
||||
<div
|
||||
className="mt-5 pt-4 flex items-center justify-end gap-2"
|
||||
style={{ borderTop: '1px dashed var(--border-hair)' }}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
interface NumberStepperProps {
|
||||
value: number
|
||||
/** Inclusive minimum, applied only to ▲▼ stepper clicks (not free typing). */
|
||||
min?: number
|
||||
/** Inclusive maximum, applied only to ▲▼ stepper clicks (not free typing). */
|
||||
max?: number
|
||||
/** Increment per ▲▼ click. */
|
||||
step: number
|
||||
onChange: (v: number) => void
|
||||
/** Trailing unit label (e.g. "FR", "SEC", "%"). */
|
||||
suffix: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Number input with ▲▼ stepper buttons next to it and a trailing unit
|
||||
* label. Stepper buttons clamp to [min, max]; direct typing does NOT —
|
||||
* so `userEvent.clear()` + `type('9')` behaves as expected without being
|
||||
* snapped mid-keystroke. Invalid intermediate values fall through; the
|
||||
* caller validates on save.
|
||||
*/
|
||||
export function NumberStepper({ value, min, max, step, onChange, suffix }: NumberStepperProps) {
|
||||
const clamp = (v: number) => Math.max(min ?? -Infinity, Math.min(max ?? Infinity, v))
|
||||
return (
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input
|
||||
className="inp inp-mono"
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={e => {
|
||||
const raw = e.target.value
|
||||
const parsed = raw === '' ? 0 : Number(raw)
|
||||
onChange(Number.isFinite(parsed) ? parsed : 0)
|
||||
}}
|
||||
style={{ textAlign: 'right', width: 88 }}
|
||||
/>
|
||||
<div className="flex flex-col" style={{ border: '1px solid var(--border-hair)', borderRadius: 2 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(clamp(value + step))}
|
||||
className="mono"
|
||||
aria-label="Increment"
|
||||
style={{ width: 24, height: 15, fontSize: 9, color: 'var(--text-secondary)', background: 'var(--surface-input)', borderBottom: '1px solid var(--border-hair)' }}
|
||||
>▲</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(clamp(value - step))}
|
||||
className="mono"
|
||||
aria-label="Decrement"
|
||||
style={{ width: 24, height: 15, fontSize: 9, color: 'var(--text-secondary)', background: 'var(--surface-input)' }}
|
||||
>▼</button>
|
||||
</div>
|
||||
<span className="micro self-center" style={{ color: 'var(--text-muted)' }}>{suffix}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from '../../../../tests/msw/server'
|
||||
import { jsonResponse, errorResponse } from '../../../../tests/msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor, userEvent } from '../../../../tests/helpers/render'
|
||||
import { seedBearer, clearBearer } from '../../../../tests/helpers/auth'
|
||||
import { AdminPage } from '..'
|
||||
|
||||
// v2 admin — AI Recognition Engine panel. Covers GET → render telemetry,
|
||||
// edit value via stepper / input, APPLY → PATCH, RESET → discards draft,
|
||||
// PATCH 500 → inline error.
|
||||
//
|
||||
// Both AI and GPS panels render APPLY buttons; AI is the first one in DOM
|
||||
// order. We pick [0] from getAllByRole rather than coupling to internal markup.
|
||||
|
||||
function aiApplyButton(): HTMLElement {
|
||||
return screen.getAllByRole('button', { name: /apply/i })[0]
|
||||
}
|
||||
function aiResetButton(): HTMLElement {
|
||||
return screen.getByRole('button', { name: /reset/i })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
})
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
describe('AdminPage — AI Recognition Engine', () => {
|
||||
it('renders initial settings + telemetry from GET /api/admin/ai-settings', async () => {
|
||||
renderWithProviders(<AdminPage />)
|
||||
expect(await screen.findByText('YOLOV8-X · CKPT-241')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('4')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('25')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('APPLY sends PATCH with edited settings and reflects telemetry refresh', async () => {
|
||||
const calls: { body: unknown }[] = []
|
||||
server.use(
|
||||
http.patch('/api/admin/ai-settings', async ({ request }) => {
|
||||
const body = await request.json()
|
||||
calls.push({ body })
|
||||
return jsonResponse({
|
||||
settings: { framesToRecognize: 8, minSecondsBetween: 2, minConfidence: 25 },
|
||||
telemetry: {
|
||||
model: 'YOLOV8-X', checkpoint: 'CKPT-242',
|
||||
lastRunAt: '2026-05-18T12:00:00Z', frames: 99, avgConfidence: 80,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByText('YOLOV8-X · CKPT-241')
|
||||
|
||||
const framesInput = screen.getByDisplayValue('4') as HTMLInputElement
|
||||
await userEvent.clear(framesInput)
|
||||
await userEvent.type(framesInput, '8')
|
||||
|
||||
await userEvent.click(aiApplyButton())
|
||||
|
||||
await waitFor(() => expect(calls.length).toBe(1))
|
||||
expect((calls[0].body as { framesToRecognize: number }).framesToRecognize).toBe(8)
|
||||
expect(await screen.findByText(/CKPT-242/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('RESET reverts draft to the last persisted value (no PATCH)', async () => {
|
||||
const patchCalls: unknown[] = []
|
||||
server.use(
|
||||
http.patch('/api/admin/ai-settings', () => {
|
||||
patchCalls.push({})
|
||||
return jsonResponse({})
|
||||
}),
|
||||
)
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByText('YOLOV8-X · CKPT-241')
|
||||
|
||||
const framesInput = screen.getByDisplayValue('4') as HTMLInputElement
|
||||
await userEvent.clear(framesInput)
|
||||
await userEvent.type(framesInput, '9')
|
||||
expect(screen.getByDisplayValue('9')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(aiResetButton())
|
||||
|
||||
expect(screen.getByDisplayValue('4')).toBeInTheDocument()
|
||||
expect(patchCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
it('PATCH 500 surfaces an inline error', async () => {
|
||||
server.use(
|
||||
http.patch('/api/admin/ai-settings', () => errorResponse(500, 'boom')),
|
||||
)
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByText('YOLOV8-X · CKPT-241')
|
||||
|
||||
await userEvent.click(aiApplyButton())
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent ?? '').toMatch(/failed to save ai/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from '../../../../tests/msw/server'
|
||||
import { jsonResponse } from '../../../../tests/msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor, userEvent } from '../../../../tests/helpers/render'
|
||||
import { seedBearer, clearBearer } from '../../../../tests/helpers/auth'
|
||||
import { seedAircraft } from '../../../../tests/fixtures/seed_aircraft'
|
||||
import { AdminPage } from '..'
|
||||
|
||||
// v2 admin — Default Aircrafts panel: render 6 mockup rows + star toggle.
|
||||
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
server.use(
|
||||
http.get('/api/flights/aircrafts', () => jsonResponse(seedAircraft)),
|
||||
)
|
||||
})
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
describe('AdminPage — Default Aircrafts', () => {
|
||||
it('renders all 6 seeded aircraft with id · resolution · minutes', async () => {
|
||||
renderWithProviders(<AdminPage />)
|
||||
expect(await screen.findByText('DJI Mavic 3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matrice 300 RTK')).toBeInTheDocument()
|
||||
expect(screen.getByText('Leleka-100')).toBeInTheDocument()
|
||||
expect(screen.getByText('Fixed Wing Scout')).toBeInTheDocument()
|
||||
expect(screen.getByText('Autel EVO II Pro')).toBeInTheDocument()
|
||||
expect(screen.getByText('PD-2 Recon')).toBeInTheDocument()
|
||||
// Subline format: "AC-001 · 4K · 46MIN"
|
||||
expect(screen.getByText(/AC-001\s+·\s+4K\s+·\s+46MIN/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('star toggle PATCHes isDefault and updates UI', async () => {
|
||||
const calls: { id: string; body: unknown }[] = []
|
||||
server.use(
|
||||
http.patch('/api/flights/aircrafts/:id', async ({ params, request }) => {
|
||||
const body = await request.json()
|
||||
calls.push({ id: String(params.id), body })
|
||||
return jsonResponse({ ok: true })
|
||||
}),
|
||||
)
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByText('DJI Mavic 3')
|
||||
|
||||
// AC-002 starts non-default → click its star to mark default.
|
||||
const ac002Row = screen.getByText('Matrice 300 RTK').closest('[data-aircraft-id]') as HTMLElement
|
||||
expect(ac002Row).not.toBeNull()
|
||||
// Within the row find the toggle button (set-default label).
|
||||
const toggleBtn = ac002Row.querySelector('button[aria-pressed="false"]') as HTMLButtonElement
|
||||
expect(toggleBtn).not.toBeNull()
|
||||
await userEvent.click(toggleBtn)
|
||||
|
||||
await waitFor(() => expect(calls.length).toBe(1))
|
||||
expect(calls[0].id).toBe('AC-002')
|
||||
expect((calls[0].body as { isDefault: boolean }).isDefault).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from '../../../../tests/msw/server'
|
||||
import { jsonResponse } from '../../../../tests/msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor, userEvent } from '../../../../tests/helpers/render'
|
||||
import { seedBearer, clearBearer } from '../../../../tests/helpers/auth'
|
||||
import { AdminPage } from '..'
|
||||
|
||||
// v2 admin — GPS Device Link panel.
|
||||
//
|
||||
// AI and GPS share APPLY label; GPS is the SECOND APPLY in DOM order.
|
||||
|
||||
function gpsApplyButton(): HTMLElement {
|
||||
return screen.getAllByRole('button', { name: /apply/i })[1]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
})
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
describe('AdminPage — GPS Device Link', () => {
|
||||
it('renders initial settings + telemetry from GET /api/admin/gps-settings', async () => {
|
||||
renderWithProviders(<AdminPage />)
|
||||
expect(await screen.findByDisplayValue('192.168.1.100')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('9001')).toBeInTheDocument()
|
||||
expect(screen.getByText('UDP/192.168.1.100:9001')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('protocol segmented control switches active value and APPLY PATCHes', async () => {
|
||||
const calls: { body: unknown }[] = []
|
||||
server.use(
|
||||
http.patch('/api/admin/gps-settings', async ({ request }) => {
|
||||
const body = await request.json()
|
||||
calls.push({ body })
|
||||
return jsonResponse({
|
||||
settings: { ...(body as object), address: '192.168.1.100', port: 9001 },
|
||||
telemetry: { socket: 'UDP/192.168.1.100:9001', connected: true, fix: '3D', satellites: 11, hdop: 0.82, lastPacketMs: 12 },
|
||||
})
|
||||
}),
|
||||
)
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByDisplayValue('192.168.1.100')
|
||||
|
||||
const ubxBtn = screen.getByRole('button', { name: 'UBX' })
|
||||
await userEvent.click(ubxBtn)
|
||||
expect(ubxBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
await userEvent.click(gpsApplyButton())
|
||||
|
||||
await waitFor(() => expect(calls.length).toBe(1))
|
||||
expect((calls[0].body as { protocol: string }).protocol).toBe('UBX')
|
||||
})
|
||||
|
||||
it('PING and RECONNECT fire their dedicated endpoints', async () => {
|
||||
let pingHits = 0
|
||||
let reconnectHits = 0
|
||||
server.use(
|
||||
http.post('/api/admin/gps-settings/ping', () => { pingHits += 1; return new Response(null, { status: 204 }) }),
|
||||
http.post('/api/admin/gps-settings/reconnect', () => {
|
||||
reconnectHits += 1
|
||||
return jsonResponse({
|
||||
settings: { address: '192.168.1.100', port: 9001, protocol: 'NMEA' },
|
||||
telemetry: { socket: 'UDP/192.168.1.100:9001', connected: true, fix: '3D', satellites: 11, hdop: 0.82, lastPacketMs: 0 },
|
||||
})
|
||||
}),
|
||||
)
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByDisplayValue('192.168.1.100')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^ping$/i }))
|
||||
await waitFor(() => expect(pingHits).toBe(1))
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /reconnect/i }))
|
||||
await waitFor(() => expect(reconnectHits).toBe(1))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { api, endpoints } from '../../api'
|
||||
import type {
|
||||
AiRecognitionResponse,
|
||||
AiRecognitionSettings,
|
||||
AiRecognitionTelemetry,
|
||||
} from '../../types'
|
||||
|
||||
type Status = 'idle' | 'loading' | 'ready' | 'saving' | 'error'
|
||||
|
||||
// Factory defaults — UI stays interactive when GET fails (no backend).
|
||||
const FACTORY_AI_SETTINGS: AiRecognitionSettings = {
|
||||
framesToRecognize: 4,
|
||||
minSecondsBetween: 2,
|
||||
minConfidence: 25,
|
||||
}
|
||||
|
||||
export function useAiSettings() {
|
||||
const [draft, setDraft] = useState<AiRecognitionSettings>(FACTORY_AI_SETTINGS)
|
||||
const [persisted, setPersisted] = useState<AiRecognitionSettings>(FACTORY_AI_SETTINGS)
|
||||
const [telemetry, setTelemetry] = useState<AiRecognitionTelemetry | null>(null)
|
||||
const [status, setStatus] = useState<Status>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setStatus('loading')
|
||||
api.get<AiRecognitionResponse>(endpoints.admin.aiSettings())
|
||||
.then(res => {
|
||||
if (cancelled) return
|
||||
setDraft(res.settings)
|
||||
setPersisted(res.settings)
|
||||
setTelemetry(res.telemetry)
|
||||
setStatus('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setStatus('error')
|
||||
setError('Failed to load AI settings')
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setStatus('saving')
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.patch<AiRecognitionResponse>(endpoints.admin.aiSettings(), draft)
|
||||
setDraft(res.settings)
|
||||
setPersisted(res.settings)
|
||||
setTelemetry(res.telemetry)
|
||||
setStatus('ready')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setError('Failed to save AI settings')
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setDraft(persisted)
|
||||
}, [persisted])
|
||||
|
||||
return { draft, setDraft, telemetry, status, error, save, reset } as const
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { api, endpoints } from '../../api'
|
||||
import type {
|
||||
GpsDeviceResponse,
|
||||
GpsDeviceSettings,
|
||||
GpsDeviceTelemetry,
|
||||
} from '../../types'
|
||||
|
||||
type Status = 'idle' | 'loading' | 'ready' | 'saving' | 'pinging' | 'reconnecting' | 'error'
|
||||
|
||||
// Factory defaults — UI stays interactive when GET fails (no backend).
|
||||
const FACTORY_GPS_SETTINGS: GpsDeviceSettings = {
|
||||
address: '192.168.1.100',
|
||||
port: 9001,
|
||||
protocol: 'NMEA',
|
||||
}
|
||||
|
||||
export function useGpsSettings() {
|
||||
const [draft, setDraft] = useState<GpsDeviceSettings>(FACTORY_GPS_SETTINGS)
|
||||
const [persisted, setPersisted] = useState<GpsDeviceSettings>(FACTORY_GPS_SETTINGS)
|
||||
const [telemetry, setTelemetry] = useState<GpsDeviceTelemetry | null>(null)
|
||||
const [status, setStatus] = useState<Status>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setStatus('loading')
|
||||
api.get<GpsDeviceResponse>(endpoints.admin.gpsSettings())
|
||||
.then(res => {
|
||||
if (cancelled) return
|
||||
setDraft(res.settings)
|
||||
setPersisted(res.settings)
|
||||
setTelemetry(res.telemetry)
|
||||
setStatus('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setStatus('error')
|
||||
setError('Failed to load GPS settings')
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setStatus('saving')
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.patch<GpsDeviceResponse>(endpoints.admin.gpsSettings(), draft)
|
||||
setDraft(res.settings)
|
||||
setPersisted(res.settings)
|
||||
setTelemetry(res.telemetry)
|
||||
setStatus('ready')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setError('Failed to save GPS settings')
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
const ping = useCallback(async () => {
|
||||
setStatus('pinging')
|
||||
setError(null)
|
||||
try {
|
||||
await api.post(endpoints.admin.gpsPing(), {})
|
||||
setStatus('ready')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setError('Ping failed')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const reconnect = useCallback(async () => {
|
||||
setStatus('reconnecting')
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.post<GpsDeviceResponse>(endpoints.admin.gpsReconnect(), {})
|
||||
setTelemetry(res.telemetry)
|
||||
setStatus('ready')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setError('Reconnect failed')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setDraft(persisted)
|
||||
}, [persisted])
|
||||
|
||||
return { draft, setDraft, telemetry, status, error, save, ping, reconnect, reset } as const
|
||||
}
|
||||
@@ -1,38 +1,108 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useResizablePanel } from '../../hooks'
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api, endpoints } from '../../api'
|
||||
import MediaList from './MediaList'
|
||||
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
||||
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
||||
import AnnotationsSidebar from './AnnotationsSidebar'
|
||||
import Scrubber, { type ScrubberMark } from './Scrubber'
|
||||
import { DetectionClasses, useFlight } from '../../components'
|
||||
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
||||
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors'
|
||||
import { captureThumbnails } from './thumbnail'
|
||||
import { formatTime, formatTicks, parseAnnotationTime } from './time'
|
||||
import type { Media, AnnotationListItem, Detection } from '../../types'
|
||||
|
||||
const FRAME_STEPS = [1, 5, 10, 30, 60]
|
||||
|
||||
const FAKE_LOG_LINES = [
|
||||
'[tile 04/16] 2 candidates',
|
||||
'[tile 05/16] 1 candidate (conf 0.94)',
|
||||
'[filter] min_conf=0.25…',
|
||||
]
|
||||
|
||||
export default function AnnotationsPage() {
|
||||
const { t } = useTranslation()
|
||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [annotations, setAnnotations] = useState<AnnotationListItem[]>([])
|
||||
const [selectedAnnotation, setSelectedAnnotation] = useState<AnnotationListItem | null>(null)
|
||||
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
||||
const [photoMode, setPhotoMode] = useState(0)
|
||||
const [detections, setDetections] = useState<Detection[]>([])
|
||||
const leftPanel = useResizablePanel(250, 200, 400)
|
||||
const rightPanel = useResizablePanel(200, 150, 350)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [volume, setVolume] = useState(0.62)
|
||||
const [muted, setMuted] = useState(false)
|
||||
const [aiDetecting, setAiDetecting] = useState(false)
|
||||
const [aiLog, setAiLog] = useState<string[]>([])
|
||||
const [aiProgress, setAiProgress] = useState(0)
|
||||
const aiStartRef = useRef<number>(0)
|
||||
const aiCloseTimerRef = useRef<number | null>(null)
|
||||
const [aiElapsed, setAiElapsed] = useState(0)
|
||||
const videoPlayerRef = useRef<VideoPlayerHandle>(null)
|
||||
const canvasRef = useRef<CanvasEditorHandle>(null)
|
||||
const { addMany } = useSavedAnnotations()
|
||||
const { selectedFlight } = useFlight()
|
||||
|
||||
const isVideo = selectedMedia?.mediaType === MediaType.Video
|
||||
|
||||
useEffect(() => {
|
||||
setDetections([])
|
||||
setSelectedAnnotation(null)
|
||||
setCurrentTime(0)
|
||||
setDuration(0)
|
||||
setIsPlaying(false)
|
||||
setMuted(false)
|
||||
}, [selectedMedia])
|
||||
|
||||
// Push the page's initial volume into the <video> element once the player
|
||||
// is mounted — otherwise the slider shows 62% while audio plays at 100%.
|
||||
useEffect(() => {
|
||||
if (!selectedMedia || !isVideo) return
|
||||
videoPlayerRef.current?.setVolume(volume)
|
||||
// Only on media change — subsequent slider drags push via onVolumeChange.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedMedia, isVideo])
|
||||
|
||||
// AI detection fake-log progress
|
||||
useEffect(() => {
|
||||
if (!aiDetecting) return
|
||||
aiStartRef.current = performance.now()
|
||||
setAiElapsed(0)
|
||||
setAiLog([])
|
||||
setAiProgress(0)
|
||||
let i = 0
|
||||
const logTimer = window.setInterval(() => {
|
||||
if (i < FAKE_LOG_LINES.length) {
|
||||
setAiLog(prev => [...prev, FAKE_LOG_LINES[i]])
|
||||
i++
|
||||
}
|
||||
}, 700)
|
||||
const tickTimer = window.setInterval(() => {
|
||||
setAiElapsed((performance.now() - aiStartRef.current) / 1000)
|
||||
setAiProgress(p => Math.min(0.95, p + 0.04))
|
||||
}, 100)
|
||||
return () => {
|
||||
window.clearInterval(logTimer)
|
||||
window.clearInterval(tickTimer)
|
||||
}
|
||||
}, [aiDetecting])
|
||||
|
||||
const scrubberMarks = useMemo<ScrubberMark[]>(() => {
|
||||
return annotations
|
||||
.map(a => {
|
||||
const sec = parseAnnotationTime(a.time)
|
||||
if (sec == null) return null
|
||||
const first = a.detections[0]
|
||||
return { time: sec, color: first ? getClassColor(first.classNum) : '#9AA4B2' }
|
||||
})
|
||||
.filter((m): m is ScrubberMark => m !== null)
|
||||
}, [annotations])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedMedia || !detections.length) return
|
||||
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
|
||||
@@ -108,7 +178,6 @@ export default function AnnotationsPage() {
|
||||
txtA.click()
|
||||
URL.revokeObjectURL(txtUrl)
|
||||
|
||||
// Build the image: video frame or image with rectangles drawn
|
||||
const videoEl = videoPlayerRef.current?.getVideoElement() ?? null
|
||||
let w = 0, h = 0
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -181,11 +250,10 @@ export default function AnnotationsPage() {
|
||||
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
|
||||
setSelectedAnnotation(ann)
|
||||
setDetections(ann.detections)
|
||||
if (ann.time) {
|
||||
const parts = ann.time.split(':').map(Number)
|
||||
const seconds = (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0)
|
||||
videoPlayerRef.current?.seek(seconds)
|
||||
setCurrentTime(seconds)
|
||||
const sec = parseAnnotationTime(ann.time)
|
||||
if (sec != null) {
|
||||
videoPlayerRef.current?.seek(sec)
|
||||
setCurrentTime(sec)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -193,20 +261,68 @@ export default function AnnotationsPage() {
|
||||
setDetections(dets)
|
||||
}, [])
|
||||
|
||||
const isVideo = selectedMedia?.mediaType === MediaType.Video
|
||||
|
||||
function formatTicks(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}`
|
||||
const handleAiDetect = useCallback(async () => {
|
||||
if (!selectedMedia || aiDetecting) return
|
||||
if (aiCloseTimerRef.current != null) {
|
||||
window.clearTimeout(aiCloseTimerRef.current)
|
||||
aiCloseTimerRef.current = null
|
||||
}
|
||||
setAiDetecting(true)
|
||||
try {
|
||||
await api.post(endpoints.detect.media(selectedMedia.id))
|
||||
} catch {
|
||||
// banner stays visible briefly; sidebar SSE refresh will pick up results
|
||||
} finally {
|
||||
setAiProgress(1)
|
||||
aiCloseTimerRef.current = window.setTimeout(() => {
|
||||
aiCloseTimerRef.current = null
|
||||
setAiDetecting(false)
|
||||
}, 500)
|
||||
}
|
||||
}, [selectedMedia, aiDetecting])
|
||||
|
||||
// Clear any pending AI-banner close timer on unmount.
|
||||
useEffect(() => () => {
|
||||
if (aiCloseTimerRef.current != null) {
|
||||
window.clearTimeout(aiCloseTimerRef.current)
|
||||
aiCloseTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const togglePlay = () => { videoPlayerRef.current?.toggle() }
|
||||
const stepFrames = (n: number) => { videoPlayerRef.current?.frameStep(n) }
|
||||
const seekRel = (sec: number) => {
|
||||
const p = videoPlayerRef.current
|
||||
if (!p) return
|
||||
p.seek(Math.max(0, Math.min(p.getDuration(), p.getCurrentTime() + sec)))
|
||||
}
|
||||
|
||||
const onVolumeChange = (v: number) => {
|
||||
setVolume(v)
|
||||
videoPlayerRef.current?.setVolume(v)
|
||||
}
|
||||
const toggleMute = () => {
|
||||
// VideoPlayer.toggleMute() fires onMutedChange, which updates `muted` —
|
||||
// don't flip parent state independently or the two desync (e.g. M-key
|
||||
// shortcut already routed via onMutedChange).
|
||||
videoPlayerRef.current?.toggleMute()
|
||||
}
|
||||
|
||||
const dims = (() => {
|
||||
const v = videoPlayerRef.current?.getVideoElement()
|
||||
if (!v || !v.videoWidth) return null
|
||||
return { w: v.videoWidth, h: v.videoHeight }
|
||||
})()
|
||||
const fps = videoPlayerRef.current?.getFrameRate() ?? 30
|
||||
const currentFrame = isVideo ? Math.floor(currentTime * fps) : 0
|
||||
const totalFrames = isVideo ? Math.floor(duration * fps) : 0
|
||||
|
||||
const detectionsLabel = `${detections.length} det${detections.length !== 1 ? 's' : ''}`
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left panel */}
|
||||
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
||||
{/* LEFT SIDEBAR */}
|
||||
<div style={{ width: 232 }} className="bg-surface-1 flex flex-col shrink-0 border-r border-border-hair">
|
||||
<MediaList
|
||||
selectedMedia={selectedMedia}
|
||||
onSelect={setSelectedMedia}
|
||||
@@ -219,41 +335,46 @@ export default function AnnotationsPage() {
|
||||
onPhotoModeChange={setPhotoMode}
|
||||
/>
|
||||
</div>
|
||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
||||
|
||||
{/* Center - video/canvas */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* CENTER */}
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-surface-0">
|
||||
{/* Canvas top bar */}
|
||||
<div className="h-9 flex items-center gap-3 px-4 border-b border-border-hair bg-surface-1 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="sect-head">{t('annotations.canvas')}</span>
|
||||
{selectedMedia && (
|
||||
<div className="bg-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!detections.length}
|
||||
className="px-2.5 py-1 rounded border border-az-green text-az-green text-[11px] hover:bg-az-green/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => canvasRef.current?.deleteSelected()}
|
||||
disabled={!detections.length}
|
||||
className="px-2.5 py-1 rounded border border-az-red text-az-red text-[11px] hover:bg-az-red/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<button
|
||||
onClick={() => canvasRef.current?.deleteAll()}
|
||||
disabled={!detections.length}
|
||||
className="px-2.5 py-1 rounded border border-az-red text-az-red text-[11px] hover:bg-az-red/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Remove All
|
||||
</button>
|
||||
<span className="text-az-muted text-[10px]">{detections.length} detection{detections.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<>
|
||||
<span className="mono text-[11px] text-text-muted">{selectedMedia.name}</span>
|
||||
{dims && (
|
||||
<span className="mono text-[10px] px-1.5 py-0.5 border border-border-hair text-text-secondary">
|
||||
{dims.w}×{dims.h} · {fps} FPS
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="micro">{t('annotations.zoom')}</span>
|
||||
<span className="mono text-[11px] text-text-primary">{Math.round(zoom * 100)}%</span>
|
||||
<span className="mx-2 h-4 w-px bg-border-hair" />
|
||||
<span className="micro">{t('annotations.cursor')}</span>
|
||||
<span className="mono text-[11px] text-text-primary">
|
||||
{cursor ? `${cursor.x.toFixed(3)}, ${cursor.y.toFixed(3)}` : '—'}
|
||||
</span>
|
||||
<span className="mx-2 h-4 w-px bg-border-hair" />
|
||||
<span className="mono text-[11px] text-text-secondary">{detectionsLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas area */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
{selectedMedia && isVideo && (
|
||||
<VideoPlayer
|
||||
ref={videoPlayerRef}
|
||||
media={selectedMedia}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
onPlayingChange={setIsPlaying}
|
||||
onDurationChange={setDuration}
|
||||
onMutedChange={setMuted}
|
||||
>
|
||||
<CanvasEditor
|
||||
ref={canvasRef}
|
||||
@@ -264,6 +385,8 @@ export default function AnnotationsPage() {
|
||||
selectedClassNum={selectedClassNum}
|
||||
currentTime={currentTime}
|
||||
annotations={annotations}
|
||||
onZoomChange={setZoom}
|
||||
onCursorChange={(x, y) => setCursor({ x, y })}
|
||||
/>
|
||||
</VideoPlayer>
|
||||
)}
|
||||
@@ -277,18 +400,178 @@ export default function AnnotationsPage() {
|
||||
selectedClassNum={selectedClassNum}
|
||||
currentTime={currentTime}
|
||||
annotations={annotations}
|
||||
onZoomChange={setZoom}
|
||||
onCursorChange={(x, y) => setCursor({ x, y })}
|
||||
/>
|
||||
)}
|
||||
{!selectedMedia && (
|
||||
<div className="flex-1 flex items-center justify-center text-az-muted text-sm">
|
||||
Select a media file to start
|
||||
<div className="absolute inset-0 flex items-center justify-center text-text-muted text-sm">
|
||||
{t('annotations.selectMedia')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Detection floating banner */}
|
||||
{aiDetecting && (
|
||||
<div className="absolute top-6 right-6 ai-banner px-3 py-2 w-72">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="live-dot" />
|
||||
<span className="micro text-accent-cyan">{t('annotations.detectInProgress')}</span>
|
||||
<span className="ml-auto mono text-[10px] text-text-muted">{aiElapsed.toFixed(1)}s</span>
|
||||
</div>
|
||||
<div className="mono text-[10px] space-y-0.5 text-text-secondary">
|
||||
{aiLog.map((line, i) => <div key={i}>{line}</div>)}
|
||||
</div>
|
||||
<div className="mt-2 h-[2px] bg-black/40 overflow-hidden">
|
||||
<div style={{ height: '100%', width: `${aiProgress * 100}%`, background: 'var(--accent-cyan)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div onMouseDown={rightPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
||||
<div style={{ width: rightPanel.width }} className="bg-az-panel border-l border-az-border flex flex-col shrink-0">
|
||||
{/* Scrubber + Controls */}
|
||||
{selectedMedia && isVideo && (
|
||||
<div className="border-t border-border-hair bg-surface-1 shrink-0">
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<Scrubber
|
||||
current={currentTime}
|
||||
duration={duration}
|
||||
marks={scrubberMarks}
|
||||
onSeek={t => { videoPlayerRef.current?.seek(t); setCurrentTime(t) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 pb-3 flex items-center gap-1.5 min-w-0 whitespace-nowrap overflow-hidden">
|
||||
<div className="flex items-center gap-1 p-1 border border-border-hair rounded-[2px]">
|
||||
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.previousMedia')}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.back5s')} onClick={() => seekRel(-5)}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M11 18V6l-8.5 6zM22 18V6l-8.5 6z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
className="ibtn"
|
||||
title={isPlaying ? t('annotations.pause') : t('annotations.play')}
|
||||
onClick={togglePlay}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
background: isPlaying ? 'rgba(255,157,61,0.12)' : 'transparent',
|
||||
color: isPlaying ? 'var(--accent-amber)' : undefined,
|
||||
borderColor: isPlaying ? 'var(--accent-amber)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
{isPlaying
|
||||
? <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>
|
||||
: <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>}
|
||||
</button>
|
||||
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.forward5s')} onClick={() => seekRel(5)}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M13 6v12l8.5-6zM2 6v12l8.5-6z"/></svg>
|
||||
</button>
|
||||
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.nextMedia')}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2zM6 18l8.5-6L6 6z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="micro">{t('annotations.frameStep')}</span>
|
||||
<div className="flex items-center gap-1 p-1 border border-border-hair rounded-[2px]">
|
||||
{FRAME_STEPS.map(n => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => stepFrames(n)}
|
||||
className="ibtn mono"
|
||||
style={{ width: 30, height: 28, fontSize: 10, border: 0, background: 'transparent', letterSpacing: 0 }}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!detections.length}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
|
||||
{t('annotations.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => canvasRef.current?.deleteSelected()}
|
||||
disabled={!detections.length}
|
||||
className="btn btn-danger-ghost"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
||||
{t('annotations.delete')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => canvasRef.current?.deleteAll()}
|
||||
disabled={!detections.length}
|
||||
className="btn btn-danger-ghost"
|
||||
title={t('annotations.deleteAllTitle')}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11l4 6M14 11l-4 6"/></svg>
|
||||
{t('annotations.deleteAll')}
|
||||
</button>
|
||||
|
||||
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||
|
||||
<button
|
||||
onClick={handleAiDetect}
|
||||
disabled={!selectedMedia || aiDetecting}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7V3h4"/><path d="M17 3h4v4"/><path d="M21 17v4h-4"/><path d="M7 21H3v-4"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/></svg>
|
||||
{t('annotations.detect')}
|
||||
<span className="ml-1 mono opacity-70" style={{ fontSize: 9 }}>[R]</span>
|
||||
</button>
|
||||
|
||||
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button className="ibtn" style={{ width: 28, height: 28 }} title={t('annotations.mute')} onClick={toggleMute}>
|
||||
{muted
|
||||
? <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.21.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.95 8.95 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.17v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
|
||||
: <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z"/></svg>}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="vol"
|
||||
min={0}
|
||||
max={100}
|
||||
value={Math.round(volume * 100)}
|
||||
onChange={e => onVolumeChange(Number(e.target.value) / 100)}
|
||||
/>
|
||||
<span className="mono text-[10px] text-text-muted" style={{ width: 24 }}>{Math.round(volume * 100)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="px-4 h-7 flex items-center border-t border-border-hair bg-surface-0">
|
||||
<span className="mono text-[11px] text-text-primary">{formatTime(currentTime, true)}</span>
|
||||
<span className="mono text-[11px] mx-1.5 text-text-muted">/</span>
|
||||
<span className="mono text-[11px] text-text-secondary">{formatTime(duration, true)}</span>
|
||||
<span className="mx-3 h-4 w-px bg-border-hair" />
|
||||
<span className="micro">{t('annotations.frame')}</span>
|
||||
<span className="mono text-[11px] ml-1.5 text-text-primary">{currentFrame} / {totalFrames}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photo-only controls row (save/delete/AI detect) */}
|
||||
{selectedMedia && !isVideo && (
|
||||
<div className="border-t border-border-hair bg-surface-1 shrink-0 px-4 py-2 flex items-center gap-3">
|
||||
<button onClick={handleSave} disabled={!detections.length} className="btn btn-secondary">{t('annotations.save')}</button>
|
||||
<button onClick={() => canvasRef.current?.deleteSelected()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.delete')}</button>
|
||||
<button onClick={() => canvasRef.current?.deleteAll()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.deleteAll')}</button>
|
||||
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||
<button onClick={handleAiDetect} disabled={!selectedMedia || aiDetecting} className="btn btn-primary">{t('annotations.detect')}</button>
|
||||
<span className="ml-auto mono text-[11px] text-text-muted">{detectionsLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT SIDEBAR */}
|
||||
<div style={{ width: 208 }} className="bg-surface-1 flex flex-col shrink-0 border-l border-border-hair">
|
||||
<AnnotationsSidebar
|
||||
media={selectedMedia}
|
||||
annotations={annotations}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FaDownload } from 'react-icons/fa'
|
||||
import { api, createSSE, endpoints } from '../../api'
|
||||
import { getClassColor } from '../../class-colors'
|
||||
import { getClassColor, getClassNameFallback, hexToRgba } from '../../class-colors'
|
||||
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
||||
|
||||
interface Props {
|
||||
@@ -14,10 +14,46 @@ interface Props {
|
||||
onDownload?: (ann: AnnotationListItem) => void
|
||||
}
|
||||
|
||||
function getRowGradient(ann: AnnotationListItem): string {
|
||||
if (ann.detections.length === 0) {
|
||||
return 'linear-gradient(90deg, rgba(221,221,221,0.10), rgba(221,221,221,0.04))'
|
||||
}
|
||||
if (ann.detections.length === 1) {
|
||||
const c = getClassColor(ann.detections[0].classNum)
|
||||
return `linear-gradient(90deg, ${hexToRgba(c, 0.55)} 0%, ${hexToRgba(c, 0.10)} 60%, transparent 100%)`
|
||||
}
|
||||
const n = ann.detections.length
|
||||
const bandWidth = 100 / n
|
||||
const stops: string[] = []
|
||||
ann.detections.forEach((d, i) => {
|
||||
const c = getClassColor(d.classNum)
|
||||
const start = i * bandWidth
|
||||
const mid = start + bandWidth * 0.6
|
||||
const end = (i + 1) * bandWidth
|
||||
stops.push(`${hexToRgba(c, 0.50)} ${start}%`)
|
||||
stops.push(`${hexToRgba(c, 0.10)} ${mid}%`)
|
||||
if (i < n - 1) stops.push(`${hexToRgba(c, 0.10)} ${end - 0.01}%`)
|
||||
})
|
||||
return `linear-gradient(90deg, ${stops.join(', ')})`
|
||||
}
|
||||
|
||||
interface ClassAgg { classNum: number; color: string; count: number }
|
||||
|
||||
function aggregateClasses(annotations: AnnotationListItem[]): ClassAgg[] {
|
||||
const counts = new Map<number, number>()
|
||||
for (const ann of annotations) {
|
||||
for (const d of ann.detections) {
|
||||
counts.set(d.classNum, (counts.get(d.classNum) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([classNum, count]) => ({ classNum, color: getClassColor(classNum), count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 6)
|
||||
}
|
||||
|
||||
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate, onDownload }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [detecting, setDetecting] = useState(false)
|
||||
const [detectLog, setDetectLog] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!media) return
|
||||
@@ -30,85 +66,105 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
||||
})
|
||||
}, [media, onAnnotationsUpdate])
|
||||
|
||||
const handleDetect = async () => {
|
||||
if (!media) return
|
||||
setDetecting(true)
|
||||
setDetectLog(['Starting AI detection...'])
|
||||
try {
|
||||
await api.post(endpoints.detect.media(media.id))
|
||||
setDetectLog(prev => [...prev, 'Detection complete.'])
|
||||
} catch (e: any) {
|
||||
setDetectLog(prev => [...prev, `Error: ${e.message}`])
|
||||
}
|
||||
}
|
||||
const totals = useMemo(() => ({
|
||||
total: annotations.length,
|
||||
empty: annotations.filter(a => a.detections.length === 0).length,
|
||||
}), [annotations])
|
||||
|
||||
const getRowGradient = (ann: AnnotationListItem) => {
|
||||
if (ann.detections.length === 0) return 'rgba(221,221,221,0.25)'
|
||||
const stops = ann.detections.map((d, i) => {
|
||||
const pct = (i / Math.max(ann.detections.length - 1, 1)) * 100
|
||||
const alpha = Math.min(1, d.confidence)
|
||||
return `${getClassColor(d.classNum)}${Math.round(alpha * 40).toString(16).padStart(2, '0')} ${pct}%`
|
||||
})
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`
|
||||
}
|
||||
const classDist = useMemo(() => aggregateClasses(annotations), [annotations])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-2 border-b border-az-border flex items-center justify-between gap-1">
|
||||
<span className="text-xs font-semibold text-az-muted">{t('annotations.title')}</span>
|
||||
<div className="flex flex-col h-full bg-surface-1">
|
||||
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="sect-head">{t('annotations.title')}</span>
|
||||
<span className="mono text-[10px] text-text-muted">{String(annotations.length).padStart(2, '0')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleDetect}
|
||||
disabled={!media}
|
||||
className="text-xs bg-az-blue text-white px-2 py-0.5 rounded disabled:opacity-50"
|
||||
>
|
||||
{t('annotations.detect')}
|
||||
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.filter')}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="22 3 2 3 10 12.5 10 19 14 21 14 12.5"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedAnnotation && onDownload?.(selectedAnnotation)}
|
||||
disabled={!selectedAnnotation}
|
||||
title="Download annotation"
|
||||
className="text-xs bg-az-orange text-white p-1 rounded disabled:opacity-50"
|
||||
>
|
||||
<FaDownload size={12} />
|
||||
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.sort')}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 6h13M3 12h9M3 18h5M17 8l4-4 4 4M21 4v16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{annotations.map(ann => (
|
||||
<div className="grid grid-cols-[44px_1fr_auto] gap-2 px-3 h-6 items-center border-b border-border-hair">
|
||||
<span className="micro">{t('annotations.colTime')}</span>
|
||||
<span className="micro">{t('annotations.colClass')}</span>
|
||||
<span className="micro">{t('annotations.colConf')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{annotations.map(ann => {
|
||||
const isSelected = selectedAnnotation?.id === ann.id
|
||||
const isEmpty = ann.detections.length === 0
|
||||
const first = ann.detections[0]
|
||||
const extra = ann.detections.length > 1 ? ` +${ann.detections.length - 1}` : ''
|
||||
const maxConf = ann.detections.reduce((m, d) => Math.max(m, d.confidence ?? 0), 0)
|
||||
const className = first ? (first.label || getClassNameFallback(first.classNum)) : ''
|
||||
return (
|
||||
<div
|
||||
key={ann.id}
|
||||
onClick={() => onSelect(ann)}
|
||||
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs ${
|
||||
selectedAnnotation?.id === ann.id ? 'ring-1 ring-az-orange ring-inset' : ''
|
||||
}`}
|
||||
style={{ background: getRowGradient(ann) }}
|
||||
className={`ann-row${isSelected ? ' active' : ''}`}
|
||||
style={{ ['--row-grad' as string]: getRowGradient(ann) }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-az-text font-mono">{ann.time || '—'}</span>
|
||||
<span className="text-az-muted">{ann.detections.length > 0 ? ann.detections[0].label : '—'}</span>
|
||||
<span className={`mono text-[11px] ${isSelected ? 'text-accent-amber font-semibold' : isEmpty ? 'text-text-muted' : 'text-text-secondary'}`}>
|
||||
{ann.time || '—'}
|
||||
</span>
|
||||
{isEmpty
|
||||
? <span className="text-text-muted italic">{t('annotations.emptyFrame')}</span>
|
||||
: <span className={`truncate ${isSelected ? 'text-text-primary font-semibold' : 'text-text-primary'}`}>{className}{extra}</span>
|
||||
}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isSelected && !isEmpty && onDownload && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onDownload(ann) }}
|
||||
className="ibtn"
|
||||
style={{ width: 18, height: 18 }}
|
||||
title="Download annotation"
|
||||
>
|
||||
<FaDownload size={9} />
|
||||
</button>
|
||||
)}
|
||||
<span className={`mono text-[10px] ${isEmpty ? 'text-text-muted' : isSelected ? 'text-accent-amber' : 'text-text-secondary'}`}>
|
||||
{isEmpty ? '—' : `${Math.round(maxConf * 100)}%`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{annotations.length === 0 && (
|
||||
<div className="p-2 text-az-muted text-xs text-center">{t('common.noData')}</div>
|
||||
<div className="p-3 text-text-muted text-xs text-center">{t('common.noData')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detecting && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]">
|
||||
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-96 max-h-80 flex flex-col">
|
||||
<h3 className="text-white font-semibold mb-2">{t('annotations.detect')}</h3>
|
||||
<div className="flex-1 overflow-y-auto bg-az-bg rounded p-2 text-xs text-az-text font-mono space-y-0.5 mb-2">
|
||||
{detectLog.map((line, i) => <div key={i}>{line}</div>)}
|
||||
<div className="border-t border-border-hair px-3 py-2.5 bg-surface-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="micro">{t('annotations.summary')}</span>
|
||||
<span className="mono text-[10px] text-text-muted">
|
||||
{t('annotations.annCount', { count: totals.total })} · {t('annotations.emptyCount', { count: totals.empty })}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => setDetecting(false)} className="self-end text-xs bg-az-border text-az-text px-3 py-1 rounded">
|
||||
Close
|
||||
</button>
|
||||
{classDist.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 h-2">
|
||||
{classDist.map(c => (
|
||||
<span key={c.classNum} style={{ flex: c.count, background: c.color, height: '100%' }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 mono text-[10px] text-text-muted">
|
||||
{classDist.map(c => (
|
||||
<span key={c.classNum} className="flex items-center gap-1">
|
||||
<span style={{ color: c.color }}>■</span> {c.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHand
|
||||
import { endpoints } from '../../api'
|
||||
import { MediaType } from '../../types'
|
||||
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
|
||||
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from '../../class-colors'
|
||||
import { getClassColor, getClassNameFallback, hexToRgba } from '../../class-colors'
|
||||
import { parseAnnotationTime } from './time'
|
||||
|
||||
interface Props {
|
||||
media: Media
|
||||
@@ -12,6 +13,8 @@ interface Props {
|
||||
selectedClassNum: number
|
||||
currentTime: number
|
||||
annotations: AnnotationListItem[]
|
||||
onZoomChange?: (zoom: number) => void
|
||||
onCursorChange?: (nx: number, ny: number) => void
|
||||
}
|
||||
|
||||
export interface CanvasEditorHandle {
|
||||
@@ -28,28 +31,60 @@ interface DragState {
|
||||
handle?: string
|
||||
}
|
||||
|
||||
interface LabelChip {
|
||||
leftPct: number
|
||||
topPct: number
|
||||
color: string
|
||||
name: string
|
||||
conf: number
|
||||
combatReady: boolean
|
||||
}
|
||||
|
||||
const HANDLE_SIZE = 6
|
||||
const MIN_BOX_SIZE = 12
|
||||
|
||||
const AFFILIATION_COLORS: Record<number, string> = {
|
||||
0: '#FFD700',
|
||||
1: '#228be6',
|
||||
2: '#fa5252',
|
||||
const HOSTILE_HEXES = new Set(['#FF0000', '#FFFF00', '#FF00FF', '#800000', '#808000', '#800080'])
|
||||
const FRIENDLY_HEXES = new Set(['#00FF00', '#0000FF', '#00FFFF', '#008000', '#000080', '#008080'])
|
||||
|
||||
function affiliationIcon(hex: string) {
|
||||
const up = hex.toUpperCase()
|
||||
if (HOSTILE_HEXES.has(up)) {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
|
||||
<polygon points="5.5,0.7 10.3,5.5 5.5,10.3 0.7,5.5" fill="#FF0000" stroke="#0A0D10" strokeWidth="1"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (FRIENDLY_HEXES.has(up)) {
|
||||
return (
|
||||
<svg width="11" height="9" viewBox="0 0 11 9" aria-hidden="true">
|
||||
<rect x="0.5" y="0.5" width="10" height="8" fill="#87CEEB" stroke="#0A0D10" strokeWidth="1"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||
<circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor" strokeWidth="1.2"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor(
|
||||
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations },
|
||||
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations, onZoomChange, onCursorChange },
|
||||
ref,
|
||||
) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const imgRef = useRef<HTMLImageElement | null>(null)
|
||||
const cursorRafRef = useRef<number | null>(null)
|
||||
const cursorLatestRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||
const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null)
|
||||
const [imgSize, setImgSize] = useState({ w: 0, h: 0 })
|
||||
const [labelChips, setLabelChips] = useState<LabelChip[]>([])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
deleteSelected() {
|
||||
@@ -70,7 +105,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
|
||||
const loadImage = useCallback(() => {
|
||||
if (isVideo) {
|
||||
// Use natural size based on container; no image load
|
||||
imgRef.current = null
|
||||
return
|
||||
}
|
||||
@@ -116,16 +150,45 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
return () => ro.disconnect()
|
||||
}, [isVideo])
|
||||
|
||||
const toCanvas = useCallback((nx: number, ny: number) => ({
|
||||
x: nx * imgSize.w * zoom + pan.x,
|
||||
y: ny * imgSize.h * zoom + pan.y,
|
||||
}), [imgSize, zoom, pan])
|
||||
useEffect(() => { onZoomChange?.(zoom) }, [zoom, onZoomChange])
|
||||
|
||||
// Cancel any pending cursor RAF on unmount so the callback can't fire after.
|
||||
useEffect(() => () => {
|
||||
if (cursorRafRef.current != null) {
|
||||
cancelAnimationFrame(cursorRafRef.current)
|
||||
cursorRafRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fromCanvas = useCallback((cx: number, cy: number) => ({
|
||||
x: Math.max(0, Math.min(1, (cx - pan.x) / (imgSize.w * zoom))),
|
||||
y: Math.max(0, Math.min(1, (cy - pan.y) / (imgSize.h * zoom))),
|
||||
}), [imgSize, zoom, pan])
|
||||
|
||||
const getTimeWindowDetections = useCallback((): Detection[] => {
|
||||
if (media.mediaType !== MediaType.Video) return []
|
||||
if (annotation) return []
|
||||
const timeTicks = currentTime * 10_000_000
|
||||
return annotations
|
||||
.filter(a => {
|
||||
const sec = parseAnnotationTime(a.time)
|
||||
if (sec == null) return false
|
||||
return Math.abs(sec * 10_000_000 - timeTicks) < 2_000_000
|
||||
})
|
||||
.flatMap(a => a.detections)
|
||||
}, [media.mediaType, annotation, annotations, currentTime])
|
||||
|
||||
const getHandles = (x: number, y: number, w: number, h: number) => [
|
||||
{ x, y, cursor: 'nw-resize', name: 'tl' },
|
||||
{ x: x + w / 2, y, cursor: 'n-resize', name: 'tc' },
|
||||
{ x: x + w, y, cursor: 'ne-resize', name: 'tr' },
|
||||
{ x: x + w, y: y + h / 2, cursor: 'e-resize', name: 'mr' },
|
||||
{ x: x + w, y: y + h, cursor: 'se-resize', name: 'br' },
|
||||
{ x: x + w / 2, y: y + h, cursor: 's-resize', name: 'bc' },
|
||||
{ x, y: y + h, cursor: 'sw-resize', name: 'bl' },
|
||||
{ x, y: y + h / 2, cursor: 'w-resize', name: 'ml' },
|
||||
]
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
@@ -146,9 +209,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
|
||||
const timeWindowDets = getTimeWindowDetections()
|
||||
const allDets = [...detections, ...timeWindowDets]
|
||||
const chips: LabelChip[] = []
|
||||
|
||||
allDets.forEach((det, i) => {
|
||||
const isSelected = selected.has(i) && i < detections.length
|
||||
const isOwn = i < detections.length
|
||||
const isSelected = selected.has(i) && isOwn
|
||||
const cx = (det.centerX - det.width / 2) * imgSize.w * zoom + pan.x
|
||||
const cy = (det.centerY - det.height / 2) * imgSize.h * zoom + pan.y
|
||||
const w = det.width * imgSize.w * zoom
|
||||
@@ -160,45 +225,51 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
ctx.strokeRect(cx, cy, w, h)
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.globalAlpha = 0.1
|
||||
ctx.globalAlpha = 0.06
|
||||
ctx.fillRect(cx, cy, w, h)
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
const name = det.label || getClassNameFallback(det.classNum)
|
||||
const modeSuffix = getPhotoModeSuffix(det.classNum)
|
||||
const confSuffix = det.confidence < 0.995 ? ` ${(det.confidence * 100).toFixed(0)}%` : ''
|
||||
const label = `${name}${modeSuffix}${confSuffix}`
|
||||
|
||||
ctx.font = '11px sans-serif'
|
||||
const metrics = ctx.measureText(label)
|
||||
const padX = 3
|
||||
const labelH = 14
|
||||
const labelW = metrics.width + padX * 2
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(cx, cy - labelH, labelW, labelH)
|
||||
ctx.fillStyle = '#000'
|
||||
ctx.fillText(label, cx + padX, cy - 3)
|
||||
|
||||
if (det.combatReadiness === 1) {
|
||||
ctx.fillStyle = '#40c057'
|
||||
// Corner brackets — 8px legs (skipped in environments lacking path API, e.g. JSDOM)
|
||||
if (typeof ctx.moveTo === 'function' && typeof ctx.beginPath === 'function') {
|
||||
const legLen = 8
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx + w - 6, cy + 6, 3, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.moveTo(cx, cy + legLen); ctx.lineTo(cx, cy); ctx.lineTo(cx + legLen, cy)
|
||||
ctx.moveTo(cx + w - legLen, cy); ctx.lineTo(cx + w, cy); ctx.lineTo(cx + w, cy + legLen)
|
||||
ctx.moveTo(cx + w, cy + h - legLen); ctx.lineTo(cx + w, cy + h); ctx.lineTo(cx + w - legLen, cy + h)
|
||||
ctx.moveTo(cx + legLen, cy + h); ctx.lineTo(cx, cy + h); ctx.lineTo(cx, cy + h - legLen)
|
||||
ctx.strokeStyle = color
|
||||
ctx.stroke()
|
||||
ctx.lineWidth = 1
|
||||
}
|
||||
|
||||
if (isOwn) {
|
||||
const container = containerRef.current
|
||||
if (container && container.clientWidth && container.clientHeight) {
|
||||
chips.push({
|
||||
leftPct: (cx / container.clientWidth) * 100,
|
||||
topPct: (cy / container.clientHeight) * 100,
|
||||
color,
|
||||
name: det.label || getClassNameFallback(det.classNum),
|
||||
conf: det.confidence,
|
||||
combatReady: det.combatReadiness === 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
const handles = getHandles(cx, cy, w, h)
|
||||
handles.forEach(hp => {
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fillStyle = '#FF9D3D'
|
||||
ctx.fillRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
||||
ctx.strokeStyle = color
|
||||
ctx.strokeStyle = '#0A0D10'
|
||||
ctx.strokeRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (drawRect) {
|
||||
ctx.strokeStyle = '#fd7e14'
|
||||
ctx.strokeStyle = '#FF9D3D'
|
||||
ctx.lineWidth = 1
|
||||
ctx.setLineDash([4, 4])
|
||||
ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h)
|
||||
@@ -206,7 +277,23 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}, [detections, selected, zoom, pan, imgSize, drawRect, currentTime, annotations])
|
||||
|
||||
// Only setState when chips actually changed — prevents a render storm
|
||||
// during video playback (draw runs on every time-update; without this
|
||||
// guard React would commit a new array reference on every paint).
|
||||
setLabelChips(prev => {
|
||||
if (prev.length !== chips.length) return chips
|
||||
for (let i = 0; i < chips.length; i++) {
|
||||
const a = prev[i], b = chips[i]
|
||||
if (
|
||||
a.leftPct !== b.leftPct || a.topPct !== b.topPct ||
|
||||
a.color !== b.color || a.name !== b.name ||
|
||||
a.conf !== b.conf || a.combatReady !== b.combatReady
|
||||
) return chips
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}, [detections, selected, zoom, pan, imgSize, drawRect, isVideo, getTimeWindowDetections])
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(draw)
|
||||
@@ -221,31 +308,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
return () => obs.disconnect()
|
||||
}, [draw])
|
||||
|
||||
const getTimeWindowDetections = (): Detection[] => {
|
||||
if (media.mediaType !== MediaType.Video) return []
|
||||
if (annotation) return []
|
||||
const timeTicks = currentTime * 10_000_000
|
||||
return annotations
|
||||
.filter(a => {
|
||||
if (!a.time) return false
|
||||
const parts = a.time.split(':').map(Number)
|
||||
const annTime = (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 10_000_000
|
||||
return Math.abs(annTime - timeTicks) < 2_000_000
|
||||
})
|
||||
.flatMap(a => a.detections)
|
||||
}
|
||||
|
||||
const getHandles = (x: number, y: number, w: number, h: number) => [
|
||||
{ x, y, cursor: 'nw-resize', name: 'tl' },
|
||||
{ x: x + w / 2, y, cursor: 'n-resize', name: 'tc' },
|
||||
{ x: x + w, y, cursor: 'ne-resize', name: 'tr' },
|
||||
{ x: x + w, y: y + h / 2, cursor: 'e-resize', name: 'mr' },
|
||||
{ x: x + w, y: y + h, cursor: 'se-resize', name: 'br' },
|
||||
{ x: x + w / 2, y: y + h, cursor: 's-resize', name: 'bc' },
|
||||
{ x, y: y + h, cursor: 'sw-resize', name: 'bl' },
|
||||
{ x, y: y + h / 2, cursor: 'w-resize', name: 'ml' },
|
||||
]
|
||||
|
||||
const hitTest = (cx: number, cy: number) => {
|
||||
for (let i = detections.length - 1; i >= 0; i--) {
|
||||
const d = detections[i]
|
||||
@@ -298,12 +360,28 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!dragState) return
|
||||
const rect = canvasRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const mx = e.clientX - rect.left
|
||||
const my = e.clientY - rect.top
|
||||
|
||||
if (onCursorChange && imgSize.w && imgSize.h) {
|
||||
const nx = (mx - pan.x) / (imgSize.w * zoom)
|
||||
const ny = (my - pan.y) / (imgSize.h * zoom)
|
||||
if (nx >= 0 && nx <= 1 && ny >= 0 && ny <= 1) {
|
||||
cursorLatestRef.current = { x: nx, y: ny }
|
||||
if (cursorRafRef.current == null) {
|
||||
cursorRafRef.current = requestAnimationFrame(() => {
|
||||
const v = cursorLatestRef.current
|
||||
cursorRafRef.current = null
|
||||
if (v) onCursorChange(v.x, v.y)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!dragState) return
|
||||
|
||||
if (dragState.type === 'draw') {
|
||||
setDrawRect({
|
||||
x: Math.min(dragState.startX, mx),
|
||||
@@ -415,6 +493,25 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
onMouseLeave={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
/>
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{labelChips.map((chip, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bbox-label"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${chip.leftPct}%`,
|
||||
top: `calc(${chip.topPct}% - 26px)`,
|
||||
borderColor: hexToRgba(chip.color, 0.6),
|
||||
}}
|
||||
>
|
||||
<span style={{ color: chip.color, display: 'inline-flex' }}>{affiliationIcon(chip.color)}</span>
|
||||
{chip.combatReady && <span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--accent-green)', display: 'inline-block' }} />}
|
||||
<span style={{ color: chip.color }}>{chip.name}</span>
|
||||
{chip.conf < 0.995 && <span className="conf">{(chip.conf * 100).toFixed(1)}%</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
const debouncedFilter = useDebounce(filter, 300)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchMedia = useCallback(async () => {
|
||||
const params = new URLSearchParams({ pageSize: '1000' })
|
||||
@@ -139,25 +140,20 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const filtered = media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase()))
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps({
|
||||
className: `flex-1 flex flex-col overflow-hidden ${isDragActive ? 'ring-2 ring-az-orange ring-inset' : ''}`,
|
||||
className: `flex flex-col flex-1 min-h-0 bg-surface-1${isDragActive ? ' ring-2 ring-accent-amber ring-inset' : ''}`,
|
||||
})}
|
||||
>
|
||||
{/* Dropzone hidden input */}
|
||||
<input {...getInputProps()} />
|
||||
<div className="p-2 border-b border-az-border flex gap-1">
|
||||
<input
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
placeholder={t('annotations.mediaList')}
|
||||
className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 pt-2 pb-2 flex gap-1">
|
||||
<label className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded text-center cursor-pointer hover:brightness-110">
|
||||
Open File
|
||||
|
||||
{/* Hidden file inputs */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
@@ -166,14 +162,6 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded hover:brightness-110"
|
||||
>
|
||||
Open Folder
|
||||
</button>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
@@ -184,25 +172,94 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
directory=""
|
||||
onChange={handleFolderInput}
|
||||
/>
|
||||
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="sect-head">{t('annotations.mediaList')}</span>
|
||||
<span className="mono text-[10px] text-text-muted">{filtered.length}</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase())).map(m => (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Upload file button */}
|
||||
<button
|
||||
type="button"
|
||||
className="ibtn"
|
||||
style={{ width: 22, height: 22 }}
|
||||
title={t('annotations.upload')}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* Open folder button */}
|
||||
<button
|
||||
type="button"
|
||||
className="ibtn"
|
||||
style={{ width: 22, height: 22 }}
|
||||
title="Open Folder"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter input row */}
|
||||
<div className="px-3 py-2 border-b border-border-hair shrink-0">
|
||||
<div className="relative">
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
|
||||
>
|
||||
<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>
|
||||
</svg>
|
||||
<input
|
||||
className="inp w-full pl-7"
|
||||
style={{ height: 28, padding: '0 10px 0 28px' }}
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
placeholder={t('annotations.filterByName')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{filtered.map(m => {
|
||||
const isActive = selectedMedia?.id === m.id
|
||||
const isVideo = m.mediaType === MediaType.Video
|
||||
const hasDuration = !!m.duration
|
||||
const durationColor = isActive
|
||||
? 'text-accent-amber'
|
||||
: hasDuration
|
||||
? 'text-text-secondary'
|
||||
: 'text-text-muted'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => handleSelect(m)}
|
||||
onContextMenu={e => { e.preventDefault(); setDeleteId(m.id) }}
|
||||
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs flex items-center gap-1.5 ${
|
||||
selectedMedia?.id === m.id ? 'bg-az-bg text-white' : ''
|
||||
} ${m.annotationCount > 0 ? 'bg-az-bg/50' : ''} text-az-text hover:bg-az-bg`}
|
||||
className={`media-row${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<span className={`font-mono text-[10px] px-1 rounded ${m.mediaType === MediaType.Video ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
|
||||
{m.mediaType === MediaType.Video ? 'V' : 'P'}
|
||||
{isVideo
|
||||
? <span className="chip-video">VIDEO</span>
|
||||
: <span className="chip-photo">PHOTO</span>
|
||||
}
|
||||
<span className={`truncate${isActive ? ' font-medium text-text-primary' : ' text-text-primary'}`}>
|
||||
{m.name}
|
||||
</span>
|
||||
<span className={`mono text-[11px] ${durationColor}`}>
|
||||
{m.duration ?? '—'}
|
||||
</span>
|
||||
<span className="truncate flex-1">{m.name}</span>
|
||||
{m.duration && <span className="text-az-muted">{m.duration}</span>}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteId}
|
||||
title={t('annotations.deleteMedia')}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export interface ScrubberMark {
|
||||
time: number
|
||||
color: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
current: number
|
||||
duration: number
|
||||
marks: ScrubberMark[]
|
||||
onSeek: (time: number) => void
|
||||
}
|
||||
|
||||
const TICK_PERCENTS = [0, 25, 50, 75, 100]
|
||||
|
||||
export default function Scrubber({ current, duration, marks, onSeek }: Props) {
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const safeDuration = duration > 0 ? duration : 1
|
||||
const pct = Math.max(0, Math.min(100, (current / safeDuration) * 100))
|
||||
|
||||
const seekFromClientX = useCallback((clientX: number) => {
|
||||
const el = trackRef.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(rect.width, clientX - rect.left))
|
||||
onSeek((x / rect.width) * safeDuration)
|
||||
}, [onSeek, safeDuration])
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
seekFromClientX(e.clientX)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return
|
||||
const move = (e: MouseEvent) => seekFromClientX(e.clientX)
|
||||
const up = () => setDragging(false)
|
||||
window.addEventListener('mousemove', move)
|
||||
window.addEventListener('mouseup', up)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', move)
|
||||
window.removeEventListener('mouseup', up)
|
||||
}
|
||||
}, [dragging, seekFromClientX])
|
||||
|
||||
return (
|
||||
<div ref={trackRef} className="scrub" onMouseDown={handleMouseDown}>
|
||||
<div className="fill" style={{ width: `${pct}%` }} />
|
||||
{TICK_PERCENTS.map(p => (
|
||||
<div key={p} className="tick" style={{ left: `${p}%` }} />
|
||||
))}
|
||||
{marks.map((m, i) => {
|
||||
const mpct = Math.max(0, Math.min(100, (m.time / safeDuration) * 100))
|
||||
return <div key={i} className="mark" style={{ left: `${mpct}%`, background: m.color }} />
|
||||
})}
|
||||
<div className="head" style={{ left: `${pct}%` }} />
|
||||
<div className="head-knob" style={{ left: `${pct}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +1,52 @@
|
||||
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
|
||||
import { endpoints } from '../../api'
|
||||
import type { Media } from '../../types'
|
||||
|
||||
interface Props {
|
||||
media: Media
|
||||
onTimeUpdate: (time: number) => void
|
||||
/** Fires when the <video> emits 'play'/'pause' (no polling needed). */
|
||||
onPlayingChange?: (playing: boolean) => void
|
||||
/** Fires when the <video> reports a valid duration. */
|
||||
onDurationChange?: (duration: number) => void
|
||||
/** Fires when the <video> mute state changes (incl. the M keyboard shortcut). */
|
||||
onMutedChange?: (muted: boolean) => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const STEP_BTN_CLASS = 'w-9 h-8 flex items-center justify-center bg-az-bg rounded hover:bg-az-border text-az-text text-xs font-mono'
|
||||
const ICON_BTN_CLASS = 'w-10 h-10 flex items-center justify-center bg-az-bg rounded hover:bg-az-border text-white'
|
||||
|
||||
export interface VideoPlayerHandle {
|
||||
seek: (seconds: number) => void
|
||||
getVideoElement: () => HTMLVideoElement | null
|
||||
play: () => void
|
||||
pause: () => void
|
||||
toggle: () => void
|
||||
isPlaying: () => boolean
|
||||
frameStep: (deltaFrames: number) => void
|
||||
getDuration: () => number
|
||||
getCurrentTime: () => number
|
||||
getFrameRate: () => number
|
||||
getCurrentFrame: () => number
|
||||
getTotalFrames: () => number
|
||||
getVolume: () => number
|
||||
setVolume: (v: number) => void
|
||||
toggleMute: () => void
|
||||
isMuted: () => boolean
|
||||
}
|
||||
|
||||
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({ media, onTimeUpdate, children }, ref) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const FPS = 30
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
seek(seconds: number) {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = seconds
|
||||
setCurrentTime(seconds)
|
||||
}
|
||||
},
|
||||
getVideoElement() {
|
||||
return videoRef.current
|
||||
},
|
||||
}))
|
||||
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||
media, onTimeUpdate, onPlayingChange, onDurationChange, onMutedChange, children,
|
||||
}, ref) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [muted, setMuted] = useState(false)
|
||||
|
||||
const notifyMuted = useCallback((m: boolean) => {
|
||||
setMuted(m)
|
||||
onMutedChange?.(m)
|
||||
}, [onMutedChange])
|
||||
|
||||
const videoUrl = media.path.startsWith('blob:')
|
||||
? media.path
|
||||
: endpoints.annotations.mediaFile(media.id)
|
||||
@@ -44,24 +54,47 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||
const stepFrames = useCallback((count: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const fps = 30
|
||||
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + count / fps))
|
||||
video.currentTime = Math.max(0, Math.min(video.duration || 0, video.currentTime + count / FPS))
|
||||
}, [])
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
if (v.paused) { v.play(); setPlaying(true) }
|
||||
else { v.pause(); setPlaying(false) }
|
||||
if (v.paused) v.play().catch(() => {})
|
||||
else v.pause()
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
useImperativeHandle(ref, () => ({
|
||||
seek(seconds: number) {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
v.pause()
|
||||
v.currentTime = 0
|
||||
setPlaying(false)
|
||||
}, [])
|
||||
if (v) v.currentTime = seconds
|
||||
},
|
||||
getVideoElement() { return videoRef.current },
|
||||
play() { videoRef.current?.play().catch(() => {}) },
|
||||
pause() { videoRef.current?.pause() },
|
||||
toggle() { togglePlay() },
|
||||
isPlaying() { return !!videoRef.current && !videoRef.current.paused },
|
||||
frameStep(delta) { stepFrames(delta) },
|
||||
getDuration() { return videoRef.current?.duration ?? 0 },
|
||||
getCurrentTime() { return videoRef.current?.currentTime ?? 0 },
|
||||
getFrameRate() { return FPS },
|
||||
getCurrentFrame() { return Math.floor((videoRef.current?.currentTime ?? 0) * FPS) },
|
||||
getTotalFrames() { return Math.floor((videoRef.current?.duration ?? 0) * FPS) },
|
||||
getVolume() { return videoRef.current?.volume ?? 1 },
|
||||
setVolume(v) {
|
||||
const el = videoRef.current
|
||||
if (!el) return
|
||||
el.volume = Math.max(0, Math.min(1, v))
|
||||
if (el.volume > 0 && el.muted) { el.muted = false; notifyMuted(false) }
|
||||
},
|
||||
toggleMute() {
|
||||
const el = videoRef.current
|
||||
if (!el) return
|
||||
el.muted = !el.muted
|
||||
notifyMuted(el.muted)
|
||||
},
|
||||
isMuted() { return !!videoRef.current?.muted },
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -70,22 +103,22 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||
case ' ': e.preventDefault(); togglePlay(); break
|
||||
case 'ArrowLeft': e.preventDefault(); stepFrames(e.ctrlKey ? -150 : -1); break
|
||||
case 'ArrowRight': e.preventDefault(); stepFrames(e.ctrlKey ? 150 : 1); break
|
||||
case 'm': case 'M': setMuted(m => !m); break
|
||||
case 'm': case 'M': {
|
||||
const v = videoRef.current
|
||||
if (v) { v.muted = !v.muted; notifyMuted(v.muted) }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [togglePlay, stepFrames])
|
||||
|
||||
const formatTime = (s: number) => {
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = Math.floor(s % 60)
|
||||
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-black flex flex-col flex-1 min-h-0">
|
||||
{error && <div className="bg-az-red/80 text-white text-xs px-2 py-1">{error}</div>}
|
||||
<div className="flex flex-col flex-1 min-h-0 bg-surface-0">
|
||||
{error && (
|
||||
<div className="bg-surface-1 border-b border-border-hair text-accent-red text-xs px-3 py-1">{error}</div>
|
||||
)}
|
||||
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -94,76 +127,18 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||
controls={false}
|
||||
playsInline
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onTimeUpdate={e => {
|
||||
const t = (e.target as HTMLVideoElement).currentTime
|
||||
setCurrentTime(t)
|
||||
onTimeUpdate(t)
|
||||
}}
|
||||
onLoadedMetadata={e => {
|
||||
setDuration((e.target as HTMLVideoElement).duration)
|
||||
setError(null)
|
||||
onTimeUpdate={e => onTimeUpdate((e.target as HTMLVideoElement).currentTime)}
|
||||
onPlay={() => onPlayingChange?.(true)}
|
||||
onPause={() => onPlayingChange?.(false)}
|
||||
onDurationChange={e => {
|
||||
const d = (e.target as HTMLVideoElement).duration
|
||||
if (Number.isFinite(d)) onDurationChange?.(d)
|
||||
}}
|
||||
onLoadedMetadata={() => setError(null)}
|
||||
onError={() => setError(`Failed to load video (${media.name})`)}
|
||||
/>
|
||||
{children && <div className="absolute inset-0">{children}</div>}
|
||||
</div>
|
||||
{/* Progress row: time | slider | remaining */}
|
||||
<div className="flex items-center gap-3 bg-az-header px-4 py-1.5">
|
||||
<span className="text-white text-xs font-mono tabular-nums min-w-[40px] text-right">{formatTime(currentTime)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
step={0.01}
|
||||
value={currentTime}
|
||||
onChange={e => {
|
||||
const v = Number(e.target.value)
|
||||
setCurrentTime(v)
|
||||
if (videoRef.current) videoRef.current.currentTime = v
|
||||
}}
|
||||
className="flex-1 accent-az-orange h-1 cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #fd7e14 0%, #fd7e14 ${(currentTime / (duration || 1)) * 100}%, #495057 ${(currentTime / (duration || 1)) * 100}%, #495057 100%)`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-white text-xs font-mono tabular-nums min-w-[40px]">-{formatTime(Math.max(0, duration - currentTime))}</span>
|
||||
</div>
|
||||
|
||||
{/* Buttons row */}
|
||||
<div className="flex items-center justify-center gap-2 bg-az-header pb-2 flex-wrap">
|
||||
<button onClick={() => stepFrames(-1)} title="Previous frame" className={ICON_BTN_CLASS}>
|
||||
<FaStepBackward size={14} />
|
||||
</button>
|
||||
<button onClick={togglePlay} title={playing ? 'Pause' : 'Play'} className="w-10 h-10 flex items-center justify-center bg-az-orange rounded hover:brightness-110 text-white">
|
||||
{playing ? <FaPause size={14} /> : <FaPlay size={14} />}
|
||||
</button>
|
||||
<button onClick={() => stepFrames(1)} title="Next frame" className={ICON_BTN_CLASS}>
|
||||
<FaStepForward size={14} />
|
||||
</button>
|
||||
<button onClick={stop} title="Stop" className={ICON_BTN_CLASS}>
|
||||
<FaStop size={14} />
|
||||
</button>
|
||||
|
||||
<span className="w-px h-8 bg-az-border mx-1" />
|
||||
|
||||
{[1, 5, 10, 30, 60].map(n => (
|
||||
<button key={`prev-${n}`} onClick={() => stepFrames(-n)} title={`-${n} frames`} className={STEP_BTN_CLASS}>
|
||||
-{n}
|
||||
</button>
|
||||
))}
|
||||
<span className="w-px h-8 bg-az-border mx-1" />
|
||||
{[1, 5, 10, 30, 60].map(n => (
|
||||
<button key={`next-${n}`} onClick={() => stepFrames(n)} title={`+${n} frames`} className={STEP_BTN_CLASS}>
|
||||
+{n}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<span className="w-px h-8 bg-az-border mx-1" />
|
||||
|
||||
<button onClick={() => setMuted(m => !m)} title={muted ? 'Unmute' : 'Mute'} className={ICON_BTN_CLASS}>
|
||||
{muted ? <FaVolumeMute size={14} /> : <FaVolumeUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Annotation time helpers — shared between AnnotationsPage and CanvasEditor.
|
||||
* Annotation `time` is the backend's "HH:MM:SS.mmm" tick representation; this
|
||||
* module owns the conversion to/from seconds + display formatting.
|
||||
*/
|
||||
|
||||
export function parseAnnotationTime(t: string | null | undefined): number | null {
|
||||
if (!t) return null
|
||||
const parts = t.split(':').map(Number)
|
||||
if (parts.length !== 3) return null
|
||||
if (parts.some(p => !Number.isFinite(p))) return null
|
||||
return (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0)
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number, withMs = false): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const base = `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
if (!withMs) return base
|
||||
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
|
||||
return `${base}.${String(ms).padStart(3, '0')}`
|
||||
}
|
||||
|
||||
export function formatTicks(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}`
|
||||
}
|
||||
@@ -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)
|
||||
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) => {
|
||||
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="text-az-muted text-xs block mb-0.5">{label}</label>
|
||||
<label className="micro block mb-1">{t('admin.aircrafts.fieldModel')}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={value ?? ''}
|
||||
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>
|
||||
<FieldLabel label={label} hint={hint} />
|
||||
<input
|
||||
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}
|
||||
<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>
|
||||
<button onClick={() => handleToggleDefault(a)} className={`text-sm ${a.isDefault ? 'text-az-orange' : 'text-az-muted hover:text-az-orange'}`}>
|
||||
★
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
+126
-11
@@ -2,7 +2,7 @@
|
||||
"nav": {
|
||||
"flights": "Flights",
|
||||
"annotations": "Annotations",
|
||||
"dataset": "Dataset Explorer",
|
||||
"dataset": "Dataset",
|
||||
"admin": "Admin",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout"
|
||||
@@ -85,18 +85,47 @@
|
||||
},
|
||||
"annotations": {
|
||||
"title": "Annotations",
|
||||
"mediaList": "Media",
|
||||
"mediaList": "Media Files",
|
||||
"filterByName": "filter by name…",
|
||||
"upload": "Upload Files",
|
||||
"deleteMedia": "Delete media?",
|
||||
"detect": "AI Detect",
|
||||
"detectInProgress": "AI DETECTION IN PROGRESS",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"deleteAll": "Delete All",
|
||||
"deleteAllTitle": "Delete all on frame",
|
||||
"classes": "Detection Classes",
|
||||
"photoMode": "Photo Mode",
|
||||
"photoMode": "PhotoMode",
|
||||
"regular": "Regular",
|
||||
"winter": "Winter",
|
||||
"night": "Night"
|
||||
"night": "Night",
|
||||
"colName": "NAME",
|
||||
"colKey": "KEY",
|
||||
"colNum": "#",
|
||||
"colTime": "TIME",
|
||||
"colClass": "CLASS",
|
||||
"colConf": "CONF",
|
||||
"canvas": "Canvas",
|
||||
"zoom": "ZOOM",
|
||||
"cursor": "CURSOR",
|
||||
"frameStep": "FRAME STEP",
|
||||
"frame": "FRAME",
|
||||
"summary": "SUMMARY",
|
||||
"emptyFrame": "empty frame",
|
||||
"filter": "Filter",
|
||||
"sort": "Sort",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"previousMedia": "Previous media",
|
||||
"nextMedia": "Next media",
|
||||
"back5s": "Back 5s",
|
||||
"forward5s": "Forward 5s",
|
||||
"mute": "Mute",
|
||||
"selectMedia": "Select a media file to start",
|
||||
"annCount_one": "{{count}} ann",
|
||||
"annCount_other": "{{count}} ann",
|
||||
"emptyCount": "{{count}} empty"
|
||||
},
|
||||
"dataset": {
|
||||
"title": "Dataset Explorer",
|
||||
@@ -116,26 +145,112 @@
|
||||
"title": "Admin",
|
||||
"classes": {
|
||||
"title": "Detection Classes",
|
||||
"search": "Search class…",
|
||||
"add": "+ ADD",
|
||||
"colName": "Name",
|
||||
"colHex": "Hex",
|
||||
"colOps": "Ops",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"nameRequired": "Name is required",
|
||||
"maxSizeMustBePositive": "Max size must be a positive number",
|
||||
"updateFailed": "Update failed. Please try again."
|
||||
},
|
||||
"aiSettings": "AI Recognition Settings",
|
||||
"gpsSettings": "GPS Device Settings",
|
||||
"aircrafts": "Default Aircrafts",
|
||||
"users": "User Management",
|
||||
"addUser": "Add User",
|
||||
"deactivate": "Deactivate"
|
||||
"aiEngine": {
|
||||
"title": "AI Recognition Engine",
|
||||
"subtitle": "Detection model runtime parameters. Applied per-flight, hot-reloaded.",
|
||||
"framesToRecognize": "Frames To Recognize",
|
||||
"framesHint": "Number of consecutive frames the model averages before emitting a detection.",
|
||||
"minSeconds": "Min Seconds Between",
|
||||
"minSecondsHint": "Cooldown gap between successive inference calls on the same video stream.",
|
||||
"minConfidence": "Min Confidence",
|
||||
"minConfidenceHint": "Detections below this threshold are discarded before reaching the canvas.",
|
||||
"reset": "RESET",
|
||||
"apply": "APPLY",
|
||||
"lastRun": "LAST RUN",
|
||||
"frames": "FRAMES",
|
||||
"avgConf": "AVG CONF",
|
||||
"model": "MODEL",
|
||||
"loaded": "LOADED",
|
||||
"unitFR": "FR",
|
||||
"unitSec": "SEC"
|
||||
},
|
||||
"gpsDevice": {
|
||||
"title": "GPS Device Link",
|
||||
"subtitle": "Ground-station receiver feeding the GPS-Denied correction pipeline.",
|
||||
"address": "Device Address",
|
||||
"addressHint": "IPv4 endpoint or hostname of the GPS receiver bridge.",
|
||||
"port": "Device Port",
|
||||
"portHint": "UDP port the receiver streams NMEA sentences on.",
|
||||
"protocol": "Protocol",
|
||||
"protocolHint": "Wire format negotiated with the receiver. Switch only when the device is offline.",
|
||||
"ping": "PING",
|
||||
"reconnect": "RECONNECT",
|
||||
"apply": "APPLY",
|
||||
"connected": "CONNECTED",
|
||||
"fix": "FIX",
|
||||
"hdop": "HDOP",
|
||||
"lastPkt": "LAST PKT",
|
||||
"socket": "SOCKET"
|
||||
},
|
||||
"aircrafts": {
|
||||
"title": "Default Aircrafts",
|
||||
"legendPlane": "PLANE",
|
||||
"legendCopter": "COPTER",
|
||||
"legendFixedW": "FIXED-W",
|
||||
"add": "+ ADD AIRCRAFT",
|
||||
"addTitle": "Add Aircraft",
|
||||
"setDefault": "Set default",
|
||||
"default": "Default",
|
||||
"fieldModel": "Model",
|
||||
"fieldType": "Type",
|
||||
"fieldResolution": "Resolution",
|
||||
"fieldMaxMinutes": "Max minutes",
|
||||
"fieldDefault": "Set as default",
|
||||
"modelRequired": "Model is required",
|
||||
"saveFailed": "Save failed. Please try again."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"tenant": "Tenant Configuration",
|
||||
"directories": "Directories",
|
||||
"aircrafts": "Aircrafts",
|
||||
"save": "Save"
|
||||
"save": "Save",
|
||||
"militaryUnit": "Military Unit",
|
||||
"unitName": "Name",
|
||||
"camWidth": "Cam Width",
|
||||
"camFoV": "Cam FoV",
|
||||
"required": "REQ",
|
||||
"imagesDir": "Images Dir",
|
||||
"labelsDir": "Labels Dir",
|
||||
"thumbnailsDir": "Thumbnails Dir",
|
||||
"mounted": "MOUNTED",
|
||||
"cache": "CACHE",
|
||||
"browse": "Browse",
|
||||
"storageFree": "Storage Free",
|
||||
"aircraftsRegistered": "REGISTERED",
|
||||
"addAircraft": "Add Aircraft",
|
||||
"colModel": "Model",
|
||||
"colType": "Type",
|
||||
"colDefault": "Default",
|
||||
"language": "Language",
|
||||
"languageHint": "Affects all UI text",
|
||||
"languageNote": "Detection class names also use the localized field from seed data.",
|
||||
"languageBundle": "i18n BUNDLE",
|
||||
"locale": "Locale",
|
||||
"session": "Session",
|
||||
"sessionActive": "ACTIVE",
|
||||
"lastLogin": "Last Login",
|
||||
"signOutEverywhere": "Sign out everywhere",
|
||||
"cancel": "Cancel",
|
||||
"saveChanges": "Save Changes",
|
||||
"saveError": "Save failed. Please try again.",
|
||||
"unsavedChanges": "Unsaved changes detected in",
|
||||
"unitTenant": "TENANT",
|
||||
"unitDirectories": "DIRECTORIES"
|
||||
},
|
||||
"common": {
|
||||
"confirm": "Confirm",
|
||||
|
||||
+13
-1
@@ -3,9 +3,21 @@ import { initReactI18next } from 'react-i18next'
|
||||
import en from './en.json'
|
||||
import ua from './ua.json'
|
||||
|
||||
export const LANG_STORAGE_KEY = 'azaion.lang'
|
||||
|
||||
function readPersistedLanguage(): 'en' | 'ua' {
|
||||
// Safari private mode throws on localStorage access — fall back to 'en'.
|
||||
try {
|
||||
const persisted = localStorage.getItem(LANG_STORAGE_KEY)
|
||||
return persisted === 'ua' || persisted === 'en' ? persisted : 'en'
|
||||
} catch {
|
||||
return 'en'
|
||||
}
|
||||
}
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: { en: { translation: en }, ua: { translation: ua } },
|
||||
lng: 'en',
|
||||
lng: readPersistedLanguage(),
|
||||
fallbackLng: 'en',
|
||||
interpolation: { escapeValue: false },
|
||||
})
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export { default } from './i18n'
|
||||
export { default, LANG_STORAGE_KEY } from './i18n'
|
||||
|
||||
+126
-9
@@ -85,18 +85,49 @@
|
||||
},
|
||||
"annotations": {
|
||||
"title": "Анотації",
|
||||
"mediaList": "Медіа",
|
||||
"mediaList": "Медіа файли",
|
||||
"filterByName": "фільтр за назвою…",
|
||||
"upload": "Завантажити файли",
|
||||
"deleteMedia": "Видалити медіа?",
|
||||
"detect": "AI Розпізнавання",
|
||||
"detectInProgress": "AI РОЗПІЗНАВАННЯ ТРИВАЄ",
|
||||
"save": "Зберегти",
|
||||
"delete": "Видалити",
|
||||
"deleteAll": "Видалити все",
|
||||
"deleteAllTitle": "Видалити все на кадрі",
|
||||
"classes": "Класи детекцій",
|
||||
"photoMode": "Режим фото",
|
||||
"regular": "Звичайний",
|
||||
"winter": "Зимовий",
|
||||
"night": "Нічний"
|
||||
"night": "Нічний",
|
||||
"colName": "НАЗВА",
|
||||
"colKey": "КЛВ",
|
||||
"colNum": "№",
|
||||
"colTime": "ЧАС",
|
||||
"colClass": "КЛАС",
|
||||
"colConf": "ВПЕВ",
|
||||
"canvas": "Канва",
|
||||
"zoom": "ЗУМ",
|
||||
"cursor": "КУРСОР",
|
||||
"frameStep": "КРОК КАДРУ",
|
||||
"frame": "КАДР",
|
||||
"summary": "ПІДСУМОК",
|
||||
"emptyFrame": "порожній кадр",
|
||||
"filter": "Фільтр",
|
||||
"sort": "Сортувати",
|
||||
"play": "Програти",
|
||||
"pause": "Пауза",
|
||||
"previousMedia": "Попереднє медіа",
|
||||
"nextMedia": "Наступне медіа",
|
||||
"back5s": "Назад 5с",
|
||||
"forward5s": "Вперед 5с",
|
||||
"mute": "Без звуку",
|
||||
"selectMedia": "Оберіть файл медіа щоб почати",
|
||||
"annCount_one": "{{count}} анот.",
|
||||
"annCount_few": "{{count}} анот.",
|
||||
"annCount_many": "{{count}} анот.",
|
||||
"annCount_other": "{{count}} анот.",
|
||||
"emptyCount": "{{count}} порожн."
|
||||
},
|
||||
"dataset": {
|
||||
"title": "Датасет",
|
||||
@@ -116,26 +147,112 @@
|
||||
"title": "Адмін",
|
||||
"classes": {
|
||||
"title": "Класи детекцій",
|
||||
"search": "Пошук класу…",
|
||||
"add": "+ ДОДАТИ",
|
||||
"colName": "Назва",
|
||||
"colHex": "Hex",
|
||||
"colOps": "Дії",
|
||||
"edit": "Редагувати",
|
||||
"delete": "Видалити",
|
||||
"save": "Зберегти",
|
||||
"cancel": "Скасувати",
|
||||
"nameRequired": "Назва обов'язкова",
|
||||
"maxSizeMustBePositive": "Максимальний розмір має бути додатнім числом",
|
||||
"updateFailed": "Не вдалося оновити. Спробуйте ще раз."
|
||||
},
|
||||
"aiSettings": "AI Налаштування",
|
||||
"gpsSettings": "GPS Пристрій",
|
||||
"aircrafts": "Літальні апарати",
|
||||
"users": "Користувачі",
|
||||
"addUser": "Додати користувача",
|
||||
"deactivate": "Деактивувати"
|
||||
"aiEngine": {
|
||||
"title": "AI Розпізнавання",
|
||||
"subtitle": "Параметри роботи моделі. Застосовуються до польоту, гаряче перезавантаження.",
|
||||
"framesToRecognize": "Кадрів для розпізнавання",
|
||||
"framesHint": "Кількість послідовних кадрів, які модель усереднює перед видачею детекції.",
|
||||
"minSeconds": "Мін секунд між",
|
||||
"minSecondsHint": "Інтервал між послідовними викликами розпізнавання на одному відеопотоці.",
|
||||
"minConfidence": "Мін впевненість",
|
||||
"minConfidenceHint": "Детекції нижче порогу відкидаються до відображення на канві.",
|
||||
"reset": "СКИНУТИ",
|
||||
"apply": "ЗАСТОСУВАТИ",
|
||||
"lastRun": "ОСТАННІЙ ЗАПУСК",
|
||||
"frames": "КАДРИ",
|
||||
"avgConf": "СЕРЕДНЯ",
|
||||
"model": "МОДЕЛЬ",
|
||||
"loaded": "ЗАВАНТАЖЕНО",
|
||||
"unitFR": "КАДР",
|
||||
"unitSec": "СЕК"
|
||||
},
|
||||
"gpsDevice": {
|
||||
"title": "GPS Пристрій",
|
||||
"subtitle": "Наземний приймач, який живить конвеєр корекції GPS-Denied.",
|
||||
"address": "Адреса пристрою",
|
||||
"addressHint": "IPv4 точка або hostname моста GPS-приймача.",
|
||||
"port": "Порт пристрою",
|
||||
"portHint": "UDP-порт, на якому приймач транслює NMEA-повідомлення.",
|
||||
"protocol": "Протокол",
|
||||
"protocolHint": "Wire-формат узгоджений з приймачем. Перемикайте лише коли пристрій офлайн.",
|
||||
"ping": "PING",
|
||||
"reconnect": "ПЕРЕПІД'ЄДНАТИ",
|
||||
"apply": "ЗАСТОСУВАТИ",
|
||||
"connected": "З'ЄДНАНО",
|
||||
"fix": "FIX",
|
||||
"hdop": "HDOP",
|
||||
"lastPkt": "ОСТ. ПАКЕТ",
|
||||
"socket": "СОКЕТ"
|
||||
},
|
||||
"aircrafts": {
|
||||
"title": "Літальні апарати",
|
||||
"legendPlane": "ЛІТАК",
|
||||
"legendCopter": "КОПТЕР",
|
||||
"legendFixedW": "FIXED-W",
|
||||
"add": "+ ДОДАТИ АПАРАТ",
|
||||
"addTitle": "Додати апарат",
|
||||
"setDefault": "Встановити за замовч.",
|
||||
"default": "За замовч.",
|
||||
"fieldModel": "Модель",
|
||||
"fieldType": "Тип",
|
||||
"fieldResolution": "Роздільність",
|
||||
"fieldMaxMinutes": "Макс. хвилин",
|
||||
"fieldDefault": "За замовчуванням",
|
||||
"modelRequired": "Модель обов'язкова",
|
||||
"saveFailed": "Не вдалося зберегти. Спробуйте ще раз."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Налаштування",
|
||||
"tenant": "Конфігурація",
|
||||
"directories": "Директорії",
|
||||
"aircrafts": "Літальні апарати",
|
||||
"save": "Зберегти"
|
||||
"save": "Зберегти",
|
||||
"militaryUnit": "Військова частина",
|
||||
"unitName": "Назва",
|
||||
"camWidth": "Ширина кадру",
|
||||
"camFoV": "Кут огляду",
|
||||
"required": "ОБ.",
|
||||
"imagesDir": "Директорія зображень",
|
||||
"labelsDir": "Директорія міток",
|
||||
"thumbnailsDir": "Директорія мініатюр",
|
||||
"mounted": "ПІД'ЄДНАНО",
|
||||
"cache": "КЕШ",
|
||||
"browse": "Огляд",
|
||||
"storageFree": "Вільно",
|
||||
"aircraftsRegistered": "ЗАРЕЄСТРОВАНО",
|
||||
"addAircraft": "Додати апарат",
|
||||
"colModel": "Модель",
|
||||
"colType": "Тип",
|
||||
"colDefault": "За замовч.",
|
||||
"language": "Мова",
|
||||
"languageHint": "Впливає на весь UI",
|
||||
"languageNote": "Назви класів детекцій теж беруться з локалізованого поля seed-даних.",
|
||||
"languageBundle": "i18n БАНДЛ",
|
||||
"locale": "Локаль",
|
||||
"session": "Сесія",
|
||||
"sessionActive": "АКТИВНА",
|
||||
"lastLogin": "Останній вхід",
|
||||
"signOutEverywhere": "Вийти всюди",
|
||||
"cancel": "Скасувати",
|
||||
"saveChanges": "Зберегти зміни",
|
||||
"saveError": "Не вдалося зберегти. Спробуйте ще раз.",
|
||||
"unsavedChanges": "Незбережені зміни в",
|
||||
"unitTenant": "КОНФІГУРАЦІЇ",
|
||||
"unitDirectories": "ДИРЕКТОРІЯХ"
|
||||
},
|
||||
"common": {
|
||||
"confirm": "Підтвердити",
|
||||
|
||||
+613
-19
@@ -1,31 +1,625 @@
|
||||
@import "tailwindcss";
|
||||
/* Fonts are loaded via <link rel="stylesheet"> in index.html <head> so they
|
||||
resolve before first paint (no FOUT). Don't re-import via @import here. */
|
||||
|
||||
@theme {
|
||||
--color-az-bg: #1e1e1e;
|
||||
--color-az-panel: #2b2b2b;
|
||||
--color-az-header: #343a40;
|
||||
--color-az-border: #495057;
|
||||
--color-az-muted: #6c757d;
|
||||
--color-az-text: #adb5bd;
|
||||
--color-az-orange: #fd7e14;
|
||||
--color-az-blue: #228be6;
|
||||
--color-az-red: #fa5252;
|
||||
--color-az-green: #40c057;
|
||||
/* v2 — AZAION design system. v1 az-* names below are aliases so legacy
|
||||
pages still render until they're migrated to v2 utilities. */
|
||||
--color-surface-0: #0A0D10;
|
||||
--color-surface-1: #13171C;
|
||||
--color-surface-2: #1A1F26;
|
||||
--color-surface-input: #0A0D10;
|
||||
--color-border-hair: #252B34;
|
||||
--color-border-raised: #3B4451;
|
||||
--color-text-primary: #E8ECF1;
|
||||
--color-text-secondary: #9AA4B2;
|
||||
--color-text-muted: #5B6573;
|
||||
--color-accent-amber: #FF9D3D;
|
||||
--color-accent-cyan: #36D6C5;
|
||||
--color-accent-red: #FF4756;
|
||||
--color-accent-green: #3DDC84;
|
||||
--color-accent-blue: #4E9EFF;
|
||||
|
||||
/* legacy v1 aliases — mapped to v2 vars so unmigrated pages stay readable. */
|
||||
--color-az-bg: #0A0D10;
|
||||
--color-az-panel: #13171C;
|
||||
--color-az-header: #13171C;
|
||||
--color-az-border: #252B34;
|
||||
--color-az-muted: #5B6573;
|
||||
--color-az-text: #E8ECF1;
|
||||
--color-az-orange: #FF9D3D;
|
||||
--color-az-blue: #4E9EFF;
|
||||
--color-az-red: #FF4756;
|
||||
--color-az-green: #3DDC84;
|
||||
}
|
||||
|
||||
:root {
|
||||
--surface-0: #0A0D10;
|
||||
--surface-1: #13171C;
|
||||
--surface-2: #1A1F26;
|
||||
--surface-input: #0A0D10;
|
||||
--border-hair: #252B34;
|
||||
--border-raised: #3B4451;
|
||||
--text-primary: #E8ECF1;
|
||||
--text-secondary: #9AA4B2;
|
||||
--text-muted: #5B6573;
|
||||
--accent-amber: #FF9D3D;
|
||||
--accent-cyan: #36D6C5;
|
||||
--accent-red: #FF4756;
|
||||
--accent-green: #3DDC84;
|
||||
--accent-blue: #4E9EFF;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: var(--surface-0);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
|
||||
.tnum { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.micro {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-az-bg);
|
||||
|
||||
.sect-head {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-az-border);
|
||||
border-radius: 3px;
|
||||
|
||||
.hint { font-size: 11px; color: var(--text-muted); line-height: 1.45; }
|
||||
|
||||
/* Corner brackets */
|
||||
.bracket { position: relative; }
|
||||
.bracket::before, .bracket::after,
|
||||
.bracket > .br::before, .bracket > .br::after {
|
||||
content: ''; position: absolute; width: 8px; height: 8px;
|
||||
border-color: var(--accent-amber); border-style: solid; border-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.bracket::before { top: -1px; left: -1px; border-top-width: 1px; border-left-width: 1px; }
|
||||
.bracket::after { top: -1px; right: -1px; border-top-width: 1px; border-right-width: 1px; }
|
||||
.bracket > .br::before { bottom: -1px; left: -1px; border-bottom-width: 1px; border-left-width: 1px; }
|
||||
.bracket > .br::after { bottom: -1px; right: -1px; border-bottom-width: 1px; border-right-width: 1px; }
|
||||
|
||||
/* Subtle grid backdrop */
|
||||
.grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.inp {
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-hair);
|
||||
border-radius: 2px;
|
||||
height: 32px;
|
||||
padding: 6px 10px;
|
||||
font: 12px 'IBM Plex Sans', system-ui, sans-serif;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
.inp:focus { border-color: var(--accent-amber); box-shadow: 0 0 0 1px var(--accent-amber); }
|
||||
.inp::placeholder { color: var(--text-muted); }
|
||||
.inp-mono { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Hide native number-input spinner arrows — custom ▲▼ steppers replace them. */
|
||||
.inp[type="number"]::-webkit-inner-spin-button,
|
||||
.inp[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.inp[type="number"] { -moz-appearance: textfield; appearance: textfield; }
|
||||
|
||||
/* Checkbox — v2 dark theme, amber check.
|
||||
Layout-stable: flex (not inline-flex) so the baseline of the wrapping
|
||||
label doesn't shift when the input gains focus or toggles. The checkmark
|
||||
is a background-image SVG so there is no pseudo-element being added /
|
||||
removed (which can briefly affect intrinsic size in some browsers). */
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.checkbox {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
box-sizing: border-box;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--surface-input) no-repeat center center;
|
||||
background-size: 10px 10px;
|
||||
border: 1px solid var(--border-raised);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: border-color .1s, background-color .1s, box-shadow .1s;
|
||||
outline: none;
|
||||
}
|
||||
.checkbox:hover { border-color: var(--accent-amber); }
|
||||
.checkbox:focus-visible {
|
||||
border-color: var(--accent-amber);
|
||||
box-shadow: 0 0 0 1px var(--accent-amber);
|
||||
}
|
||||
.checkbox:checked {
|
||||
background-color: var(--accent-amber);
|
||||
border-color: var(--accent-amber);
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%230A0D10' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='3 8.5 7 12 13 4.5'/></svg>");
|
||||
}
|
||||
.checkbox:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 28px; padding: 0 12px;
|
||||
font: 600 11px 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 2px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color .12s, color .12s, border-color .12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary {
|
||||
background: var(--accent-amber);
|
||||
color: #0A0D10;
|
||||
border-color: var(--accent-amber);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--accent-amber);
|
||||
border-color: var(--accent-amber);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { background: rgba(255,157,61,.12); }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-hair);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-raised); }
|
||||
.btn-danger-ghost {
|
||||
background: transparent;
|
||||
color: var(--accent-red);
|
||||
border-color: rgba(255,71,86,0.5);
|
||||
}
|
||||
.btn-danger-ghost:hover:not(:disabled) {
|
||||
background: rgba(255,71,86,0.08);
|
||||
border-color: var(--accent-red);
|
||||
}
|
||||
.btn-danger {
|
||||
background: var(--accent-red);
|
||||
color: #0A0D10;
|
||||
border-color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* Icon button */
|
||||
.ibtn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: color .1s, background .1s, border-color .1s;
|
||||
}
|
||||
.ibtn:hover { color: var(--text-primary); background: var(--surface-2); border-color: var(--border-hair); }
|
||||
.ibtn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent-red); background: rgba(255,71,86,.08); }
|
||||
.ibtn.edit:hover { color: var(--accent-amber); border-color: var(--accent-amber); background: rgba(255,157,61,.08); }
|
||||
.ibtn.cyan:hover { color: var(--accent-cyan); border-color: var(--accent-cyan); background: rgba(54,214,197,.08); }
|
||||
|
||||
/* Header-scoped icon buttons override the smaller in-table variant */
|
||||
header .ibtn {
|
||||
width: 28px; height: 28px;
|
||||
border: 1px solid var(--border-hair);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
header .ibtn:hover { background: var(--surface-2); color: var(--text-primary); border-color: var(--border-raised); }
|
||||
header .ibtn.active { color: var(--accent-amber); border-color: var(--accent-amber); background: rgba(255,157,61,0.08); }
|
||||
header .ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent-red); background: rgba(255,71,86,0.08); }
|
||||
|
||||
/* Pills */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 18px; padding: 0 8px;
|
||||
font: 600 10px 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.10em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.pill-green { color: var(--accent-green); }
|
||||
.pill-red { color: var(--accent-red); }
|
||||
.pill-cyan { color: var(--accent-cyan); }
|
||||
.pill-amber { color: var(--accent-amber); }
|
||||
.pill-blue { color: var(--accent-blue); }
|
||||
.pill-muted { color: var(--text-muted); }
|
||||
|
||||
/* Chip (role chips, type chips — solid filled, denser) */
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
height: 18px; min-width: 60px; padding: 0 8px;
|
||||
font: 600 10px 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.chip-admin { background: rgba(255,157,61,.16); color: var(--accent-amber); border: 1px solid rgba(255,157,61,.35); }
|
||||
.chip-operator { background: rgba(78,158,255,.14); color: var(--accent-blue); border: 1px solid rgba(78,158,255,.35); }
|
||||
.chip-viewer { background: rgba(154,164,178,.10); color: var(--text-secondary); border: 1px solid var(--border-hair); }
|
||||
|
||||
/* Type squares (P / C / F) */
|
||||
.type-sq {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 2px;
|
||||
font: 700 9px 'JetBrains Mono', monospace;
|
||||
color: #0A0D10;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Color swatch */
|
||||
.swatch {
|
||||
display: inline-block; width: 12px; height: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
border-radius: 1px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Segmented control */
|
||||
.seg { display: inline-flex; border: 1px solid var(--border-hair); border-radius: 2px; overflow: hidden; }
|
||||
.seg-btn {
|
||||
height: 30px; padding: 0 14px;
|
||||
font: 600 10px 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.10em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-input);
|
||||
border-right: 1px solid var(--border-hair);
|
||||
cursor: pointer;
|
||||
transition: background .1s, color .1s;
|
||||
}
|
||||
.seg-btn:last-child { border-right: 0; }
|
||||
.seg-btn:hover { color: var(--text-primary); }
|
||||
.seg-btn.active {
|
||||
background: var(--accent-amber);
|
||||
color: #0A0D10;
|
||||
}
|
||||
|
||||
/* Header bar tabs */
|
||||
.tab {
|
||||
display: inline-flex; align-items: center;
|
||||
height: 48px; padding: 0 14px;
|
||||
font: 500 12px/1 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.10em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab:hover { color: var(--text-primary); }
|
||||
.tab.active { color: var(--text-primary); border-bottom-color: var(--accent-amber); font-weight: 500; }
|
||||
|
||||
/* Table rows */
|
||||
.row-hover:hover { background: var(--surface-2); }
|
||||
|
||||
/* Card panel base */
|
||||
.panel {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border-hair);
|
||||
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 {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 4px;
|
||||
transition: color .12s, transform .12s;
|
||||
}
|
||||
.star:hover { color: var(--accent-amber); }
|
||||
.star.active { color: var(--accent-amber); }
|
||||
.star-off { color: var(--text-muted); }
|
||||
|
||||
/* Path input with Browse button (Settings v2 directories panel). */
|
||||
.path-wrap { position: relative; display: flex; align-items: center; }
|
||||
.path-wrap .path-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.path-wrap .browse {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-hair);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: color .12s, border-color .12s, background .12s;
|
||||
}
|
||||
.path-wrap .browse:hover {
|
||||
color: var(--accent-amber);
|
||||
border-color: var(--accent-amber);
|
||||
background: rgba(255,157,61,0.06);
|
||||
}
|
||||
.path-wrap > input.inp { padding-left: 30px; padding-right: 70px; }
|
||||
|
||||
/* Pulse for live dot */
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
.live { animation: pulse 1.6s ease-in-out infinite; }
|
||||
|
||||
/* Reveal-on-hover */
|
||||
.row-hover .reveal { opacity: 0; transition: opacity .12s; }
|
||||
.row-hover:hover .reveal { opacity: 1; }
|
||||
|
||||
/* select matching inp */
|
||||
select.inp {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%),
|
||||
linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%);
|
||||
background-position: calc(100% - 14px) 14px, calc(100% - 9px) 14px;
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: var(--surface-0); }
|
||||
::-webkit-scrollbar-thumb { background: #1f2630; border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #2a323e; }
|
||||
|
||||
/* =========================================================================
|
||||
ANNOTATIONS PAGE — v2 surfaces
|
||||
========================================================================= */
|
||||
|
||||
/* Splitter affordance between resizable panes */
|
||||
.split { width: 4px; cursor: col-resize; background: transparent; position: relative; }
|
||||
.split::after {
|
||||
content: ''; position: absolute; left: 1px; top: 0; bottom: 0; width: 1px;
|
||||
background: var(--border-hair);
|
||||
}
|
||||
.split:hover::after { background: var(--accent-amber); }
|
||||
|
||||
/* Media list row (264px left aside) */
|
||||
.media-row {
|
||||
position: relative;
|
||||
display: grid; grid-template-columns: 44px 1fr auto; gap: 8px;
|
||||
align-items: center;
|
||||
height: 32px; padding: 0 12px 0 14px;
|
||||
border-bottom: 1px solid var(--border-hair);
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
.media-row:hover { background: var(--surface-2); }
|
||||
.media-row.active { background: var(--surface-2); }
|
||||
.media-row.active::before {
|
||||
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
|
||||
width: 2px; background: var(--accent-amber);
|
||||
}
|
||||
|
||||
/* Type chips inside media rows */
|
||||
.chip-photo {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 40px; height: 16px; border-radius: 2px;
|
||||
font: 600 9px/1 'JetBrains Mono', monospace; letter-spacing: 0.1em;
|
||||
color: var(--accent-cyan); border: 1px solid rgba(54,214,197,0.45);
|
||||
background: rgba(54,214,197,0.06);
|
||||
}
|
||||
.chip-video {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 40px; height: 16px; border-radius: 2px;
|
||||
font: 600 9px/1 'JetBrains Mono', monospace; letter-spacing: 0.1em;
|
||||
color: var(--accent-amber); border: 1px solid rgba(255,157,61,0.45);
|
||||
background: rgba(255,157,61,0.06);
|
||||
}
|
||||
|
||||
/* Detection class row */
|
||||
.class-row {
|
||||
display: grid; grid-template-columns: 16px 1fr auto; gap: 10px;
|
||||
align-items: center; height: 28px; padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-hair);
|
||||
cursor: pointer;
|
||||
}
|
||||
.class-row:hover { background: var(--surface-2); }
|
||||
.class-row.active { background: var(--surface-2); }
|
||||
.class-row.active .kbd { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||
|
||||
/* Keycap chip */
|
||||
.kbd {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 16px; padding: 0;
|
||||
font: 600 10px/1 'JetBrains Mono', monospace;
|
||||
color: var(--text-muted); border: 1px solid var(--border-hair); border-radius: 2px;
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
/* Annotation row in right sidebar (gradient stripe via --row-grad) */
|
||||
.ann-row {
|
||||
position: relative;
|
||||
display: grid; grid-template-columns: 44px 1fr auto; gap: 8px;
|
||||
align-items: center;
|
||||
height: 36px; padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-hair);
|
||||
cursor: pointer;
|
||||
background-color: var(--surface-1);
|
||||
}
|
||||
.ann-row::after {
|
||||
content: ''; position: absolute; left: 0; right: 0; top: 0; bottom: 0;
|
||||
background-image: var(--row-grad, none);
|
||||
pointer-events: none;
|
||||
}
|
||||
.ann-row > * { position: relative; z-index: 1; }
|
||||
.ann-row:hover { background-color: var(--surface-2); }
|
||||
.ann-row.active { background-color: var(--surface-2); }
|
||||
|
||||
/* Faux terrain wash behind canvas */
|
||||
.terrain {
|
||||
background-color: #11181B;
|
||||
background-image:
|
||||
radial-gradient(900px 500px at 30% 40%, rgba(48,72,60,0.45), transparent 60%),
|
||||
radial-gradient(700px 400px at 75% 65%, rgba(40,52,68,0.35), transparent 65%),
|
||||
radial-gradient(400px 300px at 60% 30%, rgba(82,64,40,0.18), transparent 70%),
|
||||
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||
background-size: auto, auto, auto, 48px 48px, 48px 48px;
|
||||
}
|
||||
|
||||
/* Floating AI Detection banner over canvas */
|
||||
.ai-banner {
|
||||
backdrop-filter: blur(6px);
|
||||
background: rgba(10,13,16,0.78);
|
||||
border: 1px solid rgba(54,214,197,0.4);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Bounding-box label chip (DOM overlay on canvas) */
|
||||
.bbox-label {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 22px; padding: 0 8px;
|
||||
font: 600 10px/1 'JetBrains Mono', monospace; letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 2px;
|
||||
background: rgba(10,13,16,0.92);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-hair);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bbox-label .conf { color: var(--text-secondary); font-weight: 500; }
|
||||
|
||||
/* Selection handles on bounding boxes */
|
||||
.handle {
|
||||
position: absolute; width: 6px; height: 6px;
|
||||
background: var(--accent-amber); border: 1px solid #0A0D10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Scrubber (timeline with annotation marks) */
|
||||
.scrub {
|
||||
height: 4px; background: var(--surface-2); border: 1px solid var(--border-hair);
|
||||
border-radius: 2px; position: relative; cursor: pointer;
|
||||
}
|
||||
.scrub .fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent-amber); pointer-events: none; }
|
||||
.scrub .head {
|
||||
position: absolute; top: 50%; width: 2px; height: 10px; background: var(--accent-amber);
|
||||
transform: translate(-50%, -50%); pointer-events: none;
|
||||
}
|
||||
.scrub .head-knob {
|
||||
position: absolute; top: 50%; width: 12px; height: 12px;
|
||||
background: var(--accent-amber);
|
||||
border: 2px solid var(--surface-1);
|
||||
border-radius: 999px;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 0 0 1px var(--accent-amber), 0 0 8px rgba(255,157,61,0.45);
|
||||
z-index: 2;
|
||||
cursor: grab;
|
||||
}
|
||||
.scrub .head-knob:active { cursor: grabbing; }
|
||||
.scrub .tick {
|
||||
position: absolute; top: 50%; width: 1px; height: 6px; background: var(--text-muted);
|
||||
transform: translateY(-50%); pointer-events: none;
|
||||
}
|
||||
.scrub .mark {
|
||||
position: absolute; top: -3px; width: 2px; height: 10px; pointer-events: none;
|
||||
}
|
||||
|
||||
/* Volume slider (range input next to mute) */
|
||||
.vol {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
height: 2px; width: 72px; background: var(--border-hair); outline: none; border-radius: 2px;
|
||||
}
|
||||
.vol::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 10px; height: 10px; background: var(--accent-amber); border-radius: 0; cursor: pointer;
|
||||
}
|
||||
.vol::-moz-range-thumb {
|
||||
width: 10px; height: 10px; background: var(--accent-amber); border-radius: 0; cursor: pointer; border: 0;
|
||||
}
|
||||
|
||||
/* Live pulse dot (cyan glow) — annotations LIVE indicators */
|
||||
.live-dot {
|
||||
width: 6px; height: 6px; border-radius: 999px;
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 0 rgba(54,214,197,0.5);
|
||||
animation: live-pulse 1.6s ease-in-out infinite;
|
||||
display: inline-block; flex: none;
|
||||
}
|
||||
@keyframes live-pulse {
|
||||
0%,100% { box-shadow: 0 0 0 0 rgba(54,214,197,0.5); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(54,214,197,0); }
|
||||
}
|
||||
|
||||
+44
-1
@@ -69,8 +69,51 @@ export interface Flight {
|
||||
export interface Aircraft {
|
||||
id: string
|
||||
model: string
|
||||
type: 'Plane' | 'Copter'
|
||||
type: 'Plane' | 'Copter' | 'FixedWing'
|
||||
isDefault: boolean
|
||||
resolution?: string
|
||||
maxMinutes?: number
|
||||
}
|
||||
|
||||
export interface AiRecognitionSettings {
|
||||
framesToRecognize: number
|
||||
minSecondsBetween: number
|
||||
minConfidence: number
|
||||
}
|
||||
|
||||
export interface AiRecognitionTelemetry {
|
||||
model: string
|
||||
checkpoint: string
|
||||
lastRunAt: string | null
|
||||
frames: number
|
||||
avgConfidence: number
|
||||
}
|
||||
|
||||
export interface AiRecognitionResponse {
|
||||
settings: AiRecognitionSettings
|
||||
telemetry: AiRecognitionTelemetry
|
||||
}
|
||||
|
||||
export type GpsProtocol = 'NMEA' | 'UBX' | 'MAVLINK'
|
||||
|
||||
export interface GpsDeviceSettings {
|
||||
address: string
|
||||
port: number
|
||||
protocol: GpsProtocol
|
||||
}
|
||||
|
||||
export interface GpsDeviceTelemetry {
|
||||
socket: string
|
||||
connected: boolean
|
||||
fix: '2D' | '3D' | 'NO_FIX'
|
||||
satellites: number
|
||||
hdop: number
|
||||
lastPacketMs: number
|
||||
}
|
||||
|
||||
export interface GpsDeviceResponse {
|
||||
settings: GpsDeviceSettings
|
||||
telemetry: GpsDeviceTelemetry
|
||||
}
|
||||
|
||||
export interface Waypoint {
|
||||
|
||||
Vendored
+2
@@ -8,6 +8,8 @@ interface ImportMetaEnv {
|
||||
readonly VITE_OWM_API_KEY?: string
|
||||
readonly VITE_OWM_BASE_URL?: string
|
||||
readonly VITE_SATELLITE_TILE_URL?: string
|
||||
/** Dev-only: when 'true', skip backend auth and inject a fake admin user. */
|
||||
readonly VITE_DEV_AUTH_BYPASS?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -105,14 +105,12 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
||||
// Act
|
||||
await clickEdit('1')
|
||||
|
||||
// Assert — form is visible inside row 1.
|
||||
// Assert — name input is visible inside row 1 (v2 minimal edit:
|
||||
// only the name is editable inline; shortName/color/maxSizeM are
|
||||
// preserved in form state and sent on save).
|
||||
const row1 = getRow('1')
|
||||
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
|
||||
expect(nameInput).toBeInTheDocument()
|
||||
const shortInput = within(row1).getByDisplayValue('a') as HTMLInputElement
|
||||
expect(shortInput).toBeInTheDocument()
|
||||
const maxSize = within(row1).getByDisplayValue('7') as HTMLInputElement
|
||||
expect(maxSize).toBeInTheDocument()
|
||||
|
||||
// Assert — row 2 stays read-only: the row still shows the plain text name.
|
||||
const row2 = getRow('2')
|
||||
@@ -246,31 +244,17 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
||||
// Act
|
||||
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
|
||||
|
||||
// Assert — no PATCH; error alert rendered.
|
||||
// Assert — no PATCH; error alert rendered (v2 renders the alert in
|
||||
// a sibling tr below the edit row, not inside row1 itself).
|
||||
expect(patchCalls.length).toBe(0)
|
||||
const alert = within(row1).getByRole('alert')
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert.textContent ?? '').toMatch(/name is required|назва обов/i)
|
||||
})
|
||||
|
||||
it('non-positive maxSizeM → no PATCH; maxSizeMustBePositive error visible', async () => {
|
||||
// Arrange
|
||||
const patchCalls = capturePatchCalls()
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByText('class-a')
|
||||
await clickEdit('1')
|
||||
const row1 = getRow('1')
|
||||
const maxInput = within(row1).getByDisplayValue('7') as HTMLInputElement
|
||||
await userEvent.clear(maxInput)
|
||||
await userEvent.type(maxInput, '0')
|
||||
|
||||
// Act
|
||||
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
|
||||
|
||||
// Assert — no PATCH; error alert rendered.
|
||||
expect(patchCalls.length).toBe(0)
|
||||
const alert = within(row1).getByRole('alert')
|
||||
expect(alert.textContent ?? '').toMatch(/positive|додатнім/i)
|
||||
})
|
||||
// The maxSizeM field is no longer editable inline in v2 (mockup shows
|
||||
// name-only). The original "non-positive maxSizeM" validation test is
|
||||
// removed — the constraint is now enforced by a separate edit-class
|
||||
// flow (not yet built) rather than inline.
|
||||
})
|
||||
|
||||
describe('AC-6: backend error is surfaced inline', () => {
|
||||
@@ -299,10 +283,11 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
||||
// Act
|
||||
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
|
||||
|
||||
// Assert — PATCH happened, error rendered, form still open, no alert().
|
||||
// Assert — PATCH happened, error rendered (in a sibling tr), form
|
||||
// still open, no alert().
|
||||
await waitFor(() => expect(patchCount).toBe(1))
|
||||
const row1After = getRow('1')
|
||||
const alert = await within(row1After).findByRole('alert')
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent ?? '').toMatch(/update failed|не вдалося оновити/i)
|
||||
expect(within(row1After).getByDisplayValue('will-fail')).toBeInTheDocument()
|
||||
expect(alertCalls).toBe(0)
|
||||
@@ -317,7 +302,7 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
||||
// Arrange — capture POST; second GET returns 3 classes.
|
||||
const postCalls: { body: unknown }[] = []
|
||||
let getCount = 0
|
||||
const NEW_CLASS: DetectionClass = { id: 3, name: 'fresh', shortName: '', color: '#FF0000', maxSizeM: 7, photoMode: 0 }
|
||||
const NEW_CLASS: DetectionClass = { id: 3, name: 'fresh', shortName: '', color: '#FF9D3D', maxSizeM: 7, photoMode: 0 }
|
||||
server.use(
|
||||
http.post('/api/admin/classes', async ({ request }) => {
|
||||
postCalls.push({ body: await request.json() })
|
||||
@@ -332,13 +317,15 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByText('class-a')
|
||||
|
||||
// Act — scope to the classes table panel (both the class-add row and
|
||||
// the user-add row use placeholder="Name" + a `+` button; disambiguate
|
||||
// by walking up from the class-a cell to the enclosing panel).
|
||||
const classesPanel = (getRow('1').closest('table') as HTMLElement).parentElement as HTMLElement
|
||||
const addNameInput = within(classesPanel).getByPlaceholderText('Name') as HTMLInputElement
|
||||
await userEvent.type(addNameInput, 'fresh')
|
||||
await userEvent.click(within(classesPanel).getByRole('button', { name: '+' }))
|
||||
// Act — v2 layout: click the top "+ ADD" button to open an inline
|
||||
// add-row at the top of the table, type the name, click the save
|
||||
// (cyan checkmark, aria-label "Save") icon button.
|
||||
const classesPanel = getRow('1').closest('aside') as HTMLElement
|
||||
await userEvent.click(within(classesPanel).getByRole('button', { name: /^\+ add$|^\+ додати$/i }))
|
||||
const addRow = within(classesPanel).getByText('+', { selector: 'td' }).closest('tr') as HTMLElement
|
||||
const nameInput = within(addRow).getByPlaceholderText('Name') as HTMLInputElement
|
||||
await userEvent.type(nameInput, 'fresh')
|
||||
await userEvent.click(within(addRow).getByRole('button', { name: /^save$|^зберегти$/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(postCalls.length).toBe(1))
|
||||
|
||||
Vendored
+7
-4
@@ -1,8 +1,11 @@
|
||||
import type { Aircraft } from '../../src/types'
|
||||
|
||||
// Three aircraft with one default, per `seed_aircraft` in test-data.md.
|
||||
// Six aircraft matching the v2 admin mockup. AC-001 is the default.
|
||||
export const seedAircraft: Aircraft[] = [
|
||||
{ id: 'aircraft-1', model: 'Bayraktar TB2', type: 'Plane', isDefault: true },
|
||||
{ id: 'aircraft-2', model: 'DJI Mavic 3', type: 'Copter', isDefault: false },
|
||||
{ id: 'aircraft-3', model: 'Leleka-100', type: 'Plane', isDefault: false },
|
||||
{ id: 'AC-001', model: 'DJI Mavic 3', type: 'Copter', isDefault: true, resolution: '4K', maxMinutes: 46 },
|
||||
{ id: 'AC-002', model: 'Matrice 300 RTK', type: 'Copter', isDefault: false, resolution: '4K', maxMinutes: 55 },
|
||||
{ id: 'AC-003', model: 'Leleka-100', type: 'FixedWing', isDefault: false, resolution: 'HD', maxMinutes: 180 },
|
||||
{ id: 'AC-004', model: 'Fixed Wing Scout', type: 'Plane', isDefault: false, resolution: '1080P', maxMinutes: 95 },
|
||||
{ id: 'AC-005', model: 'Autel EVO II Pro', type: 'Copter', isDefault: false, resolution: '6K', maxMinutes: 40 },
|
||||
{ id: 'AC-006', model: 'PD-2 Recon', type: 'FixedWing', isDefault: false, resolution: 'HD', maxMinutes: 600 },
|
||||
]
|
||||
|
||||
Vendored
+5
-5
@@ -5,11 +5,11 @@ import type { Flight } from '../../src/types'
|
||||
// AC-08 timing assertions.
|
||||
|
||||
export const seedFlights: Flight[] = [
|
||||
{ id: 'flight-1', name: 'Recon Alpha', createdDate: '2026-05-01T10:00:00Z', aircraftId: 'aircraft-1' },
|
||||
{ id: 'flight-2', name: 'Recon Bravo', createdDate: '2026-05-02T11:30:00Z', aircraftId: 'aircraft-1' },
|
||||
{ id: 'flight-3', name: 'Survey Charlie', createdDate: '2026-05-03T14:15:00Z', aircraftId: 'aircraft-2' },
|
||||
{ id: 'flight-4', name: 'Patrol Delta', createdDate: '2026-05-04T09:45:00Z', aircraftId: 'aircraft-3' },
|
||||
{ id: 'flight-5', name: 'Strike Echo', createdDate: '2026-05-05T16:00:00Z', aircraftId: 'aircraft-1' },
|
||||
{ id: 'flight-1', name: 'Recon Alpha', createdDate: '2026-05-01T10:00:00Z', aircraftId: 'AC-001' },
|
||||
{ id: 'flight-2', name: 'Recon Bravo', createdDate: '2026-05-02T11:30:00Z', aircraftId: 'AC-001' },
|
||||
{ id: 'flight-3', name: 'Survey Charlie', createdDate: '2026-05-03T14:15:00Z', aircraftId: 'AC-002' },
|
||||
{ id: 'flight-4', name: 'Patrol Delta', createdDate: '2026-05-04T09:45:00Z', aircraftId: 'AC-003' },
|
||||
{ id: 'flight-5', name: 'Strike Echo', createdDate: '2026-05-05T16:00:00Z', aircraftId: 'AC-001' },
|
||||
]
|
||||
|
||||
export const liveGpsFlightId = 'flight-1'
|
||||
|
||||
+28
-16
@@ -6,11 +6,23 @@
|
||||
"TCP",
|
||||
"UDP",
|
||||
"Esc",
|
||||
"OK"
|
||||
"OK",
|
||||
"//",
|
||||
"|",
|
||||
"▾",
|
||||
"▲",
|
||||
"▼",
|
||||
"—"
|
||||
],
|
||||
"src/components/Header.tsx": [
|
||||
"No flights",
|
||||
"Filter..."
|
||||
"Filter...",
|
||||
"— SELECT —",
|
||||
"LINK",
|
||||
"Toggle language",
|
||||
"UA",
|
||||
"EN",
|
||||
"⚙"
|
||||
],
|
||||
"src/components/HelpModal.tsx": [
|
||||
"How to Annotate",
|
||||
@@ -36,20 +48,20 @@
|
||||
],
|
||||
"src/features/admin/AdminPage.tsx": [
|
||||
"Name",
|
||||
"Color",
|
||||
"Frame Period Recognition",
|
||||
"Frame Recognition Seconds",
|
||||
"Probability Threshold",
|
||||
"Device Address",
|
||||
"Port",
|
||||
"Protocol",
|
||||
"Email",
|
||||
"Role",
|
||||
"Status",
|
||||
"Annotator",
|
||||
"Admin",
|
||||
"Viewer",
|
||||
"Password"
|
||||
"#",
|
||||
"+",
|
||||
"0.0.0.0",
|
||||
"P",
|
||||
"C",
|
||||
"F",
|
||||
"%",
|
||||
"NMEA",
|
||||
"UBX",
|
||||
"MAVLINK",
|
||||
"SAT",
|
||||
"MIN",
|
||||
"Increment",
|
||||
"Decrement"
|
||||
],
|
||||
"src/features/annotations/AnnotationsSidebar.tsx": [
|
||||
"Download annotation"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { http } from 'msw'
|
||||
import { jsonResponse, noContent } from '../helpers'
|
||||
import type {
|
||||
AiRecognitionSettings,
|
||||
AiRecognitionTelemetry,
|
||||
GpsDeviceSettings,
|
||||
GpsDeviceTelemetry,
|
||||
} from '../../../src/types'
|
||||
|
||||
// Stateful MSW handlers for AI Recognition + GPS Device Link settings.
|
||||
// Seed mutates on PATCH so PING / RECONNECT / APPLY round-trips persist
|
||||
// within a session. `resetAdminSettingsSeed()` is invoked per-test from
|
||||
// tests/setup.ts so test isolation is preserved.
|
||||
|
||||
const DEFAULT_AI_SETTINGS: AiRecognitionSettings = {
|
||||
framesToRecognize: 4,
|
||||
minSecondsBetween: 2,
|
||||
minConfidence: 25,
|
||||
}
|
||||
|
||||
const DEFAULT_AI_TELEMETRY: AiRecognitionTelemetry = {
|
||||
model: 'YOLOV8-X',
|
||||
checkpoint: 'CKPT-241',
|
||||
lastRunAt: '2026-05-18T11:43:09Z',
|
||||
frames: 14228,
|
||||
avgConfidence: 71.4,
|
||||
}
|
||||
|
||||
const DEFAULT_GPS_SETTINGS: GpsDeviceSettings = {
|
||||
address: '192.168.1.100',
|
||||
port: 9001,
|
||||
protocol: 'NMEA',
|
||||
}
|
||||
|
||||
const DEFAULT_GPS_TELEMETRY: GpsDeviceTelemetry = {
|
||||
socket: 'UDP/192.168.1.100:9001',
|
||||
connected: true,
|
||||
fix: '3D',
|
||||
satellites: 11,
|
||||
hdop: 0.82,
|
||||
lastPacketMs: 12,
|
||||
}
|
||||
|
||||
let aiSettings: AiRecognitionSettings = { ...DEFAULT_AI_SETTINGS }
|
||||
let aiTelemetry: AiRecognitionTelemetry = { ...DEFAULT_AI_TELEMETRY }
|
||||
let gpsSettings: GpsDeviceSettings = { ...DEFAULT_GPS_SETTINGS }
|
||||
let gpsTelemetry: GpsDeviceTelemetry = { ...DEFAULT_GPS_TELEMETRY }
|
||||
|
||||
export function resetAdminSettingsSeed() {
|
||||
aiSettings = { ...DEFAULT_AI_SETTINGS }
|
||||
aiTelemetry = { ...DEFAULT_AI_TELEMETRY }
|
||||
gpsSettings = { ...DEFAULT_GPS_SETTINGS }
|
||||
gpsTelemetry = { ...DEFAULT_GPS_TELEMETRY }
|
||||
}
|
||||
|
||||
export const adminSettingsHandlers = [
|
||||
http.get('/api/admin/ai-settings', () =>
|
||||
jsonResponse({ settings: aiSettings, telemetry: aiTelemetry }),
|
||||
),
|
||||
|
||||
http.patch('/api/admin/ai-settings', async ({ request }) => {
|
||||
const body = (await request.json().catch(() => ({}))) as Partial<AiRecognitionSettings>
|
||||
aiSettings = { ...aiSettings, ...body }
|
||||
return jsonResponse({ settings: aiSettings, telemetry: aiTelemetry })
|
||||
}),
|
||||
|
||||
http.get('/api/admin/gps-settings', () =>
|
||||
jsonResponse({ settings: gpsSettings, telemetry: gpsTelemetry }),
|
||||
),
|
||||
|
||||
http.patch('/api/admin/gps-settings', async ({ request }) => {
|
||||
const body = (await request.json().catch(() => ({}))) as Partial<GpsDeviceSettings>
|
||||
gpsSettings = { ...gpsSettings, ...body }
|
||||
gpsTelemetry = {
|
||||
...gpsTelemetry,
|
||||
socket: `UDP/${gpsSettings.address}:${gpsSettings.port}`,
|
||||
}
|
||||
return jsonResponse({ settings: gpsSettings, telemetry: gpsTelemetry })
|
||||
}),
|
||||
|
||||
http.post('/api/admin/gps-settings/ping', () => noContent()),
|
||||
|
||||
http.post('/api/admin/gps-settings/reconnect', () => {
|
||||
gpsTelemetry = { ...gpsTelemetry, connected: true, lastPacketMs: 0 }
|
||||
return jsonResponse({ settings: gpsSettings, telemetry: gpsTelemetry })
|
||||
}),
|
||||
]
|
||||
@@ -64,8 +64,14 @@ export const flightsHandlers = [
|
||||
return jsonResponse({ id: params.id, ...body })
|
||||
}),
|
||||
|
||||
// POST accepts both plural and singular paths. Production convention is
|
||||
// plural (REST collection); singular kept as a backward-compat alias.
|
||||
http.post('/api/flights/aircrafts', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: 'AC-NEW', ...body }, { status: 201 })
|
||||
}),
|
||||
http.post('/api/flights/aircraft', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: 'aircraft-new', ...body }, { status: 201 })
|
||||
return jsonResponse({ id: 'AC-NEW', ...body }, { status: 201 })
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { adminHandlers } from './admin'
|
||||
import { adminSettingsHandlers } from './admin-settings'
|
||||
import { flightsHandlers } from './flights'
|
||||
import { annotationsHandlers } from './annotations'
|
||||
import { detectHandlers } from './detect'
|
||||
@@ -12,6 +13,7 @@ import { tilesHandlers } from './tiles'
|
||||
// the seeded baseline. Per-test overrides land via `server.use(...)`.
|
||||
export const defaultHandlers = [
|
||||
...adminHandlers,
|
||||
...adminSettingsHandlers,
|
||||
...flightsHandlers,
|
||||
...annotationsHandlers,
|
||||
...detectHandlers,
|
||||
@@ -23,6 +25,7 @@ export const defaultHandlers = [
|
||||
|
||||
export {
|
||||
adminHandlers,
|
||||
adminSettingsHandlers,
|
||||
flightsHandlers,
|
||||
annotationsHandlers,
|
||||
detectHandlers,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from './msw/server'
|
||||
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 { SettingsPage } from '../src/features/settings'
|
||||
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
|
||||
// to error visibility ≤ 2 s.
|
||||
//
|
||||
// Production today (`SettingsPage.saveSystem` / `saveDirs`) does
|
||||
// setSaving(true); await api.put(...); setSaving(false)
|
||||
// with no try/finally and no error region in the JSX. Both AC-1 and AC-2 are
|
||||
// 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.
|
||||
// v2 SettingsPage wraps `save()` in try/catch/finally and renders an inline
|
||||
// role="alert" in the sticky footer when the PUT rejects. The three contract
|
||||
// tests below assert that wiring directly.
|
||||
|
||||
const SYSTEM_SEED: SystemSettings = {
|
||||
id: 'sys-1',
|
||||
@@ -84,80 +77,43 @@ function rigSettingsEnv(failure: SettingsFailure): SettingsRig {
|
||||
}
|
||||
|
||||
/**
|
||||
* SettingsPage renders two "Save" buttons (one per panel) once both GETs
|
||||
* resolve. We always exercise the *system* panel — its handler (`saveSystem`)
|
||||
* has the same try-finally drift as `saveDirs`, and scoping the query to
|
||||
* "Tenant Configuration" makes the selector unambiguous regardless of which
|
||||
* GET resolves first.
|
||||
* SettingsPage (v2) renders a single sticky-footer "Save Changes" button that
|
||||
* persists whichever panels are dirty in parallel. The footer button is the
|
||||
* only Save affordance; per-panel Save buttons no longer exist. We must mark
|
||||
* the Tenant panel as dirty by editing a field before the footer button
|
||||
* 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> {
|
||||
const systemHeading = await screen.findByRole('heading', { name: /Tenant Configuration/i })
|
||||
const panel = systemHeading.parentElement as HTMLElement
|
||||
return within(panel).getByRole('button', { name: /^Save$/i })
|
||||
// Wait until the data has loaded (heading is present immediately, but the
|
||||
// input is rendered only after the GET resolves).
|
||||
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> {
|
||||
renderWithProviders(<SettingsPage />)
|
||||
await makeTenantDirty()
|
||||
const saveButton = await findSystemSaveButton()
|
||||
await userEvent.click(saveButton)
|
||||
}
|
||||
|
||||
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(() => {
|
||||
seedBearer()
|
||||
suppressedRejections = []
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
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', () => {
|
||||
it.fails(
|
||||
'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.
|
||||
it('PUT 500 → Save button is no longer disabled within 2 s', async () => {
|
||||
rigSettingsEnv({ kind: 'http', status: 500 })
|
||||
await renderAndClickSave()
|
||||
const saveButton = await findSystemSaveButton()
|
||||
@@ -165,41 +121,20 @@ describe('AZ-477 — Settings save resilience + 2 s error budget', () => {
|
||||
() => expect(saveButton).not.toBeDisabled(),
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it.fails(
|
||||
'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.
|
||||
it('PUT 500 → an in-DOM error region (role="alert") appears within 2 s', async () => {
|
||||
rigSettingsEnv({ kind: 'http', status: 500 })
|
||||
await renderAndClickSave()
|
||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||
// Message shape: production task picks the i18n key; the test only
|
||||
// asserts that *some* user-visible error text is present.
|
||||
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', () => {
|
||||
it.fails(
|
||||
'network error → Save button is no longer disabled within 2 s',
|
||||
async () => {
|
||||
it('network error → Save button is no longer disabled within 2 s', async () => {
|
||||
rigSettingsEnv({ kind: 'network' })
|
||||
await renderAndClickSave()
|
||||
const saveButton = await findSystemSaveButton()
|
||||
@@ -207,29 +142,18 @@ describe('AZ-477 — Settings save resilience + 2 s error budget', () => {
|
||||
() => expect(saveButton).not.toBeDisabled(),
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it.fails(
|
||||
'network error → an in-DOM error region (role="alert") appears within 2 s',
|
||||
async () => {
|
||||
it('network error → an in-DOM error region (role="alert") appears within 2 s', async () => {
|
||||
rigSettingsEnv({ kind: 'network' })
|
||||
await renderAndClickSave()
|
||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-3 (NFT-PERF-09) — deadline ≤ 2 s', () => {
|
||||
it.fails(
|
||||
'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.
|
||||
it('500 → DOM error region visible within 2000 ms of the response', async () => {
|
||||
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
|
||||
await renderAndClickSave()
|
||||
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).toBeLessThanOrEqual(2000)
|
||||
expect(alertEl).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cleanup } from '@testing-library/react'
|
||||
import { server } from './msw/server'
|
||||
import { setToken, setNavigateToLogin } from '../src/api'
|
||||
import { __resetBootstrapInflightForTests } from '../src/auth'
|
||||
import { resetAdminSettingsSeed } from './msw/handlers/admin-settings'
|
||||
|
||||
// JSDOM polyfills for browser APIs production code touches at mount time.
|
||||
// These are no-op stubs — tests that exercise the actual behavior install
|
||||
@@ -61,6 +62,8 @@ afterEach(() => {
|
||||
// AZ-510 — clear AuthProvider's module-scoped in-flight bootstrap promise so
|
||||
// a never-resolving fixture in test N does not leak into test N+1.
|
||||
__resetBootstrapInflightForTests()
|
||||
// v2 admin settings — module-scoped seed mutates on PATCH; reset between tests.
|
||||
resetAdminSettingsSeed()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
Reference in New Issue
Block a user