mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 14:31:10 +00:00
Compare commits
11 Commits
401f43d845
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ff522b0821 | |||
| dfcdc26630 | |||
| 60d77d0f29 | |||
| 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:
|
- 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
|
- **Investigate and fix** the failing test or source code
|
||||||
- **Remove the test** if it is obsolete or no longer relevant
|
- **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.
|
- 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
|
- 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
|
- Issue types: Epic, Story, Task, Bug, Subtask
|
||||||
|
|
||||||
## Tracker Availability Gate
|
## 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:
|
- 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.
|
- **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.
|
- 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)
|
## 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.
|
B4. Read File Index — `state.md`, `protocols.md`, and the active flow file.
|
||||||
|
|
||||||
### Resolve (once per invocation, after Bootstrap)
|
### Resolve (once per invocation, after Bootstrap)
|
||||||
R1. Reconcile state — verify state file against `_docs/` contents; on disagreement, trust the folders
|
R1. Reconcile state — verify state file against `_docs/` contents; probe `<workspace-root>/../docs`
|
||||||
and update the state file (rules: `state.md` → "State File Rules" #4).
|
(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.
|
After this step, `state.step` / `state.status` are authoritative.
|
||||||
R2. Resolve flow — see §Flow Resolution above.
|
R2. Resolve flow — see §Flow Resolution above.
|
||||||
R3. Resolve current step — when a state file exists, `state.step` drives detection.
|
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`:
|
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 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
|
- **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
|
- **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 | Config Review | (human checkpoint, no sub-skill) | — |
|
||||||
| 2.5 | Glossary & Architecture Vision | (inline, no sub-skill) | Steps 1–5 |
|
| 2.5 | Glossary & Architecture Vision | (inline, no sub-skill) | Steps 1–5 |
|
||||||
| 3 | Status | monorepo-status/SKILL.md | Sections 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 | 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) |
|
| 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) |
|
| 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
|
- Registry/config mismatches
|
||||||
- Unresolved questions
|
- 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)**
|
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.
|
||||||
- Else if **CI drift** (only) found → auto-chain to **Step 5 (CICD Sync)**
|
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.
|
||||||
- Else if **registry mismatch** found (new components not in config) → present Choose format:
|
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 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 |
|
| Config Review (2, user picked B) | **Session boundary** — end session, await re-invocation |
|
||||||
| Glossary & Architecture Vision (2.5) | Auto-chain → Status (3) |
|
| Glossary & Architecture Vision (2.5) | Auto-chain → Status (3) |
|
||||||
| Status (3, doc drift) | Auto-chain → Document Sync (4) |
|
| Status (3, todo tasks present) | Auto-chain → Suite Implement (3.5) — pre-routing gate fires before drift-based routing |
|
||||||
| Status (3, suite-e2e drift only) | Auto-chain → Integration Test Sync (4.5) |
|
| Status (3, no todo tasks, doc drift) | Auto-chain → Document Sync (4) |
|
||||||
| Status (3, CI drift only) | Auto-chain → CICD Sync (5) |
|
| Status (3, no todo tasks, suite-e2e drift only) | Auto-chain → Integration Test Sync (4.5) |
|
||||||
| Status (3, no drift) | **Cycle complete** — end session, await re-invocation |
|
| 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) |
|
| 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) + 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) + CI drift only pending | Auto-chain → CICD Sync (5) |
|
||||||
| Document Sync (4) + no further drift | **Cycle complete** |
|
| Document Sync (4) + no further drift | **Cycle complete** |
|
||||||
@@ -317,11 +456,12 @@ Flow-specific slot values:
|
|||||||
| 2 | Config Review | `IN PROGRESS (awaiting human)` |
|
| 2 | Config Review | `IN PROGRESS (awaiting human)` |
|
||||||
| 2.5 | Glossary & Architecture Vision | `SKIPPED (already captured)` |
|
| 2.5 | Glossary & Architecture Vision | `SKIPPED (already captured)` |
|
||||||
| 3 | Status | `DONE (no drift)`, `DONE (N drifts)` |
|
| 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 | 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)` |
|
| 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)` |
|
| 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:
|
Row rendering format:
|
||||||
|
|
||||||
@@ -330,6 +470,7 @@ Row rendering format:
|
|||||||
Step 2 Config Review [<state token>]
|
Step 2 Config Review [<state token>]
|
||||||
Step 2.5 Glossary & Architecture Vision [<state token>]
|
Step 2.5 Glossary & Architecture Vision [<state token>]
|
||||||
Step 3 Status [<state token>]
|
Step 3 Status [<state token>]
|
||||||
|
Step 3.5 Suite Implement [<state token>]
|
||||||
Step 4 Document Sync [<state token>]
|
Step 4 Document Sync [<state token>]
|
||||||
Step 4.5 Integration Test Sync [<state token>]
|
Step 4.5 Integration Test Sync [<state token>]
|
||||||
Step 5 CICD Sync [<state token>]
|
Step 5 CICD Sync [<state token>]
|
||||||
@@ -337,8 +478,12 @@ Row rendering format:
|
|||||||
|
|
||||||
## Notes for the meta-repo flow
|
## 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.
|
- **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.
|
- **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`).
|
- **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 |
|
| 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 | 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 |
|
| 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
|
### 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.
|
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.
|
- `<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.
|
- `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)
|
### State token set (shared)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ The autodev persists its position to `_docs/_autodev_state.md`. This is a lightw
|
|||||||
|
|
||||||
## Current Step
|
## Current Step
|
||||||
flow: [greenfield | existing-code | meta-repo]
|
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]
|
name: [step name from the active flow's Step Reference Table]
|
||||||
status: [not_started / in_progress / completed / skipped / failed]
|
status: [not_started / in_progress / completed / skipped / failed]
|
||||||
sub_step:
|
sub_step:
|
||||||
@@ -82,6 +82,19 @@ retry_count: 0
|
|||||||
cycle: 1
|
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
|
flow: existing-code
|
||||||
step: 10
|
step: 10
|
||||||
@@ -100,7 +113,7 @@ cycle: 3
|
|||||||
1. **Create** on the first autodev invocation (after state detection determines Step 1)
|
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.
|
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
|
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
|
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`
|
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
|
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)
|
└── 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)
|
## Prerequisite Checks (BLOCKING)
|
||||||
|
|
||||||
1. `TASKS_DIR/todo/` exists and contains at least one task file for the selected context — **STOP if missing**
|
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
|
### 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:
|
For each task in the batch:
|
||||||
- Read the task spec's **Component** field.
|
- 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)
|
### 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)
|
- **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
|
- **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)
|
- **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
|
### 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.
|
**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`
|
- **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`.
|
- **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`
|
- **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).
|
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
|
- [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
|
BLOCKING gate and the user skips the choice prompt, the autodev MUST default
|
||||||
to the most conservative spec-aligned option (Option A: file prerequisite
|
to the most conservative spec-aligned option (Option A: file prerequisite
|
||||||
|
|||||||
+12
-7
@@ -2,19 +2,24 @@
|
|||||||
|
|
||||||
## Current Step
|
## Current Step
|
||||||
flow: existing-code
|
flow: existing-code
|
||||||
step: 16
|
step: 9
|
||||||
name: Deploy
|
name: New Task
|
||||||
status: not_started
|
status: not_started
|
||||||
sub_step:
|
sub_step:
|
||||||
phase: 0
|
phase: 0
|
||||||
name: awaiting-invocation
|
name: awaiting-invocation
|
||||||
detail: ""
|
detail: ""
|
||||||
retry_count: 0
|
retry_count: 0
|
||||||
cycle: 4
|
cycle: 5
|
||||||
tracker: jira
|
tracker: jira
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Cycle 4 batch 16 shipped (commit ecacfa8): AZ-512 — 3/3 pts. Jira: To Do → In Testing.
|
- 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.
|
||||||
- Cross-workspace: AZ-513 on admin/ NOT shipped. Step 16 (Deploy) gates on it.
|
- Cycle 5 awaiting next `/autodev` New Task invocation.
|
||||||
- Leftovers: `2026-05-12_az-498-deploy-and-key-revocations.md` (manual), `2026-05-13_az-512-admin-classes-prereq.md` (re-opened).
|
- Leftovers carried into cycle 5 (replay at Step 0):
|
||||||
- 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.
|
- `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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AZAION</title>
|
<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>
|
</head>
|
||||||
<body class="bg-[#1e1e1e] text-[#adb5bd]">
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+15
-13
@@ -1,6 +1,6 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthProvider, ProtectedRoute } from './auth'
|
import { AuthProvider, ProtectedRoute } from './auth'
|
||||||
import { Header, FlightProvider } from './components'
|
import { Header, FlightProvider, SavedAnnotationsProvider } from './components'
|
||||||
import { LoginPage } from './features/login'
|
import { LoginPage } from './features/login'
|
||||||
import { FlightsPage } from './features/flights'
|
import { FlightsPage } from './features/flights'
|
||||||
import { AnnotationsPage } from './features/annotations'
|
import { AnnotationsPage } from './features/annotations'
|
||||||
@@ -18,19 +18,21 @@ export default function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<FlightProvider>
|
<FlightProvider>
|
||||||
<div className="flex flex-col h-screen">
|
<SavedAnnotationsProvider>
|
||||||
<Header />
|
<div className="flex flex-col h-screen">
|
||||||
<div className="flex-1 overflow-hidden">
|
<Header />
|
||||||
<Routes>
|
<div className="flex-1 overflow-hidden">
|
||||||
<Route path="/flights" element={<FlightsPage />} />
|
<Routes>
|
||||||
<Route path="/annotations" element={<AnnotationsPage />} />
|
<Route path="/flights" element={<FlightsPage />} />
|
||||||
<Route path="/dataset" element={<DatasetPage />} />
|
<Route path="/annotations" element={<AnnotationsPage />} />
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/dataset" element={<DatasetPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
<Route path="*" element={<Navigate to="/flights" replace />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Routes>
|
<Route path="*" element={<Navigate to="/flights" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SavedAnnotationsProvider>
|
||||||
</FlightProvider>
|
</FlightProvider>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ describe('AZ-486 endpoints — wire-contract URLs', () => {
|
|||||||
// Assert
|
// Assert
|
||||||
expect(endpoints.admin.class(42)).toBe('/api/admin/classes/42')
|
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', () => {
|
describe('AC-1: annotations', () => {
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export const endpoints = {
|
|||||||
// DetectionClass.id is `number` in the type system; widened to accept
|
// DetectionClass.id is `number` in the type system; widened to accept
|
||||||
// string for forward-compat if the backend switches the column to UUID.
|
// string for forward-compat if the backend switches the column to UUID.
|
||||||
class: (id: string | number) => `/api/admin/classes/${id}`,
|
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: {
|
annotations: {
|
||||||
classes: () => '/api/annotations/classes',
|
classes: () => '/api/annotations/classes',
|
||||||
|
|||||||
@@ -24,19 +24,33 @@ export function useAuth() {
|
|||||||
// AZ-510 spec.
|
// AZ-510 spec.
|
||||||
let bootstrapInflight: Promise<AuthUser | null> | null = null
|
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 {
|
export function __resetBootstrapInflightForTests(): void {
|
||||||
bootstrapInflight = null
|
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> {
|
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
|
// POST refresh with credentials — the whole point of the consolidation. Goes
|
||||||
// through fetch() directly (not api.post) because api.post does not thread
|
// through fetch() directly (not api.post) because api.post does not thread
|
||||||
// credentials:'include'; widening api.post would change CORS posture for
|
// credentials:'include'; widening api.post would change CORS posture for
|
||||||
|
|||||||
@@ -22,3 +22,11 @@ export function getClassNameFallback(classNum: number): string {
|
|||||||
const base = classNum % 20
|
const base = classNum % 20
|
||||||
return FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length] ?? `#${classNum}`
|
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,
|
getClassColor,
|
||||||
getPhotoModeSuffix,
|
getPhotoModeSuffix,
|
||||||
getClassNameFallback,
|
getClassNameFallback,
|
||||||
|
hexToRgba,
|
||||||
FALLBACK_CLASS_NAMES,
|
FALLBACK_CLASS_NAMES,
|
||||||
} from './classColors'
|
} from './classColors'
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md'
|
|
||||||
import { FaRegSnowflake } from 'react-icons/fa'
|
|
||||||
import { api, endpoints } from '../api'
|
import { api, endpoints } from '../api'
|
||||||
// classColors lives under 06_annotations until F3 moves it to its own home.
|
// classColors lives under 06_annotations until F3 moves it to its own home.
|
||||||
// Importing through the 06_annotations barrel would create a cycle
|
// Importing through the 06_annotations barrel would create a cycle
|
||||||
@@ -60,43 +58,71 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
|
|||||||
}
|
}
|
||||||
}, [classes, photoMode, selectedClassNum, onSelect])
|
}, [classes, photoMode, selectedClassNum, onSelect])
|
||||||
|
|
||||||
|
const modeClasses = classes.filter(c => c.photoMode === photoMode)
|
||||||
|
|
||||||
const modes = [
|
const modes = [
|
||||||
{ value: 0, label: t('annotations.regular'), icon: <MdOutlineWbSunny />, activeClass: 'bg-az-orange text-white', iconColor: 'text-az-orange' },
|
{ value: 0, label: t('annotations.regular') },
|
||||||
{ value: 20, label: t('annotations.winter'), icon: <FaRegSnowflake />, activeClass: 'bg-az-blue text-white', iconColor: 'text-az-blue' },
|
{ value: 20, label: t('annotations.winter') },
|
||||||
{ value: 40, label: t('annotations.night'), icon: <MdOutlineNightlightRound />, activeClass: 'bg-purple-600 text-white', iconColor: 'text-purple-400' },
|
{ value: 40, label: t('annotations.night') },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-az-border p-2">
|
<div className="border-t border-border-hair">
|
||||||
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.classes')}</div>
|
{/* Section header */}
|
||||||
<div className="space-y-0.5 max-h-48 overflow-y-auto mb-2">
|
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
|
||||||
{classes.filter(c => c.photoMode === photoMode).map((c, i) => (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<span className="sect-head">{t('annotations.classes')}</span>
|
||||||
key={c.id}
|
<span className="mono text-[10px] text-text-muted">{modeClasses.length.toString().padStart(2, '0')}</span>
|
||||||
onClick={() => onSelect(c.id)}
|
</div>
|
||||||
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>
|
</div>
|
||||||
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.photoMode')}</div>
|
|
||||||
<div className="flex gap-1">
|
{/* Column headers */}
|
||||||
{modes.map(m => (
|
<div className="grid grid-cols-[28px_1fr_auto] px-3 h-6 items-center border-b border-border-hair gap-2">
|
||||||
<button
|
<span className="micro">{t('annotations.colNum')}</span>
|
||||||
key={m.value}
|
<span className="micro">{t('annotations.colName')}</span>
|
||||||
onClick={() => onPhotoModeChange(m.value)}
|
<span className="micro">{t('annotations.colKey')}</span>
|
||||||
title={m.label}
|
</div>
|
||||||
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`}`}
|
|
||||||
>
|
{/* Class rows */}
|
||||||
{m.icon}
|
<div>
|
||||||
</button>
|
{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)}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+97
-37
@@ -3,17 +3,15 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useAuth } from '../auth'
|
import { useAuth } from '../auth'
|
||||||
import { useFlight } from './FlightContext'
|
import { useFlight } from './FlightContext'
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import HelpModal from './HelpModal'
|
|
||||||
import type { Flight } from '../types'
|
import type { Flight } from '../types'
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { user, logout, hasPermission } = useAuth()
|
const { user, logout, hasPermission } = useAuth()
|
||||||
const { flights, selectedFlight, selectFlight } = useFlight()
|
const { flights, selectedFlight, selectFlight } = useFlight()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [showDropdown, setShowDropdown] = useState(false)
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
const [showHelp, setShowHelp] = useState(false)
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,25 +37,56 @@ export default function Header() {
|
|||||||
{ to: '/admin', label: t('nav.admin'), perm: 'ADM' },
|
{ to: '/admin', label: t('nav.admin'), perm: 'ADM' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const toggleLang = () => {
|
|
||||||
i18n.changeLanguage(i18n.language === 'en' ? 'ua' : 'en')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center h-10 bg-az-header border-b border-az-border px-3 gap-3 text-sm shrink-0">
|
<header
|
||||||
<span className="font-bold text-az-orange tracking-wider">AZAION</span>
|
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}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDropdown(!showDropdown)}
|
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>
|
</button>
|
||||||
{showDropdown && (
|
{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
|
<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..."
|
placeholder="Filter..."
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={e => setFilter(e.target.value)}
|
onChange={e => setFilter(e.target.value)}
|
||||||
@@ -68,66 +97,97 @@ export default function Header() {
|
|||||||
<button
|
<button
|
||||||
key={f.id}
|
key={f.id}
|
||||||
onClick={() => { selectFlight(f); setShowDropdown(false); setFilter('') }}
|
onClick={() => { selectFlight(f); setShowDropdown(false); setFilter('') }}
|
||||||
className={`w-full text-left px-2 py-1 hover:bg-az-bg text-az-text text-sm ${
|
className="w-full text-left"
|
||||||
selectedFlight?.id === f.id ? 'bg-az-bg font-semibold' : ''
|
style={{
|
||||||
}`}
|
padding: '6px 10px',
|
||||||
|
background: selectedFlight?.id === f.id ? 'var(--surface-2)' : 'transparent',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div>{f.name}</div>
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && (
|
{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>
|
</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 => (
|
{navItems.filter(n => hasPermission(n.perm)).map(n => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={n.to}
|
key={n.to}
|
||||||
to={n.to}
|
to={n.to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) => `tab${isActive ? ' active' : ''}`}
|
||||||
`px-2 py-1 rounded text-sm ${isActive ? 'bg-az-bg font-semibold text-white' : 'text-az-text hover:text-white'}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{n.label}
|
{n.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex items-center gap-2 ml-auto micro">
|
||||||
|
<span
|
||||||
<span className="text-xs text-az-muted hidden sm:block">{user?.email}</span>
|
className="dot live"
|
||||||
<button onClick={toggleLang} className="text-xs text-az-muted hover:text-white px-1">
|
style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-cyan)' }}
|
||||||
{i18n.language === 'en' ? 'UA' : 'EN'}
|
/>
|
||||||
</button>
|
<span style={{ color: 'var(--accent-cyan)' }}>LINK</span>
|
||||||
<button onClick={() => setShowHelp(true)} className="text-az-muted hover:text-white text-xs">?</button>
|
<span style={{ color: 'var(--border-raised)' }}>|</span>
|
||||||
<NavLink to="/settings" className="text-az-muted hover:text-white">⚙</NavLink>
|
<span
|
||||||
<button onClick={handleLogout} className="text-az-muted hover:text-az-red text-xs">
|
className="hidden md:inline"
|
||||||
{t('nav.logout')}
|
style={{ color: 'var(--text-secondary)', textTransform: 'none', letterSpacing: 0 }}
|
||||||
</button>
|
>
|
||||||
|
{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 */}
|
{/* 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 => (
|
{navItems.filter(n => hasPermission(n.perm)).map(n => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={n.to}
|
key={n.to}
|
||||||
to={n.to}
|
to={n.to}
|
||||||
className={({ isActive }) =>
|
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}
|
{n.label}
|
||||||
</NavLink>
|
</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>
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
<HelpModal open={showHelp} onClose={() => setShowHelp(false)} />
|
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export { default as HelpModal } from './HelpModal'
|
|||||||
export { default as ConfirmDialog } from './ConfirmDialog'
|
export { default as ConfirmDialog } from './ConfirmDialog'
|
||||||
export { default as DetectionClasses } from './DetectionClasses'
|
export { default as DetectionClasses } from './DetectionClasses'
|
||||||
export { FlightProvider, useFlight } from './FlightContext'
|
export { FlightProvider, useFlight } from './FlightContext'
|
||||||
|
export { SavedAnnotationsProvider, useSavedAnnotations } from './SavedAnnotationsContext'
|
||||||
|
|||||||
+626
-229
@@ -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 { useTranslation } from 'react-i18next'
|
||||||
import { api, endpoints } from '../../api'
|
import { api, endpoints } from '../../api'
|
||||||
import { ConfirmDialog } from '../../components'
|
import type { DetectionClass, Aircraft, GpsProtocol } from '../../types'
|
||||||
import type { DetectionClass, Aircraft, User } 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 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() {
|
export default function AdminPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [classes, setClasses] = useState<DetectionClass[]>([])
|
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||||
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
||||||
const [users, setUsers] = useState<User[]>([])
|
const [classFilter, setClassFilter] = useState('')
|
||||||
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 [editingId, setEditingId] = useState<number | null>(null)
|
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 [editError, setEditError] = useState<EditErrorKind | null>(null)
|
||||||
const [editSaving, setEditSaving] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
|
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
|
||||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||||
api.get<User[]>(endpoints.admin.users()).then(setUsers).catch(() => {})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleAddClass = async () => {
|
const filteredClasses = useMemo(() => {
|
||||||
if (!newClass.name) return
|
const q = classFilter.trim().toLowerCase()
|
||||||
await api.post(endpoints.admin.classes(), newClass)
|
if (!q) return classes
|
||||||
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
|
return classes.filter(c => c.name.toLowerCase().includes(q))
|
||||||
setClasses(updated)
|
}, [classes, classFilter])
|
||||||
setNewClass({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
|
|
||||||
|
const handleStartAdd = () => {
|
||||||
|
setEditingId(ADDING_ID)
|
||||||
|
setEditForm({ ...NEW_CLASS_DEFAULTS })
|
||||||
|
setEditError(null)
|
||||||
|
setEditSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteClass = async (id: number) => {
|
const handleDeleteClass = async (id: number) => {
|
||||||
@@ -54,18 +152,19 @@ export default function AdminPage() {
|
|||||||
setEditSaving(false)
|
setEditSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateClass = async () => {
|
const handleSaveClass = async () => {
|
||||||
if (editingId === null || editSaving) return
|
if (editingId === null || editSaving) return
|
||||||
if (!editForm.name.trim()) { setEditError('nameRequired'); return }
|
if (!editForm.name.trim()) { setEditError('nameRequired'); return }
|
||||||
if (!(editForm.maxSizeM > 0)) { setEditError('maxSizeMustBePositive'); return }
|
|
||||||
setEditError(null)
|
setEditError(null)
|
||||||
setEditSaving(true)
|
setEditSaving(true)
|
||||||
try {
|
try {
|
||||||
// Risk 2 mitigation — always send the complete form so backend PATCH
|
if (editingId === ADDING_ID) {
|
||||||
// semantics (full-replace vs partial-merge) don't matter.
|
const created = await api.post<DetectionClass>(endpoints.admin.classes(), editForm)
|
||||||
await api.patch(endpoints.admin.class(editingId), editForm)
|
setClasses(prev => [...prev, created])
|
||||||
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
|
} else {
|
||||||
setClasses(updated)
|
const updated = await api.patch<DetectionClass>(endpoints.admin.class(editingId), editForm)
|
||||||
|
setClasses(prev => prev.map(c => c.id === editingId ? updated : c))
|
||||||
|
}
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
} catch {
|
} catch {
|
||||||
setEditError('updateFailed')
|
setEditError('updateFailed')
|
||||||
@@ -75,244 +174,542 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLElement>) => {
|
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() }
|
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) => {
|
const handleToggleDefault = async (a: Aircraft) => {
|
||||||
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
||||||
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full overflow-y-auto p-4 gap-4">
|
<main className="flex h-full overflow-hidden" style={{ background: 'var(--surface-0)' }}>
|
||||||
{/* Detection classes */}
|
|
||||||
<div className="w-[340px] shrink-0">
|
{/* ===== LEFT: DETECTION CLASSES (340px) ===== */}
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes.title')}</h2>
|
<aside
|
||||||
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
|
className="shrink-0 flex flex-col"
|
||||||
<table className="w-full text-xs">
|
style={{ width: 340, background: 'var(--surface-1)', borderRight: '1px solid var(--border-hair)' }}
|
||||||
<thead>
|
>
|
||||||
<tr className="border-b border-az-border text-az-muted">
|
<div
|
||||||
<th className="px-2 py-1 text-left">#</th>
|
className="px-4 pt-4 pb-3 flex items-center justify-between"
|
||||||
<th className="px-2 py-1 text-left">Name</th>
|
style={{ borderBottom: '1px solid var(--border-hair)' }}
|
||||||
<th className="px-2 py-1">Color</th>
|
>
|
||||||
<th className="px-2 py-1"></th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{classes.map(c => c.id === editingId ? (
|
{editingId === ADDING_ID && (
|
||||||
<tr key={c.id} className="border-b border-az-border text-az-text bg-az-bg/40" data-editing-row={c.id}>
|
<ClassEditRow
|
||||||
<td className="px-2 py-1 align-top">{c.id}</td>
|
idCell="+"
|
||||||
<td colSpan={3} className="px-2 py-1">
|
rowId="new"
|
||||||
<div className="flex flex-wrap gap-1 items-center" onKeyDown={handleEditKeyDown}>
|
form={editForm}
|
||||||
<input
|
onChange={setEditForm}
|
||||||
autoFocus
|
onSave={() => void handleSaveClass()}
|
||||||
data-field="name"
|
onCancel={handleCancelEdit}
|
||||||
value={editForm.name}
|
onKeyDown={handleEditKeyDown}
|
||||||
onChange={e => setEditForm(p => ({ ...p, name: e.target.value }))}
|
saving={editSaving}
|
||||||
className="flex-1 min-w-[80px] bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
errorMessage={editError ? t(`admin.classes.${editError}`) : null}
|
||||||
/>
|
placeholderName="Name"
|
||||||
<input
|
/>
|
||||||
data-field="shortName"
|
)}
|
||||||
value={editForm.shortName}
|
{filteredClasses.map(c => c.id === editingId ? (
|
||||||
onChange={e => setEditForm(p => ({ ...p, shortName: e.target.value }))}
|
<ClassEditRow
|
||||||
className="w-12 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
key={c.id}
|
||||||
/>
|
idCell={c.id}
|
||||||
<input
|
rowId={c.id}
|
||||||
type="color"
|
form={editForm}
|
||||||
data-field="color"
|
onChange={setEditForm}
|
||||||
value={editForm.color}
|
onSave={() => void handleSaveClass()}
|
||||||
onChange={e => setEditForm(p => ({ ...p, color: e.target.value }))}
|
onCancel={handleCancelEdit}
|
||||||
className="w-7 h-6 border-0 bg-transparent cursor-pointer"
|
onKeyDown={handleEditKeyDown}
|
||||||
/>
|
saving={editSaving}
|
||||||
<input
|
errorMessage={editError ? t(`admin.classes.${editError}`) : null}
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => void handleUpdateClass()}
|
|
||||||
disabled={editSaving}
|
|
||||||
className="bg-az-orange text-white px-2 py-0.5 rounded disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('admin.classes.save')}
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
{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">
|
<tr key={c.id} className="row-hover" style={{ borderBottom: '1px solid var(--border-hair)', height: 32 }}>
|
||||||
<td className="px-2 py-1">{c.id}</td>
|
<td className="px-3 mono tnum" style={{ color: 'var(--text-muted)', fontSize: 12 }}>{c.id}</td>
|
||||||
<td className="px-2 py-1">{c.name}</td>
|
<td className="px-2"><span style={{ fontSize: 12 }}>{c.name}</span></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 text-center"><span className="swatch" style={{ background: c.color }} /></td>
|
||||||
<td className="px-2 py-1 text-right whitespace-nowrap">
|
<td className="px-3 text-right">
|
||||||
<button
|
<span className="reveal inline-flex gap-1">
|
||||||
onClick={() => handleStartEdit(c)}
|
<button
|
||||||
aria-label={t('admin.classes.edit')}
|
type="button"
|
||||||
className="text-az-muted hover:text-az-orange mr-1"
|
onClick={() => handleStartEdit(c)}
|
||||||
>
|
className="ibtn edit"
|
||||||
{'\u270E'}
|
aria-label={t('admin.classes.edit')}
|
||||||
</button>
|
title={t('admin.classes.edit')}
|
||||||
<button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button>
|
>
|
||||||
|
<PencilIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteClass(c.id)}
|
||||||
|
className="ibtn danger"
|
||||||
|
aria-label="×"
|
||||||
|
title={t('admin.classes.delete')}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
</div>
|
</aside>
|
||||||
|
|
||||||
{/* Center: AI + GPS settings */}
|
{/* ===== CENTER ===== */}
|
||||||
<div className="flex-1 space-y-4 max-w-md">
|
<section className="flex-1 overflow-y-auto grid-bg">
|
||||||
<div>
|
<div className="max-w-[920px] mx-auto p-6 space-y-6">
|
||||||
<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">
|
{/* AI RECOGNITION ENGINE */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted">Frame Period Recognition</label>
|
<div className="flex items-end justify-between mb-3">
|
||||||
<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 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>
|
||||||
<div>
|
|
||||||
<label className="text-az-muted">Frame Recognition Seconds</label>
|
<div className="bracket panel p-5">
|
||||||
<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" />
|
<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>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
{/* GPS DEVICE LINK */}
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.gpsSettings')}</h2>
|
<div>
|
||||||
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2 text-xs">
|
<div className="flex items-end justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted">Device Address</label>
|
<div className="sect-head">{t('admin.gpsDevice.title')}</div>
|
||||||
<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 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>
|
||||||
<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 className="bracket panel p-5">
|
||||||
<div>
|
<span className="br" />
|
||||||
<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">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
|
||||||
<table className="w-full text-xs">
|
<div>
|
||||||
<thead>
|
<label className="micro block mb-1">{t('admin.gpsDevice.address')}</label>
|
||||||
<tr className="border-b border-az-border text-az-muted">
|
<div className="hint mb-2">{t('admin.gpsDevice.addressHint')}</div>
|
||||||
<th className="px-2 py-1 text-left">Name</th>
|
<input
|
||||||
<th className="px-2 py-1 text-left">Email</th>
|
className="inp inp-mono"
|
||||||
<th className="px-2 py-1">Role</th>
|
value={gps.draft.address}
|
||||||
<th className="px-2 py-1">Status</th>
|
placeholder="0.0.0.0"
|
||||||
<th className="px-2 py-1"></th>
|
onChange={e => gps.setDraft({ ...gps.draft, address: e.target.value })}
|
||||||
</tr>
|
aria-label={t('admin.gpsDevice.address')}
|
||||||
</thead>
|
/>
|
||||||
<tbody>
|
</div>
|
||||||
{users.map(u => (
|
|
||||||
<tr key={u.id} className="border-b border-az-border text-az-text">
|
<div>
|
||||||
<td className="px-2 py-1">{u.name}</td>
|
<label className="micro block mb-1">{t('admin.gpsDevice.port')}</label>
|
||||||
<td className="px-2 py-1">{u.email}</td>
|
<div className="hint mb-2">{t('admin.gpsDevice.portHint')}</div>
|
||||||
<td className="px-2 py-1 text-center">{u.role}</td>
|
<input
|
||||||
<td className="px-2 py-1 text-center">
|
className="inp inp-mono"
|
||||||
<span className={`px-1 rounded ${u.isActive ? 'text-az-green' : 'text-az-red'}`}>
|
type="number"
|
||||||
{u.isActive ? 'Active' : 'Inactive'}
|
value={gps.draft.port}
|
||||||
</span>
|
onChange={e => gps.setDraft({ ...gps.draft, port: Number(e.target.value) })}
|
||||||
</td>
|
style={{ textAlign: 'right' }}
|
||||||
<td className="px-2 py-1">
|
aria-label={t('admin.gpsDevice.port')}
|
||||||
{u.isActive && (
|
/>
|
||||||
<button onClick={() => setDeactivateId(u.id)} className="text-az-muted hover:text-az-red text-xs">
|
</div>
|
||||||
{t('admin.deactivate')}
|
</div>
|
||||||
</button>
|
|
||||||
)}
|
<div className="mt-5">
|
||||||
</td>
|
<label className="micro block mb-1">{t('admin.gpsDevice.protocol')}</label>
|
||||||
</tr>
|
<div className="hint mb-2">{t('admin.gpsDevice.protocolHint')}</div>
|
||||||
))}
|
<div className="seg" role="group" aria-label={t('admin.gpsDevice.protocol')}>
|
||||||
</tbody>
|
{PROTOCOLS.map(p => (
|
||||||
</table>
|
<button
|
||||||
<div className="p-2 flex gap-1 border-t border-az-border">
|
key={p}
|
||||||
<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" />
|
type="button"
|
||||||
<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" />
|
onClick={() => gps.setDraft({ ...gps.draft, protocol: p })}
|
||||||
<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" />
|
className={`seg-btn${gps.draft.protocol === p ? ' active' : ''}`}
|
||||||
<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">
|
aria-pressed={gps.draft.protocol === p}
|
||||||
<option>Annotator</option>
|
>
|
||||||
<option>Admin</option>
|
{p}
|
||||||
<option>Viewer</option>
|
</button>
|
||||||
</select>
|
))}
|
||||||
<button onClick={handleAddUser} className="bg-az-orange text-white text-xs px-2 py-1 rounded">+</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* Aircrafts sidebar */}
|
{/* ===== RIGHT: DEFAULT AIRCRAFTS (280px) ===== */}
|
||||||
<div className="w-[280px] shrink-0">
|
<aside
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.aircrafts')}</h2>
|
className="shrink-0 flex flex-col"
|
||||||
<div className="bg-az-panel border border-az-border rounded p-2 space-y-1">
|
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 => (
|
{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">
|
<div
|
||||||
<span className={`px-1 rounded text-[10px] ${a.type === 'Plane' ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
|
key={a.id}
|
||||||
{a.type === 'Plane' ? 'P' : 'C'}
|
data-aircraft-id={a.id}
|
||||||
</span>
|
className="row-hover flex items-center gap-3 px-4 py-2.5"
|
||||||
<span className="flex-1">{a.model}</span>
|
style={{
|
||||||
<span className={`text-sm ${a.isDefault ? 'text-az-orange' : 'text-az-muted'}`}>★</span>
|
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>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<div
|
||||||
open={!!deactivateId}
|
className="px-4 py-3"
|
||||||
title={t('admin.deactivate')}
|
style={{ borderTop: '1px solid var(--border-hair)', background: 'var(--surface-0)' }}
|
||||||
message="Deactivate this user?"
|
>
|
||||||
onConfirm={handleDeactivate}
|
<button
|
||||||
onCancel={() => setDeactivateId(null)}
|
type="button"
|
||||||
/>
|
className="btn btn-secondary w-full justify-center"
|
||||||
</div>
|
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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => void saveAircraft()}
|
||||||
|
disabled={aircraftSaving}
|
||||||
|
>
|
||||||
|
{t('admin.aircrafts.addTitle')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldModel')}</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="inp inp-mono"
|
||||||
|
value={aircraftDraft.model}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, model: e.target.value }))}
|
||||||
|
placeholder="DJI Mavic 3"
|
||||||
|
aria-label={t('admin.aircrafts.fieldModel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldType')}</label>
|
||||||
|
<div className="seg" role="group" aria-label={t('admin.aircrafts.fieldType')}>
|
||||||
|
{AIRCRAFT_TYPES.map(typ => (
|
||||||
|
<button
|
||||||
|
key={typ}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAircraftDraft(p => ({ ...p, type: typ }))}
|
||||||
|
className={`seg-btn${aircraftDraft.type === typ ? ' active' : ''}`}
|
||||||
|
aria-pressed={aircraftDraft.type === typ}
|
||||||
|
>
|
||||||
|
{t(`admin.aircrafts.${TYPE_LEGEND_KEY[typ]}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldResolution')}</label>
|
||||||
|
<select
|
||||||
|
className="inp inp-mono"
|
||||||
|
value={aircraftDraft.resolution}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, resolution: e.target.value }))}
|
||||||
|
aria-label={t('admin.aircrafts.fieldResolution')}
|
||||||
|
>
|
||||||
|
{RESOLUTIONS.map(r => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldMaxMinutes')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="inp inp-mono"
|
||||||
|
value={aircraftDraft.maxMinutes}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, maxMinutes: Number(e.target.value) }))}
|
||||||
|
style={{ textAlign: 'right' }}
|
||||||
|
aria-label={t('admin.aircrafts.fieldMaxMinutes')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={aircraftDraft.isDefault}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, isDefault: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span>{t('admin.aircrafts.fieldDefault')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{aircraftError && (
|
||||||
|
<div role="alert" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
|
||||||
|
{t(`admin.aircrafts.${aircraftError}`)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useResizablePanel } from '../../hooks'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { api, endpoints } from '../../api'
|
import { api, endpoints } from '../../api'
|
||||||
import MediaList from './MediaList'
|
import MediaList from './MediaList'
|
||||||
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
||||||
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
||||||
import AnnotationsSidebar from './AnnotationsSidebar'
|
import AnnotationsSidebar from './AnnotationsSidebar'
|
||||||
|
import Scrubber, { type ScrubberMark } from './Scrubber'
|
||||||
import { DetectionClasses, useFlight } from '../../components'
|
import { DetectionClasses, useFlight } from '../../components'
|
||||||
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||||
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
||||||
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors'
|
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors'
|
||||||
import { captureThumbnails } from './thumbnail'
|
import { captureThumbnails } from './thumbnail'
|
||||||
|
import { formatTime, formatTicks, parseAnnotationTime } from './time'
|
||||||
import type { Media, AnnotationListItem, Detection } from '../../types'
|
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() {
|
export default function AnnotationsPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null)
|
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null)
|
||||||
const [currentTime, setCurrentTime] = useState(0)
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
const [annotations, setAnnotations] = useState<AnnotationListItem[]>([])
|
const [annotations, setAnnotations] = useState<AnnotationListItem[]>([])
|
||||||
const [selectedAnnotation, setSelectedAnnotation] = useState<AnnotationListItem | null>(null)
|
const [selectedAnnotation, setSelectedAnnotation] = useState<AnnotationListItem | null>(null)
|
||||||
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
||||||
const [photoMode, setPhotoMode] = useState(0)
|
const [photoMode, setPhotoMode] = useState(0)
|
||||||
const [detections, setDetections] = useState<Detection[]>([])
|
const [detections, setDetections] = useState<Detection[]>([])
|
||||||
const leftPanel = useResizablePanel(250, 200, 400)
|
const [zoom, setZoom] = useState(1)
|
||||||
const rightPanel = useResizablePanel(200, 150, 350)
|
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 videoPlayerRef = useRef<VideoPlayerHandle>(null)
|
||||||
const canvasRef = useRef<CanvasEditorHandle>(null)
|
const canvasRef = useRef<CanvasEditorHandle>(null)
|
||||||
const { addMany } = useSavedAnnotations()
|
const { addMany } = useSavedAnnotations()
|
||||||
const { selectedFlight } = useFlight()
|
const { selectedFlight } = useFlight()
|
||||||
|
|
||||||
|
const isVideo = selectedMedia?.mediaType === MediaType.Video
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDetections([])
|
setDetections([])
|
||||||
setSelectedAnnotation(null)
|
setSelectedAnnotation(null)
|
||||||
setCurrentTime(0)
|
setCurrentTime(0)
|
||||||
|
setDuration(0)
|
||||||
|
setIsPlaying(false)
|
||||||
|
setMuted(false)
|
||||||
}, [selectedMedia])
|
}, [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 () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!selectedMedia || !detections.length) return
|
if (!selectedMedia || !detections.length) return
|
||||||
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
|
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
|
||||||
@@ -108,7 +178,6 @@ export default function AnnotationsPage() {
|
|||||||
txtA.click()
|
txtA.click()
|
||||||
URL.revokeObjectURL(txtUrl)
|
URL.revokeObjectURL(txtUrl)
|
||||||
|
|
||||||
// Build the image: video frame or image with rectangles drawn
|
|
||||||
const videoEl = videoPlayerRef.current?.getVideoElement() ?? null
|
const videoEl = videoPlayerRef.current?.getVideoElement() ?? null
|
||||||
let w = 0, h = 0
|
let w = 0, h = 0
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -181,11 +250,10 @@ export default function AnnotationsPage() {
|
|||||||
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
|
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
|
||||||
setSelectedAnnotation(ann)
|
setSelectedAnnotation(ann)
|
||||||
setDetections(ann.detections)
|
setDetections(ann.detections)
|
||||||
if (ann.time) {
|
const sec = parseAnnotationTime(ann.time)
|
||||||
const parts = ann.time.split(':').map(Number)
|
if (sec != null) {
|
||||||
const seconds = (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0)
|
videoPlayerRef.current?.seek(sec)
|
||||||
videoPlayerRef.current?.seek(seconds)
|
setCurrentTime(sec)
|
||||||
setCurrentTime(seconds)
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -193,20 +261,68 @@ export default function AnnotationsPage() {
|
|||||||
setDetections(dets)
|
setDetections(dets)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isVideo = selectedMedia?.mediaType === MediaType.Video
|
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])
|
||||||
|
|
||||||
function formatTicks(seconds: number): string {
|
// Clear any pending AI-banner close timer on unmount.
|
||||||
const h = Math.floor(seconds / 3600)
|
useEffect(() => () => {
|
||||||
const m = Math.floor((seconds % 3600) / 60)
|
if (aiCloseTimerRef.current != null) {
|
||||||
const s = Math.floor(seconds % 60)
|
window.clearTimeout(aiCloseTimerRef.current)
|
||||||
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
|
aiCloseTimerRef.current = null
|
||||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}`
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{/* Left panel */}
|
{/* LEFT SIDEBAR */}
|
||||||
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
<div style={{ width: 232 }} className="bg-surface-1 flex flex-col shrink-0 border-r border-border-hair">
|
||||||
<MediaList
|
<MediaList
|
||||||
selectedMedia={selectedMedia}
|
selectedMedia={selectedMedia}
|
||||||
onSelect={setSelectedMedia}
|
onSelect={setSelectedMedia}
|
||||||
@@ -219,42 +335,62 @@ export default function AnnotationsPage() {
|
|||||||
onPhotoModeChange={setPhotoMode}
|
onPhotoModeChange={setPhotoMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
{/* CENTER */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 bg-surface-0">
|
||||||
{/* Center - video/canvas */}
|
{/* Canvas top bar */}
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="h-9 flex items-center gap-3 px-4 border-b border-border-hair bg-surface-1 shrink-0">
|
||||||
{selectedMedia && (
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0">
|
<span className="sect-head">{t('annotations.canvas')}</span>
|
||||||
<button
|
{selectedMedia && (
|
||||||
onClick={handleSave}
|
<>
|
||||||
disabled={!detections.length}
|
<span className="mono text-[11px] text-text-muted">{selectedMedia.name}</span>
|
||||||
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"
|
{dims && (
|
||||||
>
|
<span className="mono text-[10px] px-1.5 py-0.5 border border-border-hair text-text-secondary">
|
||||||
Save
|
{dims.w}×{dims.h} · {fps} FPS
|
||||||
</button>
|
</span>
|
||||||
<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>
|
</div>
|
||||||
)}
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{selectedMedia && isVideo && (
|
<span className="micro">{t('annotations.zoom')}</span>
|
||||||
<VideoPlayer
|
<span className="mono text-[11px] text-text-primary">{Math.round(zoom * 100)}%</span>
|
||||||
ref={videoPlayerRef}
|
<span className="mx-2 h-4 w-px bg-border-hair" />
|
||||||
media={selectedMedia}
|
<span className="micro">{t('annotations.cursor')}</span>
|
||||||
onTimeUpdate={setCurrentTime}
|
<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}
|
||||||
|
media={selectedMedia}
|
||||||
|
annotation={selectedAnnotation}
|
||||||
|
detections={detections}
|
||||||
|
onDetectionsChange={handleDetectionsChange}
|
||||||
|
selectedClassNum={selectedClassNum}
|
||||||
|
currentTime={currentTime}
|
||||||
|
annotations={annotations}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCursorChange={(x, y) => setCursor({ x, y })}
|
||||||
|
/>
|
||||||
|
</VideoPlayer>
|
||||||
|
)}
|
||||||
|
{selectedMedia && !isVideo && (
|
||||||
<CanvasEditor
|
<CanvasEditor
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
media={selectedMedia}
|
media={selectedMedia}
|
||||||
@@ -264,31 +400,178 @@ export default function AnnotationsPage() {
|
|||||||
selectedClassNum={selectedClassNum}
|
selectedClassNum={selectedClassNum}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCursorChange={(x, y) => setCursor({ x, y })}
|
||||||
/>
|
/>
|
||||||
</VideoPlayer>
|
)}
|
||||||
|
{!selectedMedia && (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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 && (
|
{selectedMedia && !isVideo && (
|
||||||
<CanvasEditor
|
<div className="border-t border-border-hair bg-surface-1 shrink-0 px-4 py-2 flex items-center gap-3">
|
||||||
ref={canvasRef}
|
<button onClick={handleSave} disabled={!detections.length} className="btn btn-secondary">{t('annotations.save')}</button>
|
||||||
media={selectedMedia}
|
<button onClick={() => canvasRef.current?.deleteSelected()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.delete')}</button>
|
||||||
annotation={selectedAnnotation}
|
<button onClick={() => canvasRef.current?.deleteAll()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.deleteAll')}</button>
|
||||||
detections={detections}
|
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||||
onDetectionsChange={handleDetectionsChange}
|
<button onClick={handleAiDetect} disabled={!selectedMedia || aiDetecting} className="btn btn-primary">{t('annotations.detect')}</button>
|
||||||
selectedClassNum={selectedClassNum}
|
<span className="ml-auto mono text-[11px] text-text-muted">{detectionsLabel}</span>
|
||||||
currentTime={currentTime}
|
|
||||||
annotations={annotations}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!selectedMedia && (
|
|
||||||
<div className="flex-1 flex items-center justify-center text-az-muted text-sm">
|
|
||||||
Select a media file to start
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel */}
|
{/* RIGHT SIDEBAR */}
|
||||||
<div onMouseDown={rightPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
<div style={{ width: 208 }} className="bg-surface-1 flex flex-col shrink-0 border-l border-border-hair">
|
||||||
<div style={{ width: rightPanel.width }} className="bg-az-panel border-l border-az-border flex flex-col shrink-0">
|
|
||||||
<AnnotationsSidebar
|
<AnnotationsSidebar
|
||||||
media={selectedMedia}
|
media={selectedMedia}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FaDownload } from 'react-icons/fa'
|
import { FaDownload } from 'react-icons/fa'
|
||||||
import { api, createSSE, endpoints } from '../../api'
|
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'
|
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,10 +14,46 @@ interface Props {
|
|||||||
onDownload?: (ann: AnnotationListItem) => void
|
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) {
|
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate, onDownload }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [detecting, setDetecting] = useState(false)
|
|
||||||
const [detectLog, setDetectLog] = useState<string[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!media) return
|
if (!media) return
|
||||||
@@ -30,85 +66,105 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
|||||||
})
|
})
|
||||||
}, [media, onAnnotationsUpdate])
|
}, [media, onAnnotationsUpdate])
|
||||||
|
|
||||||
const handleDetect = async () => {
|
const totals = useMemo(() => ({
|
||||||
if (!media) return
|
total: annotations.length,
|
||||||
setDetecting(true)
|
empty: annotations.filter(a => a.detections.length === 0).length,
|
||||||
setDetectLog(['Starting AI detection...'])
|
}), [annotations])
|
||||||
try {
|
|
||||||
await api.post(endpoints.detect.media(media.id))
|
|
||||||
setDetectLog(prev => [...prev, 'Detection complete.'])
|
|
||||||
} catch (e: any) {
|
|
||||||
setDetectLog(prev => [...prev, `Error: ${e.message}`])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRowGradient = (ann: AnnotationListItem) => {
|
const classDist = useMemo(() => aggregateClasses(annotations), [annotations])
|
||||||
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(', ')})`
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full bg-surface-1">
|
||||||
<div className="p-2 border-b border-az-border flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
|
||||||
<span className="text-xs font-semibold text-az-muted">{t('annotations.title')}</span>
|
<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">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.filter')}>
|
||||||
onClick={handleDetect}
|
<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>
|
||||||
disabled={!media}
|
|
||||||
className="text-xs bg-az-blue text-white px-2 py-0.5 rounded disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('annotations.detect')}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.sort')}>
|
||||||
onClick={() => selectedAnnotation && onDownload?.(selectedAnnotation)}
|
<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>
|
||||||
disabled={!selectedAnnotation}
|
|
||||||
title="Download annotation"
|
|
||||||
className="text-xs bg-az-orange text-white p-1 rounded disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<FaDownload size={12} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="grid grid-cols-[44px_1fr_auto] gap-2 px-3 h-6 items-center border-b border-border-hair">
|
||||||
{annotations.map(ann => (
|
<span className="micro">{t('annotations.colTime')}</span>
|
||||||
<div
|
<span className="micro">{t('annotations.colClass')}</span>
|
||||||
key={ann.id}
|
<span className="micro">{t('annotations.colConf')}</span>
|
||||||
onClick={() => onSelect(ann)}
|
</div>
|
||||||
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' : ''
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
}`}
|
{annotations.map(ann => {
|
||||||
style={{ background: getRowGradient(ann) }}
|
const isSelected = selectedAnnotation?.id === ann.id
|
||||||
>
|
const isEmpty = ann.detections.length === 0
|
||||||
<div className="flex items-center justify-between">
|
const first = ann.detections[0]
|
||||||
<span className="text-az-text font-mono">{ann.time || '—'}</span>
|
const extra = ann.detections.length > 1 ? ` +${ann.detections.length - 1}` : ''
|
||||||
<span className="text-az-muted">{ann.detections.length > 0 ? ann.detections[0].label : '—'}</span>
|
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={`ann-row${isSelected ? ' active' : ''}`}
|
||||||
|
style={{ ['--row-grad' as string]: getRowGradient(ann) }}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
{annotations.length === 0 && (
|
{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>
|
</div>
|
||||||
|
|
||||||
{detecting && (
|
<div className="border-t border-border-hair px-3 py-2.5 bg-surface-0">
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-96 max-h-80 flex flex-col">
|
<span className="micro">{t('annotations.summary')}</span>
|
||||||
<h3 className="text-white font-semibold mb-2">{t('annotations.detect')}</h3>
|
<span className="mono text-[10px] text-text-muted">
|
||||||
<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">
|
{t('annotations.annCount', { count: totals.total })} · {t('annotations.emptyCount', { count: totals.empty })}
|
||||||
{detectLog.map((line, i) => <div key={i}>{line}</div>)}
|
</span>
|
||||||
</div>
|
|
||||||
<button onClick={() => setDetecting(false)} className="self-end text-xs bg-az-border text-az-text px-3 py-1 rounded">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHand
|
|||||||
import { endpoints } from '../../api'
|
import { endpoints } from '../../api'
|
||||||
import { MediaType } from '../../types'
|
import { MediaType } from '../../types'
|
||||||
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } 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 {
|
interface Props {
|
||||||
media: Media
|
media: Media
|
||||||
@@ -12,6 +13,8 @@ interface Props {
|
|||||||
selectedClassNum: number
|
selectedClassNum: number
|
||||||
currentTime: number
|
currentTime: number
|
||||||
annotations: AnnotationListItem[]
|
annotations: AnnotationListItem[]
|
||||||
|
onZoomChange?: (zoom: number) => void
|
||||||
|
onCursorChange?: (nx: number, ny: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanvasEditorHandle {
|
export interface CanvasEditorHandle {
|
||||||
@@ -28,28 +31,60 @@ interface DragState {
|
|||||||
handle?: string
|
handle?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LabelChip {
|
||||||
|
leftPct: number
|
||||||
|
topPct: number
|
||||||
|
color: string
|
||||||
|
name: string
|
||||||
|
conf: number
|
||||||
|
combatReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const HANDLE_SIZE = 6
|
const HANDLE_SIZE = 6
|
||||||
const MIN_BOX_SIZE = 12
|
const MIN_BOX_SIZE = 12
|
||||||
|
|
||||||
const AFFILIATION_COLORS: Record<number, string> = {
|
const HOSTILE_HEXES = new Set(['#FF0000', '#FFFF00', '#FF00FF', '#800000', '#808000', '#800080'])
|
||||||
0: '#FFD700',
|
const FRIENDLY_HEXES = new Set(['#00FF00', '#0000FF', '#00FFFF', '#008000', '#000080', '#008080'])
|
||||||
1: '#228be6',
|
|
||||||
2: '#fa5252',
|
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(
|
const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor(
|
||||||
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations },
|
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations, onZoomChange, onCursorChange },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const imgRef = useRef<HTMLImageElement | null>(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 [zoom, setZoom] = useState(1)
|
||||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||||
const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | 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 [imgSize, setImgSize] = useState({ w: 0, h: 0 })
|
||||||
|
const [labelChips, setLabelChips] = useState<LabelChip[]>([])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
deleteSelected() {
|
deleteSelected() {
|
||||||
@@ -70,7 +105,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
|
|
||||||
const loadImage = useCallback(() => {
|
const loadImage = useCallback(() => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
// Use natural size based on container; no image load
|
|
||||||
imgRef.current = null
|
imgRef.current = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -116,16 +150,45 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
return () => ro.disconnect()
|
return () => ro.disconnect()
|
||||||
}, [isVideo])
|
}, [isVideo])
|
||||||
|
|
||||||
const toCanvas = useCallback((nx: number, ny: number) => ({
|
useEffect(() => { onZoomChange?.(zoom) }, [zoom, onZoomChange])
|
||||||
x: nx * imgSize.w * zoom + pan.x,
|
|
||||||
y: ny * imgSize.h * zoom + pan.y,
|
// Cancel any pending cursor RAF on unmount so the callback can't fire after.
|
||||||
}), [imgSize, zoom, pan])
|
useEffect(() => () => {
|
||||||
|
if (cursorRafRef.current != null) {
|
||||||
|
cancelAnimationFrame(cursorRafRef.current)
|
||||||
|
cursorRafRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fromCanvas = useCallback((cx: number, cy: number) => ({
|
const fromCanvas = useCallback((cx: number, cy: number) => ({
|
||||||
x: Math.max(0, Math.min(1, (cx - pan.x) / (imgSize.w * zoom))),
|
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))),
|
y: Math.max(0, Math.min(1, (cy - pan.y) / (imgSize.h * zoom))),
|
||||||
}), [imgSize, zoom, pan])
|
}), [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 draw = useCallback(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
const ctx = canvas?.getContext('2d')
|
const ctx = canvas?.getContext('2d')
|
||||||
@@ -146,9 +209,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
|
|
||||||
const timeWindowDets = getTimeWindowDetections()
|
const timeWindowDets = getTimeWindowDetections()
|
||||||
const allDets = [...detections, ...timeWindowDets]
|
const allDets = [...detections, ...timeWindowDets]
|
||||||
|
const chips: LabelChip[] = []
|
||||||
|
|
||||||
allDets.forEach((det, i) => {
|
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 cx = (det.centerX - det.width / 2) * imgSize.w * zoom + pan.x
|
||||||
const cy = (det.centerY - det.height / 2) * imgSize.h * zoom + pan.y
|
const cy = (det.centerY - det.height / 2) * imgSize.h * zoom + pan.y
|
||||||
const w = det.width * imgSize.w * zoom
|
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.strokeRect(cx, cy, w, h)
|
||||||
|
|
||||||
ctx.fillStyle = color
|
ctx.fillStyle = color
|
||||||
ctx.globalAlpha = 0.1
|
ctx.globalAlpha = 0.06
|
||||||
ctx.fillRect(cx, cy, w, h)
|
ctx.fillRect(cx, cy, w, h)
|
||||||
ctx.globalAlpha = 1
|
ctx.globalAlpha = 1
|
||||||
|
|
||||||
const name = det.label || getClassNameFallback(det.classNum)
|
// Corner brackets — 8px legs (skipped in environments lacking path API, e.g. JSDOM)
|
||||||
const modeSuffix = getPhotoModeSuffix(det.classNum)
|
if (typeof ctx.moveTo === 'function' && typeof ctx.beginPath === 'function') {
|
||||||
const confSuffix = det.confidence < 0.995 ? ` ${(det.confidence * 100).toFixed(0)}%` : ''
|
const legLen = 8
|
||||||
const label = `${name}${modeSuffix}${confSuffix}`
|
ctx.lineWidth = 2
|
||||||
|
|
||||||
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'
|
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(cx + w - 6, cy + 6, 3, 0, Math.PI * 2)
|
ctx.moveTo(cx, cy + legLen); ctx.lineTo(cx, cy); ctx.lineTo(cx + legLen, cy)
|
||||||
ctx.fill()
|
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) {
|
if (isSelected) {
|
||||||
const handles = getHandles(cx, cy, w, h)
|
const handles = getHandles(cx, cy, w, h)
|
||||||
handles.forEach(hp => {
|
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.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)
|
ctx.strokeRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (drawRect) {
|
if (drawRect) {
|
||||||
ctx.strokeStyle = '#fd7e14'
|
ctx.strokeStyle = '#FF9D3D'
|
||||||
ctx.lineWidth = 1
|
ctx.lineWidth = 1
|
||||||
ctx.setLineDash([4, 4])
|
ctx.setLineDash([4, 4])
|
||||||
ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h)
|
ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h)
|
||||||
@@ -206,7 +277,23 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore()
|
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(() => {
|
useEffect(() => {
|
||||||
const id = requestAnimationFrame(draw)
|
const id = requestAnimationFrame(draw)
|
||||||
@@ -221,31 +308,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
return () => obs.disconnect()
|
return () => obs.disconnect()
|
||||||
}, [draw])
|
}, [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) => {
|
const hitTest = (cx: number, cy: number) => {
|
||||||
for (let i = detections.length - 1; i >= 0; i--) {
|
for (let i = detections.length - 1; i >= 0; i--) {
|
||||||
const d = detections[i]
|
const d = detections[i]
|
||||||
@@ -298,12 +360,28 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
if (!dragState) return
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect()
|
const rect = canvasRef.current?.getBoundingClientRect()
|
||||||
if (!rect) return
|
if (!rect) return
|
||||||
const mx = e.clientX - rect.left
|
const mx = e.clientX - rect.left
|
||||||
const my = e.clientY - rect.top
|
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') {
|
if (dragState.type === 'draw') {
|
||||||
setDrawRect({
|
setDrawRect({
|
||||||
x: Math.min(dragState.startX, mx),
|
x: Math.min(dragState.startX, mx),
|
||||||
@@ -415,6 +493,25 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
onWheel={handleWheel}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
const debouncedFilter = useDebounce(filter, 300)
|
const debouncedFilter = useDebounce(filter, 300)
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const fetchMedia = useCallback(async () => {
|
const fetchMedia = useCallback(async () => {
|
||||||
const params = new URLSearchParams({ pageSize: '1000' })
|
const params = new URLSearchParams({ pageSize: '1000' })
|
||||||
@@ -139,70 +140,126 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filtered = media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...getRootProps({
|
{...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()} />
|
<input {...getInputProps()} />
|
||||||
<div className="p-2 border-b border-az-border flex gap-1">
|
|
||||||
<input
|
{/* Hidden file inputs */}
|
||||||
value={filter}
|
<input
|
||||||
onChange={e => setFilter(e.target.value)}
|
ref={fileInputRef}
|
||||||
placeholder={t('annotations.mediaList')}
|
type="file"
|
||||||
className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
|
multiple
|
||||||
/>
|
className="hidden"
|
||||||
</div>
|
onChange={e => {
|
||||||
<div className="px-2 pt-2 pb-2 flex gap-1">
|
if (e.target.files?.length) uploadFiles(e.target.files)
|
||||||
<label className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded text-center cursor-pointer hover:brightness-110">
|
e.target.value = ''
|
||||||
Open File
|
}}
|
||||||
<input
|
/>
|
||||||
type="file"
|
<input
|
||||||
multiple
|
ref={folderInputRef}
|
||||||
className="hidden"
|
type="file"
|
||||||
onChange={e => {
|
multiple
|
||||||
if (e.target.files?.length) uploadFiles(e.target.files)
|
className="hidden"
|
||||||
e.target.value = ''
|
// @ts-expect-error webkitdirectory is non-standard but widely supported
|
||||||
}}
|
webkitdirectory=""
|
||||||
/>
|
directory=""
|
||||||
</label>
|
onChange={handleFolderInput}
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
onClick={() => folderInputRef.current?.click()}
|
{/* Header row */}
|
||||||
className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded hover:brightness-110"
|
<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">
|
||||||
Open Folder
|
<span className="sect-head">{t('annotations.mediaList')}</span>
|
||||||
</button>
|
<span className="mono text-[10px] text-text-muted">{filtered.length}</span>
|
||||||
<input
|
</div>
|
||||||
ref={folderInputRef}
|
<div className="flex items-center gap-1">
|
||||||
type="file"
|
{/* Upload file button */}
|
||||||
multiple
|
<button
|
||||||
className="hidden"
|
type="button"
|
||||||
// @ts-expect-error webkitdirectory is non-standard but widely supported
|
className="ibtn"
|
||||||
webkitdirectory=""
|
style={{ width: 22, height: 22 }}
|
||||||
directory=""
|
title={t('annotations.upload')}
|
||||||
onChange={handleFolderInput}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase())).map(m => (
|
|
||||||
<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`}
|
|
||||||
>
|
>
|
||||||
<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'}`}>
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
{m.mediaType === MediaType.Video ? 'V' : 'P'}
|
<path d="M12 5v14M5 12h14"/>
|
||||||
</span>
|
</svg>
|
||||||
<span className="truncate flex-1">{m.name}</span>
|
</button>
|
||||||
{m.duration && <span className="text-az-muted">{m.duration}</span>}
|
{/* Open folder button */}
|
||||||
</div>
|
<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>
|
</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={`media-row${isActive ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteId}
|
open={!!deleteId}
|
||||||
title={t('annotations.deleteMedia')}
|
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 { 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 { endpoints } from '../../api'
|
||||||
import type { Media } from '../../types'
|
import type { Media } from '../../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
media: Media
|
media: Media
|
||||||
onTimeUpdate: (time: number) => void
|
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
|
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 {
|
export interface VideoPlayerHandle {
|
||||||
seek: (seconds: number) => void
|
seek: (seconds: number) => void
|
||||||
getVideoElement: () => HTMLVideoElement | null
|
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 FPS = 30
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||||
seek(seconds: number) {
|
media, onTimeUpdate, onPlayingChange, onDurationChange, onMutedChange, children,
|
||||||
if (videoRef.current) {
|
}, ref) {
|
||||||
videoRef.current.currentTime = seconds
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
setCurrentTime(seconds)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getVideoElement() {
|
|
||||||
return videoRef.current
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
const [error, setError] = useState<string | null>(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 [muted, setMuted] = useState(false)
|
||||||
|
|
||||||
|
const notifyMuted = useCallback((m: boolean) => {
|
||||||
|
setMuted(m)
|
||||||
|
onMutedChange?.(m)
|
||||||
|
}, [onMutedChange])
|
||||||
|
|
||||||
const videoUrl = media.path.startsWith('blob:')
|
const videoUrl = media.path.startsWith('blob:')
|
||||||
? media.path
|
? media.path
|
||||||
: endpoints.annotations.mediaFile(media.id)
|
: endpoints.annotations.mediaFile(media.id)
|
||||||
@@ -44,24 +54,47 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
|||||||
const stepFrames = useCallback((count: number) => {
|
const stepFrames = useCallback((count: number) => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
const fps = 30
|
video.currentTime = Math.max(0, Math.min(video.duration || 0, video.currentTime + count / FPS))
|
||||||
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + count / fps))
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const togglePlay = useCallback(() => {
|
const togglePlay = useCallback(() => {
|
||||||
const v = videoRef.current
|
const v = videoRef.current
|
||||||
if (!v) return
|
if (!v) return
|
||||||
if (v.paused) { v.play(); setPlaying(true) }
|
if (v.paused) v.play().catch(() => {})
|
||||||
else { v.pause(); setPlaying(false) }
|
else v.pause()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
useImperativeHandle(ref, () => ({
|
||||||
const v = videoRef.current
|
seek(seconds: number) {
|
||||||
if (!v) return
|
const v = videoRef.current
|
||||||
v.pause()
|
if (v) v.currentTime = seconds
|
||||||
v.currentTime = 0
|
},
|
||||||
setPlaying(false)
|
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(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
@@ -70,22 +103,22 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
|||||||
case ' ': e.preventDefault(); togglePlay(); break
|
case ' ': e.preventDefault(); togglePlay(); break
|
||||||
case 'ArrowLeft': e.preventDefault(); stepFrames(e.ctrlKey ? -150 : -1); break
|
case 'ArrowLeft': e.preventDefault(); stepFrames(e.ctrlKey ? -150 : -1); break
|
||||||
case 'ArrowRight': 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)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
}, [togglePlay, stepFrames])
|
}, [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 (
|
return (
|
||||||
<div className="bg-black flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0 bg-surface-0">
|
||||||
{error && <div className="bg-az-red/80 text-white text-xs px-2 py-1">{error}</div>}
|
{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">
|
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -94,76 +127,18 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
|||||||
controls={false}
|
controls={false}
|
||||||
playsInline
|
playsInline
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-full object-contain"
|
||||||
onTimeUpdate={e => {
|
onTimeUpdate={e => onTimeUpdate((e.target as HTMLVideoElement).currentTime)}
|
||||||
const t = (e.target as HTMLVideoElement).currentTime
|
onPlay={() => onPlayingChange?.(true)}
|
||||||
setCurrentTime(t)
|
onPause={() => onPlayingChange?.(false)}
|
||||||
onTimeUpdate(t)
|
onDurationChange={e => {
|
||||||
}}
|
const d = (e.target as HTMLVideoElement).duration
|
||||||
onLoadedMetadata={e => {
|
if (Number.isFinite(d)) onDurationChange?.(d)
|
||||||
setDuration((e.target as HTMLVideoElement).duration)
|
|
||||||
setError(null)
|
|
||||||
}}
|
}}
|
||||||
|
onLoadedMetadata={() => setError(null)}
|
||||||
onError={() => setError(`Failed to load video (${media.name})`)}
|
onError={() => setError(`Failed to load video (${media.name})`)}
|
||||||
/>
|
/>
|
||||||
{children && <div className="absolute inset-0">{children}</div>}
|
{children && <div className="absolute inset-0">{children}</div>}
|
||||||
</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>
|
</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')}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { api, endpoints } from '../../api'
|
||||||
|
import { getClassColor, FALLBACK_CLASS_NAMES } from '../../class-colors'
|
||||||
|
import type { DetectionClass } from '../../types'
|
||||||
|
|
||||||
|
const FALLBACK_CLASSES: DetectionClass[] = FALLBACK_CLASS_NAMES.map((name, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name,
|
||||||
|
shortName: name.slice(0, 3),
|
||||||
|
color: getClassColor(i),
|
||||||
|
maxSizeM: 10,
|
||||||
|
photoMode: 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
interface DatasetClassListProps {
|
||||||
|
selectedClassNum: number
|
||||||
|
onSelect: (classNum: number) => void
|
||||||
|
counts: Record<number, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatasetClassList({ selectedClassNum, onSelect, counts }: DatasetClassListProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||||
|
.then(list => setClasses(list?.length ? list : FALLBACK_CLASSES))
|
||||||
|
.catch(() => setClasses(FALLBACK_CLASSES))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const regularClasses = useMemo(() => classes.filter(c => c.photoMode === 0), [classes])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
const t = e.target as HTMLElement | null
|
||||||
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
|
||||||
|
const num = parseInt(e.key)
|
||||||
|
if (num >= 1 && num <= 9) {
|
||||||
|
const cls = regularClasses[num - 1]
|
||||||
|
if (cls) onSelect(cls.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [regularClasses, onSelect])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="px-3 pt-3 pb-2 flex items-center justify-between border-b border-border-hair shrink-0">
|
||||||
|
<span className="sect-head" style={{ lineHeight: 1.2 }}>{t('annotations.classes')}</span>
|
||||||
|
<span className="mono text-[10px] text-text-muted tabular-nums">
|
||||||
|
{regularClasses.length.toString().padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="px-2 py-2 flex flex-col gap-0.5 overflow-y-auto"
|
||||||
|
style={{ maxHeight: '46vh' }}
|
||||||
|
>
|
||||||
|
{regularClasses.map(c => {
|
||||||
|
const isActive = c.id === selectedClassNum
|
||||||
|
const count = counts[c.id] ?? 0
|
||||||
|
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={`flex items-center gap-2.5 h-7 px-2 rounded-[2px] cursor-pointer transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-surface-2 text-text-primary'
|
||||||
|
: 'text-text-secondary hover:bg-surface-2 hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="swatch shrink-0" style={{ background: c.color }} />
|
||||||
|
<span className="text-[12px] truncate flex-1">{c.name}</span>
|
||||||
|
<span
|
||||||
|
className={`font-mono font-medium text-[10px] tabular-nums leading-none rounded-[2px] border bg-surface-input ${
|
||||||
|
isActive
|
||||||
|
? 'text-accent-amber border-accent-amber'
|
||||||
|
: 'text-text-secondary border-border-hair'
|
||||||
|
}`}
|
||||||
|
style={{ padding: '2px 6px' }}
|
||||||
|
>
|
||||||
|
{count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { AnnotationStatus } from '../../types'
|
||||||
|
|
||||||
|
interface DatasetFilterBarProps {
|
||||||
|
fromDate: string
|
||||||
|
toDate: string
|
||||||
|
onFromDateChange: (v: string) => void
|
||||||
|
onToDateChange: (v: string) => void
|
||||||
|
statusFilter: AnnotationStatus | null
|
||||||
|
onStatusFilterChange: (s: AnnotationStatus | null) => void
|
||||||
|
flightName: string | null
|
||||||
|
shownCount: number
|
||||||
|
totalCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatasetFilterBar({
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
onFromDateChange,
|
||||||
|
onToDateChange,
|
||||||
|
statusFilter,
|
||||||
|
onStatusFilterChange,
|
||||||
|
flightName,
|
||||||
|
shownCount,
|
||||||
|
totalCount,
|
||||||
|
}: DatasetFilterBarProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{
|
||||||
|
value: null,
|
||||||
|
label: t('dataset.status.all'),
|
||||||
|
tone: 'muted' as const,
|
||||||
|
dot: 'var(--text-muted)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: AnnotationStatus.Created,
|
||||||
|
label: t('dataset.status.created'),
|
||||||
|
tone: 'amber' as const,
|
||||||
|
dot: 'var(--accent-amber)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: AnnotationStatus.Edited,
|
||||||
|
label: t('dataset.status.edited'),
|
||||||
|
tone: 'blue' as const,
|
||||||
|
dot: 'var(--accent-blue)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: AnnotationStatus.Validated,
|
||||||
|
label: t('dataset.status.validated'),
|
||||||
|
tone: 'green' as const,
|
||||||
|
dot: 'var(--accent-green)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bracket panel relative flex items-center gap-3 px-3 shrink-0"
|
||||||
|
style={{ height: 48 }}
|
||||||
|
>
|
||||||
|
<span className="br" />
|
||||||
|
|
||||||
|
{/* Range group */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="micro">{t('dataset.range')}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="inp inp-mono cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:inset-0 [&::-webkit-calendar-picker-indicator]:w-full [&::-webkit-calendar-picker-indicator]:h-full [&::-webkit-calendar-picker-indicator]:cursor-pointer"
|
||||||
|
style={{ width: 104, height: 28, padding: '0 10px' }}
|
||||||
|
value={fromDate}
|
||||||
|
onChange={e => onFromDateChange(e.target.value)}
|
||||||
|
onClick={e => e.currentTarget.showPicker?.()}
|
||||||
|
/>
|
||||||
|
<span className="mono text-text-muted">—</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="inp inp-mono cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:inset-0 [&::-webkit-calendar-picker-indicator]:w-full [&::-webkit-calendar-picker-indicator]:h-full [&::-webkit-calendar-picker-indicator]:cursor-pointer"
|
||||||
|
style={{ width: 104, height: 28, padding: '0 10px' }}
|
||||||
|
value={toDate}
|
||||||
|
onChange={e => onToDateChange(e.target.value)}
|
||||||
|
onClick={e => e.currentTarget.showPicker?.()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* divider */}
|
||||||
|
<span className="w-px h-5 bg-border-hair shrink-0" />
|
||||||
|
|
||||||
|
{/* Flight group — display-only chip */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="micro">{t('dataset.flight')}</span>
|
||||||
|
<div
|
||||||
|
className="inp inline-flex items-center gap-2"
|
||||||
|
style={{ padding: '0 10px', height: 28, cursor: 'default' }}
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-accent-amber" />
|
||||||
|
<span className="mono text-[12px] text-text-primary tracking-wider">
|
||||||
|
{flightName ?? '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-muted ml-1">▾</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* divider */}
|
||||||
|
<span className="w-px h-5 bg-border-hair shrink-0" />
|
||||||
|
|
||||||
|
{/* Status chips */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="micro mr-1">{t('dataset.statusLabel')}</span>
|
||||||
|
{STATUS_OPTIONS.map(opt => {
|
||||||
|
const isActive = statusFilter === opt.value
|
||||||
|
const stateCls = !isActive
|
||||||
|
? 'text-text-secondary border-border-hair hover:text-text-primary hover:border-border-raised'
|
||||||
|
: opt.tone === 'muted'
|
||||||
|
? 'text-text-primary border-border-raised bg-text-muted/20'
|
||||||
|
: opt.tone === 'amber'
|
||||||
|
? 'text-accent-amber border-accent-amber bg-accent-amber/10'
|
||||||
|
: opt.tone === 'blue'
|
||||||
|
? 'text-accent-blue border-accent-blue bg-accent-blue/10'
|
||||||
|
: /* green */ 'text-accent-green border-accent-green bg-accent-green/10'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={String(opt.value)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStatusFilterChange(opt.value)}
|
||||||
|
className={`inline-flex items-center gap-1.5 h-6 px-2.5 rounded-[2px] border font-mono text-[10px] font-semibold uppercase tracking-widest cursor-pointer transition-colors ${stateCls}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="rounded-full shrink-0"
|
||||||
|
style={{ width: 6, height: 6, background: opt.dot }}
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* right side */}
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<span className="micro" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('dataset.showing')}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-[12px] text-text-primary tabular-nums">
|
||||||
|
{shownCount.toLocaleString()}
|
||||||
|
<span className="text-text-muted"> / {totalCount.toLocaleString()}</span>
|
||||||
|
</span>
|
||||||
|
<span className="w-px h-5 bg-border-hair shrink-0" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t('dataset.sort')}
|
||||||
|
className="w-7 h-7 inline-flex items-center justify-center border border-border-hair rounded-[2px] text-text-secondary hover:text-text-primary hover:border-border-raised transition-colors"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18M6 12h12M10 18h4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t('dataset.gridDensity')}
|
||||||
|
className="w-7 h-7 inline-flex items-center justify-center border border-border-hair rounded-[2px] text-text-secondary hover:text-text-primary hover:border-border-raised transition-colors"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import DatasetClassList from './DatasetClassList'
|
||||||
|
|
||||||
|
interface DatasetLeftPanelProps {
|
||||||
|
selectedClassNum: number
|
||||||
|
onSelectClass: (n: number) => void
|
||||||
|
classCounts: Record<number, number>
|
||||||
|
objectsOnly: boolean
|
||||||
|
onObjectsOnlyChange: (v: boolean) => void
|
||||||
|
search: string
|
||||||
|
onSearchChange: (v: string) => void
|
||||||
|
totalCount: number
|
||||||
|
validatedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatasetLeftPanel({
|
||||||
|
selectedClassNum,
|
||||||
|
onSelectClass,
|
||||||
|
classCounts,
|
||||||
|
objectsOnly,
|
||||||
|
onObjectsOnlyChange,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
totalCount,
|
||||||
|
validatedCount,
|
||||||
|
}: DatasetLeftPanelProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="bracket panel flex flex-col shrink-0" style={{ width: 250 }}>
|
||||||
|
<span className="br" />
|
||||||
|
|
||||||
|
<DatasetClassList
|
||||||
|
selectedClassNum={selectedClassNum}
|
||||||
|
onSelect={onSelectClass}
|
||||||
|
counts={classCounts}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-auto border-t border-border-hair px-3 py-3 flex flex-col gap-3">
|
||||||
|
<span className="micro">{t('dataset.filters')}</span>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[12px] text-text-primary">{t('dataset.objectsOnly')}</span>
|
||||||
|
<span className="text-[10px] text-text-muted">{t('dataset.hideEmpty')}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={objectsOnly}
|
||||||
|
onClick={() => onObjectsOnlyChange(!objectsOnly)}
|
||||||
|
className={`relative shrink-0 rounded-[2px] border transition-colors ${
|
||||||
|
objectsOnly
|
||||||
|
? 'border-accent-amber bg-accent-amber/20'
|
||||||
|
: 'border-border-hair bg-surface-0'
|
||||||
|
}`}
|
||||||
|
style={{ width: 30, height: 16 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-px left-px block rounded-[2px] transition-transform ${
|
||||||
|
objectsOnly ? 'bg-accent-amber' : 'bg-text-muted'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
transform: objectsOnly ? 'translateX(14px)' : 'translateX(0)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="inp w-full"
|
||||||
|
style={{ height: 28, padding: '0 10px 0 28px' }}
|
||||||
|
placeholder={t('dataset.search')}
|
||||||
|
value={search}
|
||||||
|
onChange={e => onSearchChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||||
|
<div className="border border-border-hair rounded-[2px] p-2">
|
||||||
|
<div className="micro" style={{ color: 'var(--text-muted)' }}>{t('dataset.total')}</div>
|
||||||
|
<div className="mono text-[15px] text-text-primary">{totalCount.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="border border-border-hair rounded-[2px] p-2">
|
||||||
|
<div className="micro" style={{ color: 'var(--text-muted)' }}>{t('dataset.validatedCount')}</div>
|
||||||
|
<div className="mono text-[15px] text-accent-green">{validatedCount.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,30 +1,25 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FaPen } from 'react-icons/fa'
|
|
||||||
import { api, endpoints } from '../../api'
|
import { api, endpoints } from '../../api'
|
||||||
import { useDebounce, useResizablePanel } from '../../hooks'
|
import { useDebounce } from '../../hooks'
|
||||||
import { useFlight, DetectionClasses } from '../../components'
|
import { useFlight } from '../../components'
|
||||||
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||||
import CanvasEditor from '../annotations/CanvasEditor'
|
import CanvasEditor from '../annotations/CanvasEditor'
|
||||||
import { recaptureThumbnails } from '../annotations/thumbnail'
|
import { recaptureThumbnails } from '../annotations/thumbnail'
|
||||||
import type { SavedDetection } from '../../components/SavedAnnotationsContext'
|
import type { SavedDetection } from '../../components/SavedAnnotationsContext'
|
||||||
import type { DatasetItem, PaginatedResponse, ClassDistributionItem, AnnotationListItem, Detection, Media } from '../../types'
|
import type {
|
||||||
|
DatasetItem,
|
||||||
|
PaginatedResponse,
|
||||||
|
ClassDistributionItem,
|
||||||
|
AnnotationListItem,
|
||||||
|
Detection,
|
||||||
|
Media,
|
||||||
|
} from '../../types'
|
||||||
import { AnnotationSource, AnnotationStatus } from '../../types'
|
import { AnnotationSource, AnnotationStatus } from '../../types'
|
||||||
|
import DatasetLeftPanel from './DatasetLeftPanel'
|
||||||
interface DatasetCard {
|
import DatasetFilterBar from './DatasetFilterBar'
|
||||||
annotationId: string
|
import DatasetTile, { type DatasetCard } from './DatasetTile'
|
||||||
imageName: string
|
import DatasetStatusBar from './DatasetStatusBar'
|
||||||
status: AnnotationStatus
|
|
||||||
createdDate: string
|
|
||||||
thumbnailUrl: string
|
|
||||||
isSeed: boolean
|
|
||||||
isLocal: boolean
|
|
||||||
detections?: Detection[]
|
|
||||||
mediaId?: string
|
|
||||||
time?: string | null
|
|
||||||
fullFrame?: string
|
|
||||||
annotationLocalId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Tab = 'annotations' | 'editor' | 'distribution'
|
type Tab = 'annotations' | 'editor' | 'distribution'
|
||||||
|
|
||||||
@@ -32,7 +27,6 @@ export default function DatasetPage() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { selectedFlight } = useFlight()
|
const { selectedFlight } = useFlight()
|
||||||
const { saved: savedAnnotations, removeSaved, replaceGroup, updateStatus } = useSavedAnnotations()
|
const { saved: savedAnnotations, removeSaved, replaceGroup, updateStatus } = useSavedAnnotations()
|
||||||
const leftPanel = useResizablePanel(250, 200, 400)
|
|
||||||
|
|
||||||
const [items, setItems] = useState<DatasetItem[]>([])
|
const [items, setItems] = useState<DatasetItem[]>([])
|
||||||
const [totalCount, setTotalCount] = useState(0)
|
const [totalCount, setTotalCount] = useState(0)
|
||||||
@@ -45,12 +39,14 @@ export default function DatasetPage() {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const debouncedSearch = useDebounce(search, 400)
|
const debouncedSearch = useDebounce(search, 400)
|
||||||
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
||||||
const [photoMode, setPhotoMode] = useState(0)
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [tab, setTab] = useState<Tab>('annotations')
|
const [tab, setTab] = useState<Tab>('annotations')
|
||||||
const [editorAnnotation, setEditorAnnotation] = useState<AnnotationListItem | null>(null)
|
const [editorAnnotation, setEditorAnnotation] = useState<AnnotationListItem | null>(null)
|
||||||
const [editorDetections, setEditorDetections] = useState<Detection[]>([])
|
const [editorDetections, setEditorDetections] = useState<Detection[]>([])
|
||||||
const [distribution, setDistribution] = useState<ClassDistributionItem[]>([])
|
const [distribution, setDistribution] = useState<ClassDistributionItem[]>([])
|
||||||
|
const [editorFullFrame, setEditorFullFrame] = useState<string>('')
|
||||||
|
const [editorLocalGroupId, setEditorLocalGroupId] = useState<string | null>(null)
|
||||||
|
const [editorSaving, setEditorSaving] = useState(false)
|
||||||
|
|
||||||
const fetchItems = useCallback(async () => {
|
const fetchItems = useCallback(async () => {
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||||
@@ -107,11 +103,7 @@ export default function DatasetPage() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return [...localCards, ...remoteCards]
|
return [...localCards, ...remoteCards]
|
||||||
}, [savedAnnotations, items, selectedFlight, statusFilter, objectsOnly, selectedClassNum, debouncedSearch, fromDate, toDate])
|
}, [savedAnnotations, items, selectedFlight, statusFilter, selectedClassNum, debouncedSearch, fromDate, toDate])
|
||||||
|
|
||||||
const [editorFullFrame, setEditorFullFrame] = useState<string>('')
|
|
||||||
const [editorLocalGroupId, setEditorLocalGroupId] = useState<string | null>(null)
|
|
||||||
const [editorSaving, setEditorSaving] = useState(false)
|
|
||||||
|
|
||||||
const handleDoubleClick = async (card: DatasetCard) => {
|
const handleDoubleClick = async (card: DatasetCard) => {
|
||||||
if (card.isLocal && card.detections && card.mediaId) {
|
if (card.isLocal && card.detections && card.mediaId) {
|
||||||
@@ -151,7 +143,7 @@ export default function DatasetPage() {
|
|||||||
const existing = savedAnnotations.find(s => s.annotationLocalId === editorLocalGroupId)
|
const existing = savedAnnotations.find(s => s.annotationLocalId === editorLocalGroupId)
|
||||||
const thumbs = await recaptureThumbnails(editorFullFrame, editorDetections)
|
const thumbs = await recaptureThumbnails(editorFullFrame, editorDetections)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const items: SavedDetection[] = editorDetections.map((d, i) => ({
|
const replacement: SavedDetection[] = editorDetections.map((d, i) => ({
|
||||||
id: `${editorLocalGroupId}:${d.id ?? i}`,
|
id: `${editorLocalGroupId}:${d.id ?? i}`,
|
||||||
annotationLocalId: editorLocalGroupId,
|
annotationLocalId: editorLocalGroupId,
|
||||||
mediaId: editorAnnotation.mediaId,
|
mediaId: editorAnnotation.mediaId,
|
||||||
@@ -165,7 +157,7 @@ export default function DatasetPage() {
|
|||||||
time: editorAnnotation.time,
|
time: editorAnnotation.time,
|
||||||
flightId: existing?.flightId ?? null,
|
flightId: existing?.flightId ?? null,
|
||||||
}))
|
}))
|
||||||
replaceGroup(editorLocalGroupId, items)
|
replaceGroup(editorLocalGroupId, replacement)
|
||||||
}
|
}
|
||||||
setTab('annotations')
|
setTab('annotations')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -196,114 +188,147 @@ export default function DatasetPage() {
|
|||||||
updateStatus(localIds, AnnotationStatus.Validated)
|
updateStatus(localIds, AnnotationStatus.Validated)
|
||||||
}
|
}
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
|
setPage(1)
|
||||||
fetchItems()
|
fetchItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDistribution = useCallback(async () => {
|
useEffect(() => {
|
||||||
try {
|
api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
|
||||||
const data = await api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
|
.then(setDistribution)
|
||||||
setDistribution(data)
|
.catch(() => {})
|
||||||
} catch {}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { if (tab === 'distribution') loadDistribution() }, [tab, loadDistribution])
|
const classCounts = useMemo(() => {
|
||||||
|
const m: Record<number, number> = {}
|
||||||
|
for (const d of distribution) m[d.classNum] = d.count
|
||||||
|
return m
|
||||||
|
}, [distribution])
|
||||||
|
|
||||||
const maxDistCount = Math.max(...distribution.map(d => d.count), 1)
|
const maxDistCount = useMemo(
|
||||||
|
() => Math.max(...distribution.map(d => d.count), 1),
|
||||||
|
[distribution],
|
||||||
|
)
|
||||||
const totalPages = Math.ceil(totalCount / pageSize)
|
const totalPages = Math.ceil(totalCount / pageSize)
|
||||||
|
const relevantSavedCount = useMemo(() => {
|
||||||
|
if (!selectedFlight) return savedAnnotations.length
|
||||||
|
return savedAnnotations.filter(sd => !sd.flightId || sd.flightId === selectedFlight.id).length
|
||||||
|
}, [savedAnnotations, selectedFlight])
|
||||||
|
const grandTotal = totalCount + relevantSavedCount
|
||||||
|
const validatedCount = useMemo(
|
||||||
|
() => cards.filter(c => c.status === AnnotationStatus.Validated).length,
|
||||||
|
[cards],
|
||||||
|
)
|
||||||
|
|
||||||
|
const firstSelectedName = useMemo(() => {
|
||||||
|
const firstId = selectedIds.values().next().value
|
||||||
|
if (!firstId) return null
|
||||||
|
return cards.find(c => c.annotationId === firstId)?.imageName ?? null
|
||||||
|
}, [selectedIds, cards])
|
||||||
|
|
||||||
const editorMedia: Media | null = editorAnnotation ? {
|
const editorMedia: Media | null = editorAnnotation ? {
|
||||||
id: editorAnnotation.mediaId, name: '', path: editorFullFrame, mediaType: 1, mediaStatus: 0,
|
id: editorAnnotation.mediaId, name: '', path: editorFullFrame, mediaType: 1, mediaStatus: 0,
|
||||||
duration: null, annotationCount: 0, waypointId: null, userId: '',
|
duration: null, annotationCount: 0, waypointId: null, userId: '',
|
||||||
} : null
|
} : null
|
||||||
|
|
||||||
const statusButtons = [
|
|
||||||
{ label: 'All', value: null },
|
|
||||||
{ label: t('dataset.status.created'), value: AnnotationStatus.Created },
|
|
||||||
{ label: t('dataset.status.edited'), value: AnnotationStatus.Edited },
|
|
||||||
{ label: t('dataset.status.validated'), value: AnnotationStatus.Validated },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex-1 flex overflow-hidden p-3 gap-3 h-full">
|
||||||
{/* Left panel */}
|
<DatasetLeftPanel
|
||||||
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
selectedClassNum={selectedClassNum}
|
||||||
<DetectionClasses
|
onSelectClass={setSelectedClassNum}
|
||||||
selectedClassNum={selectedClassNum}
|
classCounts={classCounts}
|
||||||
onSelect={setSelectedClassNum}
|
objectsOnly={objectsOnly}
|
||||||
photoMode={photoMode}
|
onObjectsOnlyChange={setObjectsOnly}
|
||||||
onPhotoModeChange={setPhotoMode}
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
totalCount={grandTotal}
|
||||||
|
validatedCount={validatedCount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="flex-1 min-w-0 flex flex-col gap-3">
|
||||||
|
<DatasetFilterBar
|
||||||
|
fromDate={fromDate}
|
||||||
|
toDate={toDate}
|
||||||
|
onFromDateChange={setFromDate}
|
||||||
|
onToDateChange={setToDate}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onStatusFilterChange={s => { setStatusFilter(s); setPage(1) }}
|
||||||
|
flightName={selectedFlight?.name ?? null}
|
||||||
|
shownCount={cards.length}
|
||||||
|
totalCount={grandTotal}
|
||||||
/>
|
/>
|
||||||
<div className="p-2 border-t border-az-border">
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-az-text cursor-pointer">
|
|
||||||
<input type="checkbox" checked={objectsOnly} onChange={e => setObjectsOnly(e.target.checked)} className="accent-az-orange" />
|
|
||||||
{t('dataset.objectsOnly')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="p-2 border-t border-az-border">
|
|
||||||
<input
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
placeholder={t('dataset.search')}
|
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
|
||||||
|
|
||||||
{/* Main area */}
|
<div className="bracket panel relative flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
<span className="br" />
|
||||||
{/* Filter bar */}
|
|
||||||
<div className="flex items-center gap-2 p-2 border-b border-az-border bg-az-panel text-xs flex-wrap">
|
{/* Tab strip */}
|
||||||
<input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} className="bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" />
|
<div className="flex items-center px-2 border-b border-border-hair shrink-0">
|
||||||
<input type="date" value={toDate} onChange={e => setToDate(e.target.value)} className="bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" />
|
|
||||||
{statusButtons.map(sb => (
|
|
||||||
<button
|
<button
|
||||||
key={String(sb.value)}
|
type="button"
|
||||||
onClick={() => { setStatusFilter(sb.value); setPage(1) }}
|
onClick={() => setTab('annotations')}
|
||||||
className={`px-2 py-0.5 rounded ${statusFilter === sb.value ? 'bg-az-orange text-white' : 'bg-az-bg text-az-muted'}`}
|
className={`tab ${tab === 'annotations' ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
{sb.label}
|
<span>{t('dataset.annotations')}</span>
|
||||||
|
<span
|
||||||
|
className={`ml-1.5 px-1.5 py-px text-[10px] font-mono border rounded-[2px] tabular-nums ${
|
||||||
|
tab === 'annotations'
|
||||||
|
? 'text-accent-amber border-accent-amber'
|
||||||
|
: 'text-text-muted border-border-hair'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cards.length}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
<div className="flex-1" />
|
|
||||||
{selectedIds.size > 0 && (
|
|
||||||
<button onClick={handleValidate} className="bg-az-green text-white px-2 py-0.5 rounded">
|
|
||||||
{t('dataset.validate')} ({selectedIds.size})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex border-b border-az-border bg-az-panel">
|
|
||||||
{(['annotations', 'editor', 'distribution'] as Tab[]).map(tb => (
|
|
||||||
<button
|
<button
|
||||||
key={tb}
|
type="button"
|
||||||
onClick={() => setTab(tb)}
|
onClick={() => setTab('editor')}
|
||||||
className={`px-3 py-1.5 text-xs ${tab === tb ? 'bg-az-bg text-white border-b-2 border-az-orange' : 'text-az-muted'}`}
|
className={`tab ${tab === 'editor' ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
{t(`dataset.${tb === 'distribution' ? 'classDistribution' : tb}`)}
|
<span>{t('dataset.editor')}</span>
|
||||||
|
<span
|
||||||
|
className={`ml-1.5 px-1.5 py-px text-[10px] font-mono border rounded-[2px] tabular-nums ${
|
||||||
|
tab === 'editor'
|
||||||
|
? 'text-accent-amber border-accent-amber'
|
||||||
|
: 'text-text-muted border-border-hair'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{editorAnnotation ? editorDetections.length : '—'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('distribution')}
|
||||||
|
className={`tab ${tab === 'distribution' ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<span>{t('dataset.classDistribution')}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
<div
|
||||||
{tab === 'annotations' && (
|
className="ml-auto flex items-center gap-2 px-2 micro"
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
style={{ color: 'var(--text-muted)' }}
|
||||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}>
|
>
|
||||||
{cards.map(card => {
|
<span className="live-dot" />
|
||||||
const statusPill =
|
<span>{t('dataset.liveSync')}</span>
|
||||||
card.status === AnnotationStatus.Validated ? { cls: 'bg-az-green text-white', label: t('dataset.status.validated') } :
|
</div>
|
||||||
card.status === AnnotationStatus.Edited ? { cls: 'bg-az-blue text-white', label: t('dataset.status.edited') } :
|
</div>
|
||||||
{ cls: 'bg-az-orange text-white', label: t('dataset.status.created') }
|
|
||||||
const isSelected = selectedIds.has(card.annotationId)
|
{/* Content */}
|
||||||
return (
|
{tab === 'annotations' && (
|
||||||
<div
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
<div
|
||||||
|
className="grid gap-2"
|
||||||
|
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(170px, 1fr))' }}
|
||||||
|
>
|
||||||
|
{cards.map(card => (
|
||||||
|
<DatasetTile
|
||||||
key={card.annotationId}
|
key={card.annotationId}
|
||||||
|
card={card}
|
||||||
|
isSelected={selectedIds.has(card.annotationId)}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
if (e.ctrlKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
setSelectedIds(prev => {
|
setSelectedIds(prev => {
|
||||||
const n = new Set(prev)
|
const n = new Set(prev)
|
||||||
n.has(card.annotationId) ? n.delete(card.annotationId) : n.add(card.annotationId)
|
if (n.has(card.annotationId)) n.delete(card.annotationId)
|
||||||
|
else n.add(card.annotationId)
|
||||||
return n
|
return n
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -316,119 +341,121 @@ export default function DatasetPage() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
removeSaved(card.annotationId)
|
removeSaved(card.annotationId)
|
||||||
}}
|
}}
|
||||||
title={card.imageName}
|
onEditClick={() => handleDoubleClick(card)}
|
||||||
className={`aspect-square bg-az-panel rounded border overflow-hidden cursor-pointer relative transition-colors ${
|
/>
|
||||||
isSelected ? 'border-az-orange' : 'border-az-border hover:border-az-blue'
|
))}
|
||||||
} ${card.isSeed ? 'ring-2 ring-az-red' : ''}`}
|
</div>
|
||||||
|
{cards.length === 0 && (
|
||||||
|
<div className="text-center text-text-muted text-xs py-8">{t('common.noData')}</div>
|
||||||
|
)}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center items-center gap-3 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
>
|
>
|
||||||
{card.thumbnailUrl ? (
|
Prev
|
||||||
<img
|
</button>
|
||||||
src={card.thumbnailUrl}
|
<span className="mono text-[12px] text-text-primary tabular-nums">
|
||||||
alt={card.imageName}
|
{page} / {totalPages}
|
||||||
className="w-full h-full object-cover bg-az-bg"
|
</span>
|
||||||
loading="lazy"
|
<button
|
||||||
/>
|
type="button"
|
||||||
) : (
|
className="btn btn-ghost"
|
||||||
<div className="w-full h-full bg-az-bg" />
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
)}
|
disabled={page === totalPages}
|
||||||
<span className={`absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full ${statusPill.cls}`}>
|
>
|
||||||
{statusPill.label}
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'editor' && editorMedia && editorAnnotation && (
|
||||||
|
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 flex flex-col">
|
||||||
|
<div className="bg-surface-1 border-b border-border-hair px-3 py-2 flex gap-2 items-center shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleEditorSave}
|
||||||
|
disabled={editorSaving || (!editorLocalGroupId && editorDetections.length === 0)}
|
||||||
|
>
|
||||||
|
{editorSaving ? 'Saving…' : t('common.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={handleEditorCancel}
|
||||||
|
disabled={editorSaving}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<span className="micro" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{editorDetections.length} detection{editorDetections.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{!editorLocalGroupId && (
|
||||||
|
<span className="micro ml-auto" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
remote save not wired yet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 relative">
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<CanvasEditor
|
||||||
|
media={editorMedia}
|
||||||
|
annotation={editorAnnotation}
|
||||||
|
detections={editorDetections}
|
||||||
|
onDetectionsChange={setEditorDetections}
|
||||||
|
selectedClassNum={selectedClassNum}
|
||||||
|
currentTime={0}
|
||||||
|
annotations={[]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'distribution' && (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{distribution.map(d => {
|
||||||
|
const pct = (d.count / maxDistCount) * 100
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={d.classNum}
|
||||||
|
className="relative flex items-center h-8 border-b border-border-hair px-3 gap-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 pointer-events-none"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: d.color, opacity: 0.18 }}
|
||||||
|
/>
|
||||||
|
<span className="swatch shrink-0 relative" style={{ background: d.color }} />
|
||||||
|
<span className="relative text-[12px] text-text-primary truncate">{d.label}</span>
|
||||||
|
<span className="relative ml-auto mono text-[12px] text-text-primary tabular-nums">
|
||||||
|
{d.count.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
{card.isLocal && (
|
|
||||||
<span className="absolute top-1.5 right-1.5 text-[9px] px-1.5 py-0.5 rounded bg-az-border text-az-text">
|
|
||||||
local
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={e => { e.stopPropagation(); handleDoubleClick(card) }}
|
|
||||||
title={t('dataset.edit') ?? 'Edit'}
|
|
||||||
className="absolute bottom-1.5 right-1.5 w-6 h-6 flex items-center justify-center rounded bg-az-bg/80 text-az-text hover:bg-az-orange hover:text-white"
|
|
||||||
>
|
|
||||||
<FaPen size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{distribution.length === 0 && (
|
||||||
|
<div className="text-center text-text-muted text-xs py-8">{t('common.noData')}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{cards.length === 0 && (
|
)}
|
||||||
<div className="text-center text-az-muted text-xs py-8">{t('common.noData')}</div>
|
</div>
|
||||||
)}
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex justify-center gap-2 py-3">
|
|
||||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="text-xs text-az-muted disabled:opacity-30 px-2 py-1 bg-az-panel rounded">Prev</button>
|
|
||||||
<span className="text-xs text-az-text py-1">{page} / {totalPages}</span>
|
|
||||||
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages} className="text-xs text-az-muted disabled:opacity-30 px-2 py-1 bg-az-panel rounded">Next</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'editor' && editorMedia && editorAnnotation && (
|
<DatasetStatusBar
|
||||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
selectedCount={selectedIds.size}
|
||||||
<div className="absolute inset-0 flex flex-col">
|
totalShown={cards.length}
|
||||||
<div className="bg-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0">
|
firstSelectedName={firstSelectedName}
|
||||||
<button
|
canValidate={selectedIds.size > 0}
|
||||||
onClick={handleEditorSave}
|
onValidate={handleValidate}
|
||||||
disabled={editorSaving || (!editorLocalGroupId && editorDetections.length === 0)}
|
/>
|
||||||
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"
|
</main>
|
||||||
>
|
|
||||||
{editorSaving ? 'Saving…' : t('common.save') ?? 'Save'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleEditorCancel}
|
|
||||||
disabled={editorSaving}
|
|
||||||
className="px-2.5 py-1 rounded border border-az-border text-az-text text-[11px] hover:bg-az-border/30 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{t('common.cancel') ?? 'Cancel'}
|
|
||||||
</button>
|
|
||||||
<span className="text-az-muted text-[10px]">
|
|
||||||
{editorDetections.length} detection{editorDetections.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
{!editorLocalGroupId && (
|
|
||||||
<span className="text-az-muted text-[10px] ml-auto">
|
|
||||||
remote save not wired yet
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0 relative">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<CanvasEditor
|
|
||||||
media={editorMedia}
|
|
||||||
annotation={editorAnnotation}
|
|
||||||
detections={editorDetections}
|
|
||||||
onDetectionsChange={setEditorDetections}
|
|
||||||
selectedClassNum={selectedClassNum}
|
|
||||||
currentTime={0}
|
|
||||||
annotations={[]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'distribution' && (
|
|
||||||
<div className="flex-1 overflow-y-auto bg-az-bg">
|
|
||||||
{distribution.map(d => {
|
|
||||||
const pct = (d.count / maxDistCount) * 100
|
|
||||||
return (
|
|
||||||
<div key={d.classNum} className="relative h-6 border-b border-az-border/40">
|
|
||||||
<div
|
|
||||||
className="absolute inset-y-0 left-0"
|
|
||||||
style={{ width: `${pct}%`, backgroundColor: d.color, opacity: 0.85 }}
|
|
||||||
/>
|
|
||||||
<div className="relative flex items-center justify-between h-full px-2 text-xs text-white tabular-nums">
|
|
||||||
<span className="truncate">{d.label}: {d.count}</span>
|
|
||||||
<span className="pl-2">{d.count}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface DatasetStatusBarProps {
|
||||||
|
selectedCount: number
|
||||||
|
totalShown: number
|
||||||
|
firstSelectedName: string | null
|
||||||
|
canValidate: boolean
|
||||||
|
onValidate: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatasetStatusBar({
|
||||||
|
selectedCount,
|
||||||
|
totalShown,
|
||||||
|
firstSelectedName,
|
||||||
|
canValidate,
|
||||||
|
onValidate,
|
||||||
|
}: DatasetStatusBarProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bracket panel relative flex items-center gap-3 px-3 shrink-0" style={{ height: 44 }}>
|
||||||
|
<span className="br" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!canValidate}
|
||||||
|
onClick={onValidate}
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
{t('dataset.validate')} ({selectedCount})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="w-px h-5 bg-border-hair shrink-0" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="micro">{t('dataset.selected')}</span>
|
||||||
|
<span className="mono text-[12px] text-text-primary truncate">
|
||||||
|
{firstSelectedName ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<span className="text-[11px] text-text-muted">
|
||||||
|
{t('dataset.ofSelected', { count: selectedCount, total: totalShown })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { FaPen } from 'react-icons/fa'
|
||||||
|
import { AnnotationStatus } from '../../types'
|
||||||
|
import type { Detection } from '../../types'
|
||||||
|
|
||||||
|
export interface DatasetCard {
|
||||||
|
annotationId: string
|
||||||
|
imageName: string
|
||||||
|
status: AnnotationStatus
|
||||||
|
createdDate: string
|
||||||
|
thumbnailUrl: string
|
||||||
|
isSeed: boolean
|
||||||
|
isLocal: boolean
|
||||||
|
detections?: Detection[]
|
||||||
|
mediaId?: string
|
||||||
|
time?: string | null
|
||||||
|
fullFrame?: string
|
||||||
|
annotationLocalId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DatasetTileProps {
|
||||||
|
card: DatasetCard
|
||||||
|
isSelected: boolean
|
||||||
|
onClick: (e: React.MouseEvent) => void
|
||||||
|
onDoubleClick: () => void
|
||||||
|
onContextMenu: (e: React.MouseEvent) => void
|
||||||
|
onEditClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TILE_DATE_FMT = new Intl.DateTimeFormat('en', { day: '2-digit', month: 'short' })
|
||||||
|
|
||||||
|
export function formatTileDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (isNaN(d.getTime())) return ''
|
||||||
|
return TILE_DATE_FMT.format(d).toUpperCase()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatasetTile({
|
||||||
|
card,
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
onDoubleClick,
|
||||||
|
onContextMenu,
|
||||||
|
onEditClick,
|
||||||
|
}: DatasetTileProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const statusPill =
|
||||||
|
card.status === AnnotationStatus.Validated
|
||||||
|
? { cls: 'pill-green', label: t('dataset.status.validated') }
|
||||||
|
: card.status === AnnotationStatus.Edited
|
||||||
|
? { cls: 'pill-blue', label: t('dataset.status.edited') }
|
||||||
|
: card.status === AnnotationStatus.Created
|
||||||
|
? { cls: 'pill-amber', label: t('dataset.status.created') }
|
||||||
|
: { cls: 'pill-muted', label: t('dataset.status.none') }
|
||||||
|
|
||||||
|
const borderCls = isSelected
|
||||||
|
? card.isSeed
|
||||||
|
? 'border-2 border-accent-amber ring-1 ring-accent-red'
|
||||||
|
: 'border-2 border-accent-amber'
|
||||||
|
: card.isSeed
|
||||||
|
? 'border border-accent-red'
|
||||||
|
: 'border border-border-hair hover:border-accent-amber'
|
||||||
|
|
||||||
|
const tileDate = formatTileDate(card.createdDate)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
title={card.imageName}
|
||||||
|
className={`group aspect-square relative overflow-hidden rounded-[2px] bg-surface-1 cursor-pointer transition-colors ${borderCls}`}
|
||||||
|
>
|
||||||
|
{card.thumbnailUrl ? (
|
||||||
|
<img
|
||||||
|
src={card.thumbnailUrl}
|
||||||
|
alt={card.imageName}
|
||||||
|
loading="lazy"
|
||||||
|
className="absolute inset-0 w-full h-full object-cover bg-surface-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-surface-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* composite scrim: grid lines + bottom fade (matches design .tile .scrim) */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'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) 55%, rgba(0,0,0,0.55) 100%)',
|
||||||
|
backgroundSize: '24px 24px, 24px 24px, 100% 100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* corner-tag top-right */}
|
||||||
|
{tileDate && (
|
||||||
|
<div
|
||||||
|
className="absolute top-1.5 right-1.5 font-mono text-[9px] tracking-wider text-text-primary border border-border-hair rounded-[2px]"
|
||||||
|
style={{ background: 'rgba(10,13,16,0.65)', padding: '1px 5px' }}
|
||||||
|
>
|
||||||
|
{tileDate}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* local badge — top-left */}
|
||||||
|
{card.isLocal && (
|
||||||
|
<div
|
||||||
|
className="absolute top-1.5 left-1.5 font-mono text-[9px] tracking-wider text-accent-cyan border border-accent-cyan/50 rounded-[2px] px-1.5 py-px"
|
||||||
|
style={{ background: 'rgba(10,13,16,0.65)' }}
|
||||||
|
>
|
||||||
|
{t('dataset.local').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* selected check badge (only when selected & not local — local already has top-left badge) */}
|
||||||
|
{isSelected && !card.isLocal && (
|
||||||
|
<div
|
||||||
|
className="absolute top-1 left-1 inline-flex items-center justify-center rounded-[2px] bg-accent-amber"
|
||||||
|
style={{ width: 14, height: 14, color: '#0A0D10' }}
|
||||||
|
>
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* status pill bottom-left */}
|
||||||
|
<span
|
||||||
|
className={`absolute bottom-1.5 left-1.5 pill ${statusPill.cls}`}
|
||||||
|
style={{ padding: '2px 6px', fontSize: 9, height: 'auto', lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
<span className="dot" />
|
||||||
|
{statusPill.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* edit ibtn bottom-right (reveal on hover) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => { e.stopPropagation(); onEditClick() }}
|
||||||
|
title={t('dataset.edit')}
|
||||||
|
className="ibtn edit absolute bottom-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ background: 'rgba(10,13,16,0.65)' }}
|
||||||
|
>
|
||||||
|
<FaPen size={9} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,9 +17,9 @@ export default function AltitudeChart({ points }: Props) {
|
|||||||
datasets: [{
|
datasets: [{
|
||||||
label: t('flights.planner.altitude'),
|
label: t('flights.planner.altitude'),
|
||||||
data: points.map(p => p.altitude),
|
data: points.map(p => p.altitude),
|
||||||
borderColor: '#228be6',
|
borderColor: '#36D6C5',
|
||||||
backgroundColor: 'rgba(34,139,230,0.2)',
|
backgroundColor: 'rgba(54,214,197,0.18)',
|
||||||
pointBackgroundColor: '#fd7e14',
|
pointBackgroundColor: '#FF9D3D',
|
||||||
pointBorderColor: '#1e1e1e',
|
pointBorderColor: '#1e1e1e',
|
||||||
pointBorderWidth: 1,
|
pointBorderWidth: 1,
|
||||||
tension: 0.1,
|
tension: 0.1,
|
||||||
@@ -31,8 +31,8 @@ export default function AltitudeChart({ points }: Props) {
|
|||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: { legend: { display: false } },
|
plugins: { legend: { display: false } },
|
||||||
scales: {
|
scales: {
|
||||||
x: { ticks: { font: { size: 10 }, color: '#6c757d' }, grid: { color: '#495057' } },
|
x: { ticks: { font: { size: 10 }, color: '#9AA4B2' }, grid: { color: 'rgba(255,255,255,0.06)' } },
|
||||||
y: { ticks: { font: { size: 10 }, color: '#6c757d' }, grid: { color: '#495057' } },
|
y: { ticks: { font: { size: 10 }, color: '#9AA4B2' }, grid: { color: 'rgba(255,255,255,0.06)' } },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,46 +34,56 @@ export default function AltitudeDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[2000]">
|
<div className="fixed inset-0 flex items-center justify-center z-[2000]" style={{ background: 'rgba(0,0,0,0.6)' }}>
|
||||||
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-96 shadow-xl">
|
<div className="bracket panel w-96 shadow-xl" style={{ background: 'var(--surface-1)', padding: '20px' }}>
|
||||||
<h3 className="text-white font-semibold mb-1">
|
<h3 className="sect-head mb-1">
|
||||||
{isEditMode ? t('flights.planner.titleEdit') : t('flights.planner.titleAdd')}
|
{isEditMode ? t('flights.planner.titleEdit') : t('flights.planner.titleAdd')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-az-muted text-xs mb-3">{t('flights.planner.description')}</p>
|
<p className="micro mb-4" style={{ textTransform: 'none', letterSpacing: 'normal', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.planner.description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2 text-xs">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted block mb-0.5">{t('flights.planner.latitude')}</label>
|
<label className="micro block mb-1">{t('flights.planner.latitude')}</label>
|
||||||
<input type="number" step="any"
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
value={latitude.toFixed(COORDINATE_PRECISION)}
|
value={latitude.toFixed(COORDINATE_PRECISION)}
|
||||||
onChange={e => handleCoord(e.target.value, onLatitudeChange)}
|
onChange={e => handleCoord(e.target.value, onLatitudeChange)}
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1.5 text-az-text outline-none focus:border-az-orange"
|
className="inp inp-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted block mb-0.5">{t('flights.planner.longitude')}</label>
|
<label className="micro block mb-1">{t('flights.planner.longitude')}</label>
|
||||||
<input type="number" step="any"
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
value={longitude.toFixed(COORDINATE_PRECISION)}
|
value={longitude.toFixed(COORDINATE_PRECISION)}
|
||||||
onChange={e => handleCoord(e.target.value, onLongitudeChange)}
|
onChange={e => handleCoord(e.target.value, onLongitudeChange)}
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1.5 text-az-text outline-none focus:border-az-orange"
|
className="inp inp-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted block mb-0.5">{t('flights.planner.altitude')}</label>
|
<label className="micro block mb-1">{t('flights.planner.altitude')}</label>
|
||||||
<input type="number"
|
<input
|
||||||
|
type="number"
|
||||||
value={altitude}
|
value={altitude}
|
||||||
onChange={e => onAltitudeChange(Number(e.target.value))}
|
onChange={e => onAltitudeChange(Number(e.target.value))}
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1.5 text-az-text outline-none focus:border-az-orange"
|
className="inp inp-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted block mb-1">{t('flights.planner.purpose')}</label>
|
<label className="micro block mb-2">{t('flights.planner.purpose')}</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-4">
|
||||||
{PURPOSES.map(p => (
|
{PURPOSES.map(p => (
|
||||||
<label key={p.value} className="flex items-center gap-1.5 cursor-pointer text-az-text">
|
<label key={p.value} className="flex items-center gap-1.5 cursor-pointer text-text-primary text-[12px]">
|
||||||
<input type="checkbox" checked={meta.includes(p.value)}
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={meta.includes(p.value)}
|
||||||
onChange={() => toggleMeta(p.value)}
|
onChange={() => toggleMeta(p.value)}
|
||||||
className="rounded border-az-border bg-az-bg accent-az-orange" />
|
style={{ accentColor: 'var(--accent-amber)' }}
|
||||||
|
/>
|
||||||
{t(`flights.planner.${p.label}`)}
|
{t(`flights.planner.${p.label}`)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -81,16 +91,16 @@ export default function AltitudeDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
<div className="flex justify-end gap-2 mt-5">
|
||||||
<button onClick={onClose}
|
<button onClick={onClose} className="btn btn-ghost">
|
||||||
className="px-3 py-1 text-sm border border-az-border rounded hover:bg-az-bg text-az-text">
|
|
||||||
{t('flights.planner.cancel')}
|
{t('flights.planner.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onSubmit}
|
<button onClick={onSubmit} className="btn btn-primary">
|
||||||
className="px-3 py-1 text-sm bg-az-orange rounded hover:bg-orange-600 text-white">
|
|
||||||
{isEditMode ? t('flights.planner.submitEdit') : t('flights.planner.submitAdd')}
|
{isEditMode ? t('flights.planner.submitEdit') : t('flights.planner.submitAdd')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span className="br" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default function FlightListSidebar({ flights, selectedFlight, onSelect, o
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
const name = newName.trim()
|
const name = newName.trim()
|
||||||
@@ -28,47 +29,126 @@ export default function FlightListSidebar({ flights, selectedFlight, onSelect, o
|
|||||||
setCreating(false)
|
setCreating(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const needle = search.trim().toLowerCase()
|
||||||
|
const filteredFlights = needle
|
||||||
|
? flights.filter(f => f.name.toLowerCase().includes(needle))
|
||||||
|
: flights
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-az-panel border-r border-az-border flex flex-col shrink-0 w-[160px]">
|
<div className="w-[210px] shrink-0 flex flex-col border-r border-border-hair bg-surface-1">
|
||||||
<div className="px-2 py-2 border-b border-az-border text-[10px] text-az-muted uppercase tracking-wide">
|
|
||||||
{t('flights.title')}
|
{/* Header */}
|
||||||
|
<div className="px-3 py-2.5 flex items-center justify-between border-b border-border-hair">
|
||||||
|
<span className="sect-head">{t('flights.v2.roster')}</span>
|
||||||
|
<span className="micro mono" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{String(flights.length).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{flights.map(f => (
|
{/* Search */}
|
||||||
<div key={f.id} onClick={() => onSelect(f)}
|
<div className="px-3 py-2 border-b border-border-hair">
|
||||||
className={`px-2 py-1.5 cursor-pointer border-b border-az-border text-xs ${
|
<div className="relative">
|
||||||
selectedFlight?.id === f.id ? 'bg-az-bg text-white' : 'text-az-text hover:bg-az-bg'
|
<input
|
||||||
}`}>
|
className="inp mono text-[11px]"
|
||||||
<div className="flex items-center justify-between">
|
style={{ height: 28, letterSpacing: '0.08em', paddingLeft: 28 }}
|
||||||
<span className="truncate">{f.name}</span>
|
placeholder={t('flights.v2.search')}
|
||||||
<button onClick={e => { e.stopPropagation(); onDelete(f.id) }}
|
value={search}
|
||||||
className="text-az-muted hover:text-az-red text-xs">×</button>
|
onChange={e => setSearch(e.target.value)}
|
||||||
</div>
|
/>
|
||||||
<div className="text-[10px] text-az-muted">{new Date(f.createdDate).toLocaleDateString()}</div>
|
<svg
|
||||||
</div>
|
className="absolute left-2 top-1/2 -translate-y-1/2"
|
||||||
))}
|
width="11"
|
||||||
</div>
|
height="11"
|
||||||
{creating ? (
|
viewBox="0 0 24 24"
|
||||||
<div className="flex gap-1 mx-3 my-2">
|
fill="none"
|
||||||
<input autoFocus value={newName} onChange={e => setNewName(e.target.value)}
|
stroke="currentColor"
|
||||||
onKeyDown={e => {
|
strokeWidth="2"
|
||||||
if (e.key === 'Enter') handleCreate()
|
style={{ color: 'var(--text-muted)' }}
|
||||||
if (e.key === 'Escape') handleCancel()
|
>
|
||||||
}}
|
<circle cx="11" cy="11" r="7" />
|
||||||
placeholder="Flight name"
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
className="flex-1 min-w-0 bg-az-bg border border-az-border rounded px-2 py-1.5 text-xs text-az-text outline-none focus:border-az-orange" />
|
</svg>
|
||||||
<button onClick={handleCreate} className="shrink-0 bg-az-blue text-white text-xs px-3 py-1.5 rounded hover:brightness-110">OK</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<button onClick={() => setCreating(true)}
|
|
||||||
className="mx-3 my-2 py-1.5 bg-az-blue text-white rounded text-xs hover:brightness-110">
|
|
||||||
+ {t('flights.create')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="border-t border-az-border p-2">
|
|
||||||
<label className="block text-[9px] text-az-muted uppercase tracking-wide mb-1">{t('flights.telemetry')}</label>
|
|
||||||
<input type="date" className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-[10px] text-az-text" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Flight list */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{filteredFlights.map(f => {
|
||||||
|
const isActive = selectedFlight?.id === f.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => onSelect(f)}
|
||||||
|
className={`group relative flex items-center gap-2 cursor-pointer border-b border-border-hair mono text-[12px]${isActive ? ' bg-surface-2' : ''}`}
|
||||||
|
style={{ height: 28, padding: '0 12px' }}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<span style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 2, background: 'var(--accent-amber)' }} />
|
||||||
|
)}
|
||||||
|
<span style={{ color: 'var(--accent-amber)' }} className="truncate">
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="ml-auto text-[10px]"
|
||||||
|
style={{ color: 'var(--text-muted)', letterSpacing: '0.08em' }}
|
||||||
|
>
|
||||||
|
{new Date(f.createdDate).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onDelete(f.id) }}
|
||||||
|
className="opacity-0 group-hover:opacity-100 hover:text-accent-red text-text-muted text-[13px] leading-none shrink-0"
|
||||||
|
aria-label="Delete flight"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create section */}
|
||||||
|
<div className="p-3 border-t border-border-hair">
|
||||||
|
{creating ? (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') handleCreate()
|
||||||
|
if (e.key === 'Escape') handleCancel()
|
||||||
|
}}
|
||||||
|
placeholder={t('flights.v2.createNew')}
|
||||||
|
className="inp mono flex-1 min-w-0 text-[11px]"
|
||||||
|
style={{ height: 28 }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleCreate} className="btn btn-primary shrink-0">
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
className="btn 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" strokeWidth="1.5" />
|
||||||
|
</svg>
|
||||||
|
{t('flights.v2.createNew')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Telemetry card */}
|
||||||
|
<div className="m-3 mt-0 bracket panel p-3">
|
||||||
|
<span className="br" />
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="micro" style={{ color: 'var(--accent-amber)' }}>// {t('flights.telemetry')}</span>
|
||||||
|
</div>
|
||||||
|
<label className="micro block mb-1">{t('flights.v2.date')}</label>
|
||||||
|
<input type="date" className="inp inp-mono text-[12px]" style={{ colorScheme: 'dark' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef, useEffect, useState } from 'react'
|
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||||
import { MapContainer, TileLayer, Marker, Popup, Polyline, Rectangle, useMap, useMapEvents } from 'react-leaflet'
|
import { MapContainer, TileLayer, Marker, Popup, Rectangle, useMap, useMapEvents } from 'react-leaflet'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import 'leaflet-polylinedecorator'
|
import 'leaflet-polylinedecorator'
|
||||||
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import DrawControl from './DrawControl'
|
import DrawControl from './DrawControl'
|
||||||
import MapPoint from './MapPoint'
|
import MapPoint from './MapPoint'
|
||||||
import MiniMap from './MiniMap'
|
import MiniMap from './MiniMap'
|
||||||
import { defaultIcon } from './mapIcons'
|
import { currentPositionIcon } from './mapIcons'
|
||||||
import { getTileUrl } from './types'
|
import { getTileUrl } from './types'
|
||||||
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, MovingPointInfo } from './types'
|
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, MovingPointInfo } from './types'
|
||||||
|
|
||||||
@@ -35,9 +35,9 @@ function MapEvents({ points, handlePolylineClick, containerRef, onMapMove }: Map
|
|||||||
|
|
||||||
if (points.length > 1) {
|
if (points.length > 1) {
|
||||||
const positions: L.LatLngTuple[] = points.map(p => [p.position.lat, p.position.lng])
|
const positions: L.LatLngTuple[] = points.map(p => [p.position.lat, p.position.lng])
|
||||||
polylineRef.current = L.polyline(positions, { color: '#228be6', weight: 6, opacity: 0.7, lineJoin: 'round' }).addTo(map)
|
polylineRef.current = L.polyline(positions, { color: '#36D6C5', weight: 6, opacity: 0.7, lineJoin: 'round' }).addTo(map)
|
||||||
arrowRef.current = L.polylineDecorator(polylineRef.current, {
|
arrowRef.current = L.polylineDecorator(polylineRef.current, {
|
||||||
patterns: [{ offset: '10%', repeat: '40%', symbol: L.Symbol.arrowHead({ pixelSize: 12, pathOptions: { fillOpacity: 1, weight: 0, color: '#228be6' } }) }],
|
patterns: [{ offset: '10%', repeat: '40%', symbol: L.Symbol.arrowHead({ pixelSize: 12, pathOptions: { fillOpacity: 1, weight: 0, color: '#36D6C5' } }) }],
|
||||||
}).addTo(map)
|
}).addTo(map)
|
||||||
polylineRef.current.on('click', handlePolylineClick)
|
polylineRef.current.on('click', handlePolylineClick)
|
||||||
}
|
}
|
||||||
@@ -61,6 +61,12 @@ function SetView({ center }: { center: L.LatLngExpression }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MapRefCapture({ onReady }: { onReady: (m: L.Map) => void }) {
|
||||||
|
const m = useMap()
|
||||||
|
useEffect(() => { onReady(m) }, [m, onReady])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
points: FlightPoint[]
|
points: FlightPoint[]
|
||||||
calculatedPointInfo: CalculatedPointInfo[]
|
calculatedPointInfo: CalculatedPointInfo[]
|
||||||
@@ -77,21 +83,29 @@ interface Props {
|
|||||||
onPolylineClick: (e: L.LeafletMouseEvent) => void
|
onPolylineClick: (e: L.LeafletMouseEvent) => void
|
||||||
onPositionChange: (pos: { lat: number; lng: number }) => void
|
onPositionChange: (pos: { lat: number; lng: number }) => void
|
||||||
onMapMove: (center: L.LatLng) => void
|
onMapMove: (center: L.LatLng) => void
|
||||||
|
// v2 HUD optional props — safe defaults keep existing call sites intact
|
||||||
|
liveGps?: { lat: number; lon: number; satellites: number; status: string } | null
|
||||||
|
flightLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FlightMap({
|
export default function FlightMap({
|
||||||
points, currentPosition, rectangles, setRectangles,
|
points, currentPosition, rectangles, setRectangles,
|
||||||
rectangleColor, actionMode, onAddPoint, onUpdatePoint, onRemovePoint,
|
rectangleColor, actionMode, onAddPoint, onUpdatePoint, onRemovePoint,
|
||||||
onAltitudeChange, onMetaChange, onPolylineClick, onPositionChange, onMapMove,
|
onAltitudeChange, onMetaChange, onPolylineClick, onPositionChange, onMapMove,
|
||||||
|
liveGps = null,
|
||||||
|
flightLabel = '—',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [movingPoint, setMovingPoint] = useState<MovingPointInfo | null>(null)
|
const [movingPoint, setMovingPoint] = useState<MovingPointInfo | null>(null)
|
||||||
const [draggablePoints, setDraggablePoints] = useState(points)
|
const [draggablePoints, setDraggablePoints] = useState(points)
|
||||||
const polylineClickRef = useRef(false)
|
const polylineClickRef = useRef(false)
|
||||||
|
const [mapInstance, setMapInstance] = useState<L.Map | null>(null)
|
||||||
|
|
||||||
useEffect(() => { setDraggablePoints(points) }, [points])
|
useEffect(() => { setDraggablePoints(points) }, [points])
|
||||||
|
|
||||||
|
const handleMapReady = useCallback((m: L.Map) => { setMapInstance(m) }, [])
|
||||||
|
|
||||||
function ClickHandler() {
|
function ClickHandler() {
|
||||||
useMapEvents({
|
useMapEvents({
|
||||||
click(e) {
|
click(e) {
|
||||||
@@ -117,9 +131,23 @@ export default function FlightMap({
|
|||||||
setDraggablePoints(updated)
|
setDraggablePoints(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayLat = liveGps?.lat ?? currentPosition.lat
|
||||||
|
const displayLon = liveGps?.lon ?? currentPosition.lng
|
||||||
|
const satelliteCount = liveGps?.satellites ?? 12
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 relative" ref={containerRef}>
|
<div className="flex-1 relative" ref={containerRef}>
|
||||||
<MapContainer center={currentPosition} zoom={15} className="h-full w-full">
|
<MapContainer center={currentPosition} zoom={15} className="h-full w-full"
|
||||||
|
zoomControl={false} attributionControl={false}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#0F1318',
|
||||||
|
backgroundImage:
|
||||||
|
'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%)',
|
||||||
|
backgroundSize: '60px 60px, 60px 60px, 100% 100%, 100% 100%',
|
||||||
|
}}>
|
||||||
<ClickHandler />
|
<ClickHandler />
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url={getTileUrl()}
|
url={getTileUrl()}
|
||||||
@@ -128,6 +156,7 @@ export default function FlightMap({
|
|||||||
/>
|
/>
|
||||||
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} />
|
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} />
|
||||||
<SetView center={currentPosition} />
|
<SetView center={currentPosition} />
|
||||||
|
<MapRefCapture onReady={handleMapReady} />
|
||||||
|
|
||||||
{movingPoint && <MiniMap pointPosition={movingPoint} />}
|
{movingPoint && <MiniMap pointPosition={movingPoint} />}
|
||||||
|
|
||||||
@@ -144,16 +173,8 @@ export default function FlightMap({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{draggablePoints.length > 1 && (
|
|
||||||
<Polyline
|
|
||||||
positions={[[draggablePoints[draggablePoints.length - 1].position.lat, draggablePoints[draggablePoints.length - 1].position.lng],
|
|
||||||
[draggablePoints[0].position.lat, draggablePoints[0].position.lng]]}
|
|
||||||
color="#228be6" dashArray="5,10"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentPosition && (
|
{currentPosition && (
|
||||||
<Marker position={currentPosition} icon={defaultIcon} draggable
|
<Marker position={currentPosition} icon={currentPositionIcon} draggable
|
||||||
eventHandlers={{ dragend: (e) => onPositionChange((e.target as L.Marker).getLatLng()) }}>
|
eventHandlers={{ dragend: (e) => onPositionChange((e.target as L.Marker).getLatLng()) }}>
|
||||||
<Popup>{t('flights.planner.currentLocation')}</Popup>
|
<Popup>{t('flights.planner.currentLocation')}</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
@@ -166,11 +187,227 @@ export default function FlightMap({
|
|||||||
<DrawControl color={rectangleColor} actionMode={actionMode} rectangles={rectangles} setRectangles={setRectangles} />
|
<DrawControl color={rectangleColor} actionMode={actionMode} rectangles={rectangles} setRectangles={setRectangles} />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
|
{/* v2 drawing-hint HUD — restyled to v2 tokens */}
|
||||||
{(actionMode === 'workArea' || actionMode === 'prohibitedArea') && (
|
{(actionMode === 'workArea' || actionMode === 'prohibitedArea') && (
|
||||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-[400] bg-az-panel/90 border border-az-border rounded px-3 py-1 text-[11px] text-az-text pointer-events-none">
|
<div
|
||||||
Click and drag on the map to draw a {actionMode === 'workArea' ? 'work area' : 'no-go zone'}
|
className="top-2 left-1/2 -translate-x-1/2 bracket panel micro pointer-events-none"
|
||||||
|
style={{ position: 'absolute', zIndex: 500, padding: '4px 12px', color: 'var(--accent-amber)', background: 'rgba(19,23,28,0.92)' }}
|
||||||
|
>
|
||||||
|
{t(actionMode === 'workArea' ? 'flights.v2.drawHintWork' : 'flights.v2.drawHintNoGo')}
|
||||||
|
<span className="br" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ======================================================= */}
|
||||||
|
{/* Compass rosette — top-left */}
|
||||||
|
{/* ======================================================= */}
|
||||||
|
<div
|
||||||
|
className="bracket panel flex items-center justify-center pointer-events-none"
|
||||||
|
style={{ position: 'absolute', top: 48, left: 16, width: 80, height: 80, background: 'rgba(19,23,28,0.6)', backdropFilter: 'blur(2px)', zIndex: 500 }}
|
||||||
|
>
|
||||||
|
<svg width="60" height="60" viewBox="-30 -30 60 60" style={{ color: 'var(--accent-amber)' }}>
|
||||||
|
<circle r="24" fill="none" stroke="currentColor" strokeOpacity="0.3" strokeWidth="0.7" />
|
||||||
|
<circle r="20" fill="none" stroke="currentColor" strokeOpacity="0.2" strokeWidth="0.5" />
|
||||||
|
<line x1="0" y1="-26" x2="0" y2="-20" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<line x1="0" y1="20" x2="0" y2="26" stroke="currentColor" strokeOpacity="0.4" strokeWidth="0.8" />
|
||||||
|
<line x1="-26" y1="0" x2="-20" y2="0" stroke="currentColor" strokeOpacity="0.4" strokeWidth="0.8" />
|
||||||
|
<line x1="20" y1="0" x2="26" y2="0" stroke="currentColor" strokeOpacity="0.4" strokeWidth="0.8" />
|
||||||
|
<text x="0" y="-12" textAnchor="middle" fontFamily="JetBrains Mono" fontSize="7" fill="currentColor" fontWeight="700">N</text>
|
||||||
|
<polygon points="0,-16 -3,-8 0,-10 3,-8" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ======================================================= */}
|
||||||
|
{/* Telemetry HUD — top-right */}
|
||||||
|
{/* ======================================================= */}
|
||||||
|
<div
|
||||||
|
className="bracket panel"
|
||||||
|
style={{ position: 'absolute', top: 16, right: 16, width: 240, background: 'rgba(19,23,28,0.92)', backdropFilter: 'blur(4px)', padding: 12, zIndex: 500 }}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
style={{ marginBottom: 10, paddingBottom: 8, borderBottom: '1px solid var(--border-hair)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-2 mono"
|
||||||
|
style={{ fontSize: 10, color: 'var(--accent-cyan)', letterSpacing: '0.14em' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full live"
|
||||||
|
style={{ background: 'var(--accent-cyan)' }}
|
||||||
|
/>
|
||||||
|
{t('flights.v2.hud.liveConnected')}
|
||||||
|
</span>
|
||||||
|
<span className="micro" style={{ color: 'var(--text-muted)' }}>{flightLabel}</span>
|
||||||
|
</header>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="micro">{t('flights.v2.hud.sat')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--accent-green)' }}>{satelliteCount} / 14</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="micro">{t('flights.v2.hud.lat')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--text-primary)' }}>{displayLat.toFixed(5)}° N</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="micro">{t('flights.v2.hud.lon')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--text-primary)' }}>{displayLon.toFixed(5)}° E</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="micro">{t('flights.v2.hud.alt')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--text-primary)' }}>320 M / AGL</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="micro">{t('flights.v2.hud.hdg')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--accent-amber)' }}>047° NE</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="micro">{t('flights.v2.hud.spd')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--text-primary)' }}>11.4 M/S</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
style={{ paddingTop: 6, marginTop: 6, borderTop: '1px solid var(--border-hair)' }}
|
||||||
|
>
|
||||||
|
<span className="micro">{t('flights.v2.hud.link')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 11, color: 'var(--accent-green)' }}>RSSI -52 DBM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ======================================================= */}
|
||||||
|
{/* Legend — bottom-left */}
|
||||||
|
{/* ======================================================= */}
|
||||||
|
<div
|
||||||
|
className="bracket panel pointer-events-none"
|
||||||
|
style={{ position: 'absolute', bottom: 48, left: 16, width: 200, background: 'rgba(19,23,28,0.92)', padding: 12, zIndex: 500 }}
|
||||||
|
>
|
||||||
|
<header style={{ marginBottom: 8, paddingBottom: 6, borderBottom: '1px solid var(--border-hair)' }}>
|
||||||
|
<span className="sect-head">// {t('flights.v2.hud.mapLegend')}</span>
|
||||||
|
</header>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11 }}>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<svg width="22" height="6">
|
||||||
|
<line x1="0" y1="3" x2="22" y2="3" stroke="#FF4756" strokeWidth="1.5" strokeDasharray="3 3" />
|
||||||
|
</svg>
|
||||||
|
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.v2.hud.plannedOriginal')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<svg width="22" height="6">
|
||||||
|
<line x1="0" y1="3" x2="22" y2="3" stroke="#36D6C5" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.v2.hud.correctedLive')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5"
|
||||||
|
style={{ paddingTop: 6, borderTop: '1px solid var(--border-hair)' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: 10, height: 10, background: 'var(--accent-green)', transform: 'rotate(45deg)', flexShrink: 0 }} />
|
||||||
|
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.v2.hud.originStart')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div style={{ width: 10, height: 10, background: 'transparent', border: '1.5px solid var(--accent-cyan)', flexShrink: 0 }} />
|
||||||
|
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.v2.hud.waypoint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div style={{ width: 11, height: 11, background: 'var(--accent-red)', clipPath: 'polygon(30% 0, 70% 0, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0 70%, 0 30%)', flexShrink: 0 }} />
|
||||||
|
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.v2.hud.targetFinish')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ======================================================= */}
|
||||||
|
{/* Map toolbar — right edge */}
|
||||||
|
{/* ======================================================= */}
|
||||||
|
<div
|
||||||
|
className="absolute flex flex-col gap-1.5 pointer-events-auto"
|
||||||
|
style={{ top: '50%', right: 16, transform: 'translateY(-50%)', zIndex: 500 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center border border-border-hair panel mono"
|
||||||
|
style={{ width: 32, height: 32, color: 'var(--text-primary)', fontSize: 16, background: 'var(--surface-1)' }}
|
||||||
|
title={t('flights.v2.hud.zoomIn')}
|
||||||
|
onClick={() => mapInstance?.zoomIn()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center border border-border-hair panel mono"
|
||||||
|
style={{ width: 32, height: 32, color: 'var(--text-primary)', fontSize: 16, background: 'var(--surface-1)' }}
|
||||||
|
title={t('flights.v2.hud.zoomOut')}
|
||||||
|
onClick={() => mapInstance?.zoomOut()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<div style={{ width: 32, height: 1, background: 'var(--border-hair)' }} />
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center border border-border-hair panel"
|
||||||
|
style={{ width: 32, height: 32, color: 'var(--accent-amber)', background: 'var(--surface-1)' }}
|
||||||
|
title={t('flights.v2.hud.recenter')}
|
||||||
|
onClick={() => mapInstance?.setView([currentPosition.lat, currentPosition.lng])}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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
|
||||||
|
className="flex items-center justify-center border border-border-hair panel"
|
||||||
|
style={{ width: 32, height: 32, color: 'var(--text-secondary)', background: 'var(--surface-1)' }}
|
||||||
|
title={t('flights.v2.hud.layers')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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
|
||||||
|
className="absolute left-0 right-0 flex items-center gap-4 border-t border-border-hair pointer-events-none"
|
||||||
|
style={{ bottom: 0, height: 28, padding: '0 12px', background: 'var(--surface-1)', zIndex: 500 }}
|
||||||
|
>
|
||||||
|
<span className="pill pill-green">
|
||||||
|
<span className="dot live" />
|
||||||
|
{t('flights.v2.strip.telemetryLive')}
|
||||||
|
</span>
|
||||||
|
<span className="micro" style={{ color: 'var(--text-muted)' }}>SSE</span>
|
||||||
|
<span className="mono micro" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{t('flights.v2.strip.frame')} 12,847 / 18,400
|
||||||
|
</span>
|
||||||
|
<span className="micro" style={{ color: 'var(--text-muted)' }}>·</span>
|
||||||
|
<span className="mono micro" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{displayLat.toFixed(5)} N · {displayLon.toFixed(5)} E
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto micro" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('flights.v2.strip.lastPing')} +0.42S
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import WaypointList from './WaypointList'
|
import WaypointList from './WaypointList'
|
||||||
import AltitudeChart from './AltitudeChart'
|
import AltitudeChart from './AltitudeChart'
|
||||||
import WindEffect from './WindEffect'
|
import WindEffect from './WindEffect'
|
||||||
|
import { DRAW_MODES, DRAW_MODE_ACCENT } from './drawModes'
|
||||||
import type { FlightPoint, CalculatedPointInfo, ActionMode, WindParams } from './types'
|
import type { FlightPoint, CalculatedPointInfo, ActionMode, WindParams } from './types'
|
||||||
import type { Aircraft } from '../../types'
|
import type { Aircraft } from '../../types'
|
||||||
|
|
||||||
@@ -39,75 +41,85 @@ export default function FlightParamsPanel({
|
|||||||
onSave, onUpload, onEditJson, onExport,
|
onSave, onUpload, onEditJson, onExport,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [hoveredMode, setHoveredMode] = useState<ActionMode | null>(null)
|
||||||
const modeBtn = (mode: ActionMode, label: string, color: 'orange' | 'green' | 'red') => {
|
|
||||||
const active = actionMode === mode
|
|
||||||
const colorMap = {
|
|
||||||
orange: { border: 'border-az-orange', text: 'text-az-orange', bg: 'bg-az-orange/20', hover: 'hover:bg-az-orange/10' },
|
|
||||||
green: { border: 'border-az-green', text: 'text-az-green', bg: 'bg-az-green/20', hover: 'hover:bg-az-green/10' },
|
|
||||||
red: { border: 'border-az-red', text: 'text-az-red', bg: 'bg-az-red/20', hover: 'hover:bg-az-red/10' },
|
|
||||||
}[color]
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => onActionModeChange(mode)}
|
|
||||||
className={`flex-1 px-2.5 py-1 rounded border text-[11px] ${colorMap.border} ${colorMap.text} ${active ? colorMap.bg : colorMap.hover}`}
|
|
||||||
>{label}</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2 space-y-2 text-xs overflow-y-auto flex-1">
|
<section className="p-4 space-y-5 flex-1 overflow-y-auto text-[12px]">
|
||||||
<div className="flex gap-1">
|
|
||||||
{modeBtn('points', t('flights.planner.addPoints'), 'orange')}
|
|
||||||
{modeBtn('workArea', t('flights.planner.workArea'), 'green')}
|
|
||||||
{modeBtn('prohibitedArea', t('flights.planner.prohibitedArea'), 'red')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* Draw-mode selector */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.location')}</label>
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<input
|
<span className="micro" style={{ color: 'var(--accent-amber)' }}>// {t('flights.v2.drawMode')}</span>
|
||||||
value={locationInput}
|
<span className="micro mono" style={{ color: 'var(--text-muted)' }}>{t('flights.v2.clickToPlot')}</span>
|
||||||
onChange={e => onLocationInputChange(e.target.value)}
|
</div>
|
||||||
onKeyDown={e => e.key === 'Enter' && onLocationSearch()}
|
<div className="grid grid-cols-3 gap-2">
|
||||||
placeholder="47.242, 35.024"
|
{DRAW_MODES.map(({ mode, i18nKey, accent, icon }) => {
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text outline-none focus:border-az-orange"
|
const active = actionMode === mode
|
||||||
/>
|
const { color, tint } = DRAW_MODE_ACCENT[accent]
|
||||||
<div className="text-az-muted text-[9px] mt-0.5">
|
return (
|
||||||
{t('flights.planner.currentLocation')}: {currentPosition.lat.toFixed(6)}, {currentPosition.lng.toFixed(6)}
|
<button key={mode} onClick={() => onActionModeChange(mode)} className="mono"
|
||||||
|
onMouseEnter={() => setHoveredMode(mode)} onMouseLeave={() => setHoveredMode(null)}
|
||||||
|
style={{
|
||||||
|
minHeight: 32, padding: '0 8px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
|
border: `1px solid ${color}`, color, borderRadius: 2,
|
||||||
|
fontSize: 10, fontWeight: 600, letterSpacing: '0.10em', textTransform: 'uppercase',
|
||||||
|
background: active ? tint : (hoveredMode === mode ? 'rgba(255,255,255,0.04)' : 'transparent'),
|
||||||
|
boxShadow: active ? `inset 0 0 0 1px ${color}` : 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<span style={{ flexShrink: 0, display: 'inline-flex' }}>{icon}</span>
|
||||||
|
<span style={{ textAlign: 'center', lineHeight: 1.1 }}>{t(i18nKey)}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Mission Config */}
|
||||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.aircraft')}</label>
|
<header className="flex items-center justify-between">
|
||||||
<select className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text">
|
<h2 className="sect-head">{t('flights.v2.missionConfig')}</h2>
|
||||||
{aircrafts.map(a => <option key={a.id} value={a.id}>{a.model}</option>)}
|
</header>
|
||||||
</select>
|
|
||||||
|
<div className="bracket panel p-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1.5">{t('flights.v2.aircraft')}</label>
|
||||||
|
<select className="inp">
|
||||||
|
{aircrafts.map(a => <option key={a.id} value={a.id}>{a.model}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1.5">{t('flights.v2.defaultHeight')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input type="number" value={initialAltitude}
|
||||||
|
onChange={e => onInitialAltitudeChange(Number(e.target.value))}
|
||||||
|
className="inp inp-mono" style={{ paddingRight: 36 }} />
|
||||||
|
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 micro" style={{ color: 'var(--text-muted)' }}>M</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1.5">{t('flights.v2.focalLength')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input type="text" placeholder={t('flights.planner.cameraFovPlaceholder')} className="inp inp-mono" style={{ paddingRight: 40 }} />
|
||||||
|
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 micro" style={{ color: 'var(--text-muted)' }}>MM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1.5">{t('flights.v2.commAddr')}</label>
|
||||||
|
<input type="text" placeholder={t('flights.planner.commAddrPlaceholder')} className="inp inp-mono" />
|
||||||
|
</div>
|
||||||
|
<span className="br" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Waypoints */}
|
||||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.initialAltitude')}</label>
|
<div className="bracket panel p-3">
|
||||||
<input type="number" value={initialAltitude}
|
<header className="flex items-center justify-between mb-2.5">
|
||||||
onChange={e => onInitialAltitudeChange(Number(e.target.value))}
|
<span className="sect-head">{t('flights.waypoints')}</span>
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text outline-none focus:border-az-orange"
|
<span className="micro mono" style={{ color: 'var(--text-muted)' }}>
|
||||||
/>
|
{String(points.length).padStart(2, '0')} {t('flights.v2.pts')}
|
||||||
</div>
|
</span>
|
||||||
|
</header>
|
||||||
<div>
|
|
||||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.cameraFov')}</label>
|
|
||||||
<input type="text" placeholder={t('flights.planner.cameraFovPlaceholder')}
|
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text outline-none focus:border-az-orange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.commAddr')}</label>
|
|
||||||
<input type="text" placeholder={t('flights.planner.commAddrPlaceholder')}
|
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text outline-none focus:border-az-orange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-az-muted block mb-1 text-[9px]">{t('flights.waypoints')}</label>
|
|
||||||
<WaypointList
|
<WaypointList
|
||||||
points={points}
|
points={points}
|
||||||
calculatedPointInfo={calculatedPointInfo}
|
calculatedPointInfo={calculatedPointInfo}
|
||||||
@@ -115,13 +127,32 @@ export default function FlightParamsPanel({
|
|||||||
onEdit={onEditPoint}
|
onEdit={onEditPoint}
|
||||||
onRemove={onRemovePoint}
|
onRemove={onRemovePoint}
|
||||||
/>
|
/>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Existing controls (restyled, appended below mockup blocks) ── */}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1.5">{t('flights.planner.location')}</label>
|
||||||
|
<input
|
||||||
|
value={locationInput}
|
||||||
|
onChange={e => onLocationInputChange(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && onLocationSearch()}
|
||||||
|
placeholder="47.242, 35.024"
|
||||||
|
className="inp inp-mono"
|
||||||
|
/>
|
||||||
|
<div className="micro mt-1" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('flights.planner.currentLocation')}: {currentPosition.lat.toFixed(6)}, {currentPosition.lng.toFixed(6)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{points.length > 1 && (
|
{points.length > 1 && (
|
||||||
<div className="bg-az-header rounded px-2 py-1 flex gap-2 text-[10px]">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span>{totalDistance}</span>
|
<span className="pill pill-muted">{totalDistance}</span>
|
||||||
<span>{totalTime}</span>
|
<span className="pill pill-muted">{totalTime}</span>
|
||||||
<span style={{ color: batteryStatus.color }}>{batteryStatus.label}</span>
|
<span className="pill" style={{ color: batteryStatus.color }}>
|
||||||
|
<span className="dot" />{batteryStatus.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -129,22 +160,16 @@ export default function FlightParamsPanel({
|
|||||||
|
|
||||||
<WindEffect wind={wind} onChange={onWindChange} />
|
<WindEffect wind={wind} onChange={onWindChange} />
|
||||||
|
|
||||||
<div className="flex gap-1">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button onClick={onSave} className="flex-1 px-2.5 py-1 rounded border border-az-green text-az-green text-[11px] hover:bg-az-green/10">
|
<button onClick={onSave} className="btn btn-secondary justify-center" style={{ color: 'var(--accent-green)', borderColor: 'var(--accent-green)' }}>
|
||||||
{t('flights.planner.save')}
|
{t('flights.planner.save')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onUpload} className="flex-1 px-2.5 py-1 rounded border border-az-blue text-az-blue text-[11px] hover:bg-az-blue/10">
|
<button onClick={onUpload} className="btn btn-secondary justify-center" style={{ color: 'var(--accent-cyan)', borderColor: 'var(--accent-cyan)' }}>
|
||||||
{t('flights.planner.upload')}
|
{t('flights.planner.upload')}
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={onEditJson} className="btn btn-ghost justify-center">{t('flights.planner.editAsJson')}</button>
|
||||||
|
<button onClick={onExport} className="btn btn-ghost justify-center">{t('flights.planner.exportMapData')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
</section>
|
||||||
<button onClick={onEditJson} className="flex-1 px-2.5 py-1 rounded border border-az-muted text-az-text text-[11px] hover:border-az-text hover:text-white">
|
|
||||||
{t('flights.planner.editAsJson')}
|
|
||||||
</button>
|
|
||||||
<button onClick={onExport} className="flex-1 px-2.5 py-1 rounded border border-az-muted text-az-text text-[11px] hover:border-az-text hover:text-white">
|
|
||||||
{t('flights.planner.exportMapData')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,19 @@ import FlightParamsPanel from './FlightParamsPanel'
|
|||||||
import FlightMap from './FlightMap'
|
import FlightMap from './FlightMap'
|
||||||
import AltitudeDialog from './AltitudeDialog'
|
import AltitudeDialog from './AltitudeDialog'
|
||||||
import JsonEditorDialog from './JsonEditorDialog'
|
import JsonEditorDialog from './JsonEditorDialog'
|
||||||
|
import GpsDeniedPanel from './GpsDeniedPanel'
|
||||||
|
import { DRAW_MODES, DRAW_MODE_ACCENT } from './drawModes'
|
||||||
import { newGuid, calculateDistance, calculateAllPoints, parseCoordinates, getMockAircraftParams } from './flightPlanUtils'
|
import { newGuid, calculateDistance, calculateAllPoints, parseCoordinates, getMockAircraftParams } from './flightPlanUtils'
|
||||||
import { PURPOSES } from './types'
|
import { PURPOSES } from './types'
|
||||||
import type { Aircraft, Waypoint } from '../../types'
|
import type { Aircraft, Waypoint } from '../../types'
|
||||||
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, WindParams, AircraftParams } from './types'
|
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, WindParams, AircraftParams, OrthoPhoto } from './types'
|
||||||
|
|
||||||
|
const tabStyle = (active: boolean, accentVar: string): React.CSSProperties => ({
|
||||||
|
padding: '10px 0', fontSize: 10, letterSpacing: '0.14em', borderBottom: '2px solid',
|
||||||
|
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||||
|
borderColor: active ? accentVar : 'transparent',
|
||||||
|
background: active ? 'var(--surface-1)' : 'transparent',
|
||||||
|
})
|
||||||
|
|
||||||
export default function FlightsPage() {
|
export default function FlightsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -36,6 +45,14 @@ export default function FlightsPage() {
|
|||||||
|
|
||||||
const [altDialog, setAltDialog] = useState<{ open: boolean; point: FlightPoint | null; isEdit: boolean }>({ open: false, point: null, isEdit: false })
|
const [altDialog, setAltDialog] = useState<{ open: boolean; point: FlightPoint | null; isEdit: boolean }>({ open: false, point: null, isEdit: false })
|
||||||
const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' })
|
const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' })
|
||||||
|
const [orthophotos, setOrthophotos] = useState<OrthoPhoto[]>([])
|
||||||
|
|
||||||
|
const handleApplyCorrection = useCallback((waypointNumber: number, lat: number, lon: number) => {
|
||||||
|
const idx = waypointNumber - 1
|
||||||
|
setPoints(prev => (idx < 0 || idx >= prev.length)
|
||||||
|
? prev
|
||||||
|
: prev.map((p, i) => i === idx ? { ...p, position: { lat, lng: lon } } : p))
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||||
@@ -47,6 +64,7 @@ export default function FlightsPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setLiveGps(null) // drop the previous flight's GPS readout until the new stream sends a fix
|
||||||
if (!selectedFlight) { setPoints([]); return }
|
if (!selectedFlight) { setPoints([]); return }
|
||||||
api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id))
|
api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id))
|
||||||
.then(wps => {
|
.then(wps => {
|
||||||
@@ -128,28 +146,21 @@ export default function FlightsPage() {
|
|||||||
setAltDialog({ open: false, point: null, isEdit: false })
|
setAltDialog({ open: false, point: null, isEdit: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildFlightPlanData = () => ({
|
||||||
|
operational_height: { currentAltitude: initialAltitude },
|
||||||
|
geofences: { polygons: rectangles.map(r => {
|
||||||
|
const sw = r.bounds.getSouthWest(), ne = r.bounds.getNorthEast()
|
||||||
|
return { northWest: { lat: ne.lat, lon: sw.lng }, southEast: { lat: sw.lat, lon: ne.lng }, fence_type: r.color === 'red' ? 'EXCLUSION' : 'INCLUSION' }
|
||||||
|
})},
|
||||||
|
action_points: points.map(p => ({ point: { lat: p.position.lat, lon: p.position.lng }, height: p.altitude, action: 'search', action_specific: { targets: p.meta } })),
|
||||||
|
})
|
||||||
|
|
||||||
const handleEditJson = () => {
|
const handleEditJson = () => {
|
||||||
const data = {
|
setJsonDialog({ open: true, text: JSON.stringify(buildFlightPlanData(), null, 2) })
|
||||||
operational_height: { currentAltitude: initialAltitude },
|
|
||||||
geofences: { polygons: rectangles.map(r => {
|
|
||||||
const sw = r.bounds.getSouthWest(), ne = r.bounds.getNorthEast()
|
|
||||||
return { northWest: { lat: ne.lat, lon: sw.lng }, southEast: { lat: sw.lat, lon: ne.lng }, fence_type: r.color === 'red' ? 'EXCLUSION' : 'INCLUSION' }
|
|
||||||
})},
|
|
||||||
action_points: points.map(p => ({ point: { lat: p.position.lat, lon: p.position.lng }, height: p.altitude, action: 'search', action_specific: { targets: p.meta } })),
|
|
||||||
}
|
|
||||||
setJsonDialog({ open: true, text: JSON.stringify(data, null, 2) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
const data = {
|
const blob = new Blob([JSON.stringify(buildFlightPlanData(), null, 2)], { type: 'application/json' })
|
||||||
operational_height: { currentAltitude: initialAltitude },
|
|
||||||
geofences: { polygons: rectangles.map(r => {
|
|
||||||
const sw = r.bounds.getSouthWest(), ne = r.bounds.getNorthEast()
|
|
||||||
return { northWest: { lat: ne.lat, lon: sw.lng }, southEast: { lat: sw.lat, lon: ne.lng }, fence_type: r.color === 'red' ? 'EXCLUSION' : 'INCLUSION' }
|
|
||||||
})},
|
|
||||||
action_points: points.map(p => ({ point: { lat: p.position.lat, lon: p.position.lng }, height: p.altitude, action: 'search', action_specific: { targets: p.meta } })),
|
|
||||||
}
|
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
@@ -242,29 +253,43 @@ export default function FlightsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<div className="w-10 bg-az-panel border-r border-az-border flex flex-col items-center py-2 gap-2 shrink-0">
|
<div className="shrink-0 flex flex-col items-center gap-2 border-r border-border-hair"
|
||||||
<button onClick={() => setCollapsed(false)} title="Expand"
|
style={{ width: 44, background: 'var(--surface-1)', padding: '10px 6px' }}>
|
||||||
className="w-8 h-8 rounded border border-az-border text-az-text hover:border-az-orange hover:text-az-orange text-sm">»</button>
|
<button onClick={() => setCollapsed(false)} title={t('flights.v2.expandParams')}
|
||||||
<button onClick={() => setActionMode('points')} title={t('flights.planner.addPoints')}
|
className="ibtn mono" style={{ width: 32, height: 32 }}>»</button>
|
||||||
className={`w-8 h-8 rounded border text-sm ${actionMode === 'points' ? 'border-az-orange text-az-orange bg-az-orange/20' : 'border-az-border text-az-text hover:border-az-orange'}`}>●</button>
|
<span className="block" style={{ width: 24, height: 1, background: 'var(--border-hair)' }} />
|
||||||
<button onClick={() => setActionMode('workArea')} title={t('flights.planner.workArea')}
|
{DRAW_MODES.map(({ mode: m, i18nKey, accent, icon }) => {
|
||||||
className={`w-8 h-8 rounded border text-az-green text-sm ${actionMode === 'workArea' ? 'border-az-green bg-az-green/20' : 'border-az-border hover:border-az-green'}`}>▣</button>
|
const active = actionMode === m
|
||||||
<button onClick={() => setActionMode('prohibitedArea')} title={t('flights.planner.prohibitedArea')}
|
const { color, tint } = DRAW_MODE_ACCENT[accent]
|
||||||
className={`w-8 h-8 rounded border text-az-red text-sm ${actionMode === 'prohibitedArea' ? 'border-az-red bg-az-red/20' : 'border-az-border hover:border-az-red'}`}>▣</button>
|
return (
|
||||||
|
<button key={m} onClick={() => setActionMode(m)} title={t(i18nKey)} className="mono"
|
||||||
|
style={{
|
||||||
|
width: 32, height: 32, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
border: `1px solid ${color}`, color, borderRadius: 2, cursor: 'pointer',
|
||||||
|
background: active ? tint : 'transparent',
|
||||||
|
boxShadow: active ? `inset 0 0 0 1px ${color}` : 'none',
|
||||||
|
}}>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-80 bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
<div className="shrink-0 flex flex-col overflow-y-auto border-r border-border-hair"
|
||||||
<div className="flex border-b border-az-border items-stretch">
|
style={{ width: 290, background: 'var(--surface-1)' }}>
|
||||||
|
<div className="flex items-stretch border-b border-border-hair" style={{ background: 'var(--surface-0)' }}>
|
||||||
<button onClick={() => setMode('params')}
|
<button onClick={() => setMode('params')}
|
||||||
className={`flex-1 py-1.5 text-[10px] ${mode === 'params' ? 'bg-az-bg text-white' : 'text-az-muted'}`}>
|
className="flex-1 mono uppercase"
|
||||||
{t('flights.params')}
|
style={tabStyle(mode === 'params', 'var(--accent-amber)')}>
|
||||||
|
{t('flights.v2.flightParams')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setMode('gps')}
|
<button onClick={() => setMode('gps')}
|
||||||
className={`flex-1 py-1.5 text-[10px] ${mode === 'gps' ? 'bg-az-bg text-white' : 'text-az-muted'}`}>
|
className="flex-1 mono uppercase"
|
||||||
{t('flights.gpsDenied')}
|
style={tabStyle(mode === 'gps', 'var(--accent-red)')}>
|
||||||
|
{t('flights.v2.gpsDenied')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setCollapsed(true)} title="Collapse"
|
<button onClick={() => setCollapsed(true)} title={t('flights.v2.collapse')}
|
||||||
className="px-2 text-az-muted hover:text-az-orange text-sm border-l border-az-border">«</button>
|
className="ibtn mono shrink-0 self-center mx-1" style={{ width: 26, height: 26 }}>«</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'params' && (
|
{mode === 'params' && (
|
||||||
@@ -282,24 +307,13 @@ export default function FlightsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === 'gps' && (
|
{mode === 'gps' && (
|
||||||
<div className="p-2 space-y-2 text-xs">
|
<GpsDeniedPanel
|
||||||
<div>
|
liveGps={liveGps}
|
||||||
<label className="text-az-muted block mb-1">{t('flights.liveGps')}</label>
|
orthophotos={orthophotos}
|
||||||
{liveGps ? (
|
onAddOrthophotos={(photos) => setOrthophotos(prev => [...prev, ...photos])}
|
||||||
<div className="bg-az-bg rounded p-1.5 space-y-0.5">
|
onApplyCorrection={handleApplyCorrection}
|
||||||
<div className="text-az-text">Status: <span className="text-az-green">{liveGps.status}</span></div>
|
onBack={() => setMode('params')}
|
||||||
<div className="text-az-text">Lat: {liveGps.lat.toFixed(6)}</div>
|
/>
|
||||||
<div className="text-az-text">Lon: {liveGps.lon.toFixed(6)}</div>
|
|
||||||
<div className="text-az-text">Sats: {liveGps.satellites}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-az-muted">Waiting for GPS signal...</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setMode('params')} className="text-az-orange text-xs">
|
|
||||||
← {t('flights.back')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -315,6 +329,8 @@ export default function FlightsPage() {
|
|||||||
onPolylineClick={handlePolylineClick}
|
onPolylineClick={handlePolylineClick}
|
||||||
onPositionChange={setCurrentPosition}
|
onPositionChange={setCurrentPosition}
|
||||||
onMapMove={() => {}}
|
onMapMove={() => {}}
|
||||||
|
liveGps={liveGps}
|
||||||
|
flightLabel={selectedFlight?.name ?? '—'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AltitudeDialog
|
<AltitudeDialog
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { newGuid } from './flightPlanUtils'
|
||||||
|
import type { OrthoPhoto } from './types'
|
||||||
|
|
||||||
|
interface LiveGps {
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
satellites: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
liveGps: LiveGps | null
|
||||||
|
orthophotos: OrthoPhoto[]
|
||||||
|
onAddOrthophotos: (photos: OrthoPhoto[]) => void
|
||||||
|
/** Apply a manual GPS correction to a waypoint (1-based number as shown in the list). */
|
||||||
|
onApplyCorrection: (waypointNumber: number, lat: number, lon: number) => void
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPS-Denied operating mode. The orthophoto upload and correction form are
|
||||||
|
* functional-local (no backend endpoint exists yet); the Live GPS readout is
|
||||||
|
* fed by the real SSE stream via the `liveGps` prop.
|
||||||
|
*/
|
||||||
|
function Row({ label, className, children }: { label: string; className?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-between py-1 ${className ?? ''}`}>
|
||||||
|
<span className="micro">{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GpsDeniedPanel({ liveGps, orthophotos, onAddOrthophotos, onApplyCorrection, onBack }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [wp, setWp] = useState('')
|
||||||
|
const [coords, setCoords] = useState('')
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = 'image/*'
|
||||||
|
input.multiple = true
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const files = Array.from((e.target as HTMLInputElement).files ?? [])
|
||||||
|
if (!files.length) return
|
||||||
|
const base = orthophotos.length
|
||||||
|
const photos: OrthoPhoto[] = files.map((f, i) => ({
|
||||||
|
id: newGuid(),
|
||||||
|
name: f.name,
|
||||||
|
lat: 48.8566 + (base + i) * 0.0046,
|
||||||
|
lon: 2.3522 + (base + i) * 0.0079,
|
||||||
|
}))
|
||||||
|
onAddOrthophotos(photos)
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
const num = parseInt(wp, 10)
|
||||||
|
const parts = coords.split(',').map(s => Number(s.trim()))
|
||||||
|
// Waypoint numbers are 1-based; reject 0/negative and non-numeric input.
|
||||||
|
if (!Number.isFinite(num) || num < 1 || parts.length !== 2 || !parts.every(Number.isFinite)) return
|
||||||
|
onApplyCorrection(num, parts[0], parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = liveGps?.status?.toUpperCase().includes('CONNECT') ?? false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="p-4 space-y-5 flex-1 overflow-y-auto">
|
||||||
|
<header className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="sect-head" style={{ color: 'var(--accent-red)', whiteSpace: 'nowrap' }}>{t('flights.v2.gpsDeniedActive')}</h2>
|
||||||
|
<span className="pill pill-red" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
<span className="dot live" />{t('flights.v2.active')}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Orthophoto upload — red frame (mockup: .bracket-red + .gps-active-frame).
|
||||||
|
Remap --accent-amber→red locally so the .bracket corner ticks render red;
|
||||||
|
no amber-colored children live inside this frame. */}
|
||||||
|
<div className="bracket panel" style={{
|
||||||
|
padding: 12,
|
||||||
|
border: '2px solid var(--accent-red)',
|
||||||
|
boxShadow: 'inset 0 0 0 1px rgba(255,71,86,0.12)',
|
||||||
|
['--accent-amber' as string]: 'var(--accent-red)',
|
||||||
|
} as React.CSSProperties}>
|
||||||
|
<header className="flex items-center justify-between" style={{ marginBottom: 12 }}>
|
||||||
|
<span className="sect-head" style={{ color: 'var(--accent-red)' }}>// {t('flights.v2.orthophotoUpload')}</span>
|
||||||
|
<span className="micro mono" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{String(orthophotos.length).padStart(2, '0')} / 12
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{orthophotos.map((p, i) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-2.5 border border-border-hair"
|
||||||
|
style={{ padding: '8px 10px', background: 'var(--surface-0)' }}>
|
||||||
|
<span className="flex items-center justify-center shrink-0 mono"
|
||||||
|
style={{ width: 24, height: 24, background: 'var(--accent-cyan)', color: '#0A0D10', fontSize: 10, fontWeight: 700 }}>
|
||||||
|
P{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-[11px] flex-1 truncate" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
|
||||||
|
<span className="mono text-[10px]" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{p.lat.toFixed(4)}, {p.lon.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={handleUpload}
|
||||||
|
className="w-full mono flex items-center justify-center gap-2"
|
||||||
|
style={{ marginTop: 10, padding: '8px 0', fontSize: 10, letterSpacing: '0.12em', textTransform: 'uppercase',
|
||||||
|
border: '1px dashed var(--border-raised)', color: 'var(--text-secondary)', background: 'transparent', borderRadius: 2 }}>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M5 1 V9 M1 5 H9" stroke="currentColor" strokeWidth="1.4" /></svg>
|
||||||
|
{t('flights.v2.uploadPhotos')}
|
||||||
|
</button>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live GPS readout */}
|
||||||
|
<div className="bracket panel" style={{ padding: 12 }}>
|
||||||
|
<header className="flex items-center justify-between gap-2" style={{ marginBottom: 10 }}>
|
||||||
|
<span className="sect-head" style={{ whiteSpace: 'nowrap' }}>// {t('flights.v2.liveGps')}</span>
|
||||||
|
<span className={`pill ${connected ? 'pill-green' : 'pill-muted'}`} style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
<span className="dot live" />{connected ? t('flights.v2.connected') : t('flights.v2.offline')}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className="space-y-1.5 text-[12px]">
|
||||||
|
<Row label={t('flights.v2.status')} className="border-b border-border-hair">
|
||||||
|
<span className="mono" style={{ whiteSpace: 'nowrap', color: connected ? 'var(--accent-green)' : 'var(--text-secondary)' }}>
|
||||||
|
{connected ? t('flights.v2.connectedStreaming') : t('flights.v2.offline')}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('flights.v2.latitude')} className="border-b border-border-hair">
|
||||||
|
<span className="mono num">{(liveGps?.lat ?? 0).toFixed(5)}° N</span>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('flights.v2.longitude')} className="border-b border-border-hair">
|
||||||
|
<span className="mono num">{(liveGps?.lon ?? 0).toFixed(5)}° E</span>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('flights.v2.satellites')} className="border-b border-border-hair">
|
||||||
|
<span className="mono num" style={{ color: 'var(--accent-cyan)' }}>{liveGps?.satellites ?? 0} / 14</span>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('flights.v2.drift')}>
|
||||||
|
<span className="mono num" style={{ color: 'var(--accent-amber)' }}>±2.4 M</span>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GPS Correction */}
|
||||||
|
<div className="bracket panel" style={{ padding: 12 }}>
|
||||||
|
<header className="flex items-center justify-between" style={{ marginBottom: 10 }}>
|
||||||
|
<span className="sect-head">// {t('flights.v2.gpsCorrection')}</span>
|
||||||
|
</header>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1.5">{t('flights.v2.waypointNum')}</label>
|
||||||
|
<input value={wp} onChange={e => setWp(e.target.value)} type="number" className="inp inp-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1.5">{t('flights.v2.correctedGps')}</label>
|
||||||
|
<input value={coords} onChange={e => setCoords(e.target.value)} type="text" placeholder="48.86120, 2.36011" className="inp inp-mono" />
|
||||||
|
</div>
|
||||||
|
<button onClick={handleApply} className="btn btn-primary w-full justify-center">{t('flights.v2.applyCorrection')}</button>
|
||||||
|
</div>
|
||||||
|
<span className="br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={onBack} className="btn btn-ghost w-full justify-center">‹ {t('flights.v2.backToParams')}</button>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,30 +23,36 @@ export default function JsonEditorDialog({ open, jsonText, onClose, onSave }: Pr
|
|||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[2000]">
|
<div className="fixed inset-0 flex items-center justify-center z-[2000]" style={{ background: 'rgba(0,0,0,0.6)' }}>
|
||||||
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-[700px] max-h-[80vh] shadow-xl flex flex-col">
|
<div
|
||||||
<h3 className="text-white font-semibold mb-2">{t('flights.planner.editAsJson')}</h3>
|
className="bracket panel shadow-xl flex flex-col"
|
||||||
|
style={{ background: 'var(--surface-1)', padding: '20px', width: '700px', maxHeight: '80vh' }}
|
||||||
|
>
|
||||||
|
<h3 className="sect-head mb-3">{t('flights.planner.editAsJson')}</h3>
|
||||||
<textarea
|
<textarea
|
||||||
value={edited}
|
value={edited}
|
||||||
onChange={e => handleChange(e.target.value)}
|
onChange={e => handleChange(e.target.value)}
|
||||||
rows={20}
|
rows={20}
|
||||||
className={`flex-1 w-full bg-az-bg border rounded px-3 py-2 text-az-text text-xs font-mono outline-none resize-none ${
|
className="inp inp-mono flex-1 resize-none"
|
||||||
valid ? 'border-az-border focus:border-az-orange' : 'border-az-red'
|
style={{
|
||||||
}`}
|
maxHeight: '60vh',
|
||||||
|
borderColor: valid ? undefined : 'var(--accent-red)',
|
||||||
|
boxShadow: valid ? undefined : '0 0 0 1px var(--accent-red)',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<p className={`text-xs mt-1 ${valid ? 'text-az-muted' : 'text-az-red'}`}>
|
<p className="text-[11px] mt-1.5" style={{ color: valid ? 'var(--text-secondary)' : 'var(--accent-red)' }}>
|
||||||
{valid ? t('flights.planner.editJsonHint') : t('flights.planner.invalidJson')}
|
{valid ? t('flights.planner.editJsonHint') : t('flights.planner.invalidJson')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-2 mt-3">
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
<button onClick={onClose}
|
<button onClick={onClose} className="btn btn-ghost">
|
||||||
className="px-3 py-1 text-sm border border-az-border rounded hover:bg-az-bg text-az-text">
|
|
||||||
{t('flights.planner.cancel')}
|
{t('flights.planner.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => valid && onSave(edited)} disabled={!valid}
|
<button onClick={() => valid && onSave(edited)} disabled={!valid} className="btn btn-primary">
|
||||||
className="px-3 py-1 text-sm bg-az-orange rounded hover:bg-orange-600 text-white disabled:opacity-40">
|
|
||||||
{t('flights.planner.save')}
|
{t('flights.planner.save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span className="br" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { Marker, Popup } from 'react-leaflet'
|
import { Marker, Popup } from 'react-leaflet'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { pointIconGreen, pointIconBlue, pointIconRed } from './mapIcons'
|
import { wpStartIcon, wpMidIcon, wpFinishIcon } from './mapIcons'
|
||||||
import { PURPOSES } from './types'
|
import { PURPOSES } from './types'
|
||||||
import type { FlightPoint, MovingPointInfo } from './types'
|
import type { FlightPoint, MovingPointInfo } from './types'
|
||||||
import type L from 'leaflet'
|
import type L from 'leaflet'
|
||||||
@@ -26,7 +26,7 @@ export default function MapPoint({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const markerRef = useRef<L.Marker>(null)
|
const markerRef = useRef<L.Marker>(null)
|
||||||
|
|
||||||
const icon = index === 0 ? pointIconGreen : index === points.length - 1 ? pointIconRed : pointIconBlue
|
const icon = index === 0 ? wpStartIcon : index === points.length - 1 ? wpFinishIcon : wpMidIcon
|
||||||
|
|
||||||
const handleMove = (e: L.LeafletEvent) => {
|
const handleMove = (e: L.LeafletEvent) => {
|
||||||
const marker = markerRef.current
|
const marker = markerRef.current
|
||||||
@@ -58,26 +58,55 @@ export default function MapPoint({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="text-xs space-y-1.5 min-w-[140px]">
|
<div style={{ minWidth: 148, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
<div className="font-semibold">{t('flights.planner.point')} {index + 1}</div>
|
<div
|
||||||
<div>
|
className="mono"
|
||||||
<label className="text-az-muted text-[10px]">{t('flights.planner.altitude')}</label>
|
style={{ color: 'var(--accent-amber)', fontSize: 12, fontWeight: 600 }}
|
||||||
<input type="range" min={0} max={3000} value={point.altitude}
|
>
|
||||||
onChange={e => onAltitudeChange(index, Number(e.target.value))}
|
{t('flights.planner.point')} {index + 1}
|
||||||
className="w-full accent-az-orange" />
|
|
||||||
<span className="text-[10px] text-az-muted">{point.altitude}m</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<label style={{ color: 'var(--text-secondary)', fontSize: 11 }}>
|
||||||
|
{t('flights.planner.altitude')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={3000}
|
||||||
|
value={point.altitude}
|
||||||
|
onChange={e => onAltitudeChange(index, Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
style={{ accentColor: 'var(--accent-amber)' }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="mono"
|
||||||
|
style={{ color: 'var(--text-primary)', fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{point.altitude}m
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
{PURPOSES.map(p => (
|
{PURPOSES.map(p => (
|
||||||
<label key={p.value} className="flex items-center gap-1 text-[10px] cursor-pointer">
|
<label
|
||||||
<input type="checkbox" checked={point.meta.includes(p.value)}
|
key={p.value}
|
||||||
onChange={() => toggleMeta(p.value)} className="accent-az-orange" />
|
style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', color: 'var(--text-primary)', fontSize: 12 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={point.meta.includes(p.value)}
|
||||||
|
onChange={() => toggleMeta(p.value)}
|
||||||
|
style={{ accentColor: 'var(--accent-amber)' }}
|
||||||
|
/>
|
||||||
{t(`flights.planner.${p.label}`)}
|
{t(`flights.planner.${p.label}`)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => onRemove(point.id)}
|
<button
|
||||||
className="text-az-red text-[10px] hover:underline">
|
onClick={() => onRemove(point.id)}
|
||||||
|
style={{ color: 'var(--accent-red)', fontSize: 11, background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left', textDecoration: 'none' }}
|
||||||
|
onMouseOver={e => (e.currentTarget.style.textDecoration = 'underline')}
|
||||||
|
onMouseOut={e => (e.currentTarget.style.textDecoration = 'none')}
|
||||||
|
>
|
||||||
{t('flights.planner.removePoint')}
|
{t('flights.planner.removePoint')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ interface Props {
|
|||||||
export default function MiniMap({ pointPosition }: Props) {
|
export default function MiniMap({ pointPosition }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute w-[240px] h-[180px] border border-az-border rounded shadow-lg z-[1000] overflow-hidden pointer-events-none"
|
className="absolute w-[240px] h-[180px] border border-border-hair rounded shadow-lg z-[1000] overflow-hidden pointer-events-none"
|
||||||
style={{ top: pointPosition.y, left: pointPosition.x }}
|
style={{ top: pointPosition.y, left: pointPosition.x }}
|
||||||
>
|
>
|
||||||
<MapContainer center={pointPosition.latlng} zoom={18} zoomControl={false}
|
<MapContainer center={pointPosition.latlng} zoom={18} zoomControl={false}
|
||||||
className="w-full h-full" attributionControl={false}>
|
className="w-full h-full" attributionControl={false}>
|
||||||
<TileLayer url={getTileUrl()} crossOrigin="use-credentials" />
|
<TileLayer url={getTileUrl()} crossOrigin="use-credentials" />
|
||||||
<CircleMarker center={pointPosition.latlng} radius={3} color="#fa5252" />
|
<CircleMarker center={pointPosition.latlng} radius={3} color="#FF4756" />
|
||||||
<UpdateCenter latlng={pointPosition.latlng} />
|
<UpdateCenter latlng={pointPosition.latlng} />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,25 +29,94 @@ export default function WaypointList({ points, calculatedPointInfo, onReorder, o
|
|||||||
return `${alt}${t('flights.planner.metres')} ${Math.floor(info.bat)}%${t('flights.planner.battery')} ${timeStr}`
|
return `${alt}${t('flights.planner.metres')} ${Math.floor(info.bat)}%${t('flights.planner.battery')} ${timeStr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderMarker = (index: number) => {
|
||||||
|
if (index === 0) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
background: 'var(--accent-green)',
|
||||||
|
transform: 'rotate(45deg)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (index === points.length - 1 && points.length > 1) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
background: 'var(--accent-red)',
|
||||||
|
clipPath: 'polygon(30% 0,70% 0,100% 30%,100% 70%,70% 100%,30% 100%,0 70%,0 30%)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1.5px solid var(--accent-cyan)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContext onDragEnd={handleDragEnd}>
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
<Droppable droppableId="waypoints">
|
<Droppable droppableId="waypoints">
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-0.5">
|
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-0">
|
||||||
{points.map((point, index) => (
|
{points.map((point, index) => (
|
||||||
<Draggable key={point.id} draggableId={point.id} index={index}>
|
<Draggable key={point.id} draggableId={point.id} index={index}>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}
|
<div
|
||||||
className="flex items-center justify-between bg-az-bg rounded px-1.5 py-1 text-[10px] text-az-text group">
|
ref={provided.innerRef}
|
||||||
<span>
|
{...provided.draggableProps}
|
||||||
<span className="text-az-orange font-bold mr-1">
|
{...provided.dragHandleProps}
|
||||||
{String(index + 1).padStart(2, '0')}
|
className="flex items-center gap-2.5 border-b border-border-hair mono group"
|
||||||
</span>
|
style={{ height: 30, padding: '0 4px', ...provided.draggableProps.style }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="mono text-[11px]"
|
||||||
|
style={{ color: 'var(--text-secondary)', width: 28, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{renderMarker(index)}
|
||||||
|
|
||||||
|
<span className="text-[11px] text-text-primary truncate flex-1">
|
||||||
{formatInfo(calculatedPointInfo[index], point.altitude)}
|
{formatInfo(calculatedPointInfo[index], point.altitude)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex gap-1 opacity-0 group-hover:opacity-100">
|
|
||||||
<button onClick={() => onEdit(point)} className="hover:text-az-orange">✎</button>
|
<span className="ml-auto flex gap-1 opacity-0 group-hover:opacity-100">
|
||||||
<button onClick={() => onRemove(point.id)} className="hover:text-az-red">×</button>
|
<button
|
||||||
|
onClick={() => onEdit(point)}
|
||||||
|
className="ibtn edit"
|
||||||
|
style={{ width: 22, height: 22 }}
|
||||||
|
title={t('flights.planner.edit')}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(point.id)}
|
||||||
|
className="ibtn danger"
|
||||||
|
style={{ width: 22, height: 22 }}
|
||||||
|
title={t('flights.planner.remove')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,19 +12,19 @@ export default function WindEffect({ wind, onChange }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.windDirection')}</label>
|
<label className="micro block mb-0.5">{t('flights.planner.windDirection')}</label>
|
||||||
<input type="number" min={0} max={360}
|
<input type="number" min={0} max={360}
|
||||||
value={wind.direction}
|
value={wind.direction}
|
||||||
onChange={e => onChange({ ...wind, direction: Number(e.target.value) })}
|
onChange={e => onChange({ ...wind, direction: Number(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"
|
className="inp inp-mono w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="text-az-muted block mb-0.5 text-[9px]">{t('flights.planner.windSpeed')}</label>
|
<label className="micro block mb-0.5">{t('flights.planner.windSpeed')}</label>
|
||||||
<input type="number" min={0}
|
<input type="number" min={0}
|
||||||
value={wind.speed}
|
value={wind.speed}
|
||||||
onChange={e => onChange({ ...wind, speed: Number(e.target.value) })}
|
onChange={e => onChange({ ...wind, speed: Number(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"
|
className="inp inp-mono w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ vi.mock('leaflet/dist/leaflet.css', () => ({}))
|
|||||||
vi.mock('leaflet-polylinedecorator', () => ({}))
|
vi.mock('leaflet-polylinedecorator', () => ({}))
|
||||||
vi.mock('../DrawControl', () => ({ default: () => null }))
|
vi.mock('../DrawControl', () => ({ default: () => null }))
|
||||||
vi.mock('../MapPoint', () => ({ default: () => null }))
|
vi.mock('../MapPoint', () => ({ default: () => null }))
|
||||||
vi.mock('../mapIcons', () => ({ defaultIcon: {} }))
|
vi.mock('../mapIcons', () => ({ currentPositionIcon: {} }))
|
||||||
|
|
||||||
import FlightMap from '../FlightMap'
|
import FlightMap from '../FlightMap'
|
||||||
import MiniMap from '../MiniMap'
|
import MiniMap from '../MiniMap'
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ActionMode } from './types'
|
||||||
|
|
||||||
|
export type DrawAccent = 'amber' | 'green' | 'red'
|
||||||
|
|
||||||
|
/** Accent color + active-state tint per draw mode. Shared by the collapsed rail
|
||||||
|
* (FlightsPage) and the expanded draw-mode selector (FlightParamsPanel). */
|
||||||
|
export const DRAW_MODE_ACCENT: Record<DrawAccent, { color: string; tint: string }> = {
|
||||||
|
amber: { color: 'var(--accent-amber)', tint: 'rgba(255,157,61,0.20)' },
|
||||||
|
green: { color: 'var(--accent-green)', tint: 'rgba(61,220,132,0.18)' },
|
||||||
|
red: { color: 'var(--accent-red)', tint: 'rgba(255,71,86,0.18)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single source of truth for the three flight-plan draw modes: the action mode,
|
||||||
|
* its i18n label key, accent, and icon. Consumed by both the icon-only collapsed
|
||||||
|
* rail and the labelled expanded selector. */
|
||||||
|
export const DRAW_MODES: { mode: ActionMode; i18nKey: string; accent: DrawAccent; icon: React.ReactNode }[] = [
|
||||||
|
{
|
||||||
|
mode: 'points', i18nKey: 'flights.v2.points', accent: 'amber',
|
||||||
|
icon: <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="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>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'workArea', i18nKey: 'flights.v2.workArea', accent: 'green',
|
||||||
|
icon: <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="4 7 12 3 20 7 20 17 12 21 4 17" /></svg>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'prohibitedArea', i18nKey: 'flights.v2.noGoZone', accent: 'red',
|
||||||
|
icon: <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><line x1="5.6" y1="5.6" x2="18.4" y2="18.4" /></svg>,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,23 +1,45 @@
|
|||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import markerIcon from 'leaflet/dist/images/marker-icon.png'
|
|
||||||
|
|
||||||
function pinIcon(color: string) {
|
// v2 waypoint glyphs — match the map legend shapes exactly:
|
||||||
|
// start → green diamond (.wp-diamond)
|
||||||
|
// middle → cyan-bordered square (.wp-square)
|
||||||
|
// finish → red octagon (.wp-octagon)
|
||||||
|
function glyphIcon(html: string, size: number) {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="24" height="24" fill="${color}"><path d="M384 192c0 87.4-117 243-168.3 307.2a24 24 0 0 1-47.4 0C117 435 0 279.4 0 192 0 86 86 0 192 0s192 86 192 192z"/></svg>`,
|
html: `<div style="display:flex;align-items:center;justify-content:center;width:${size}px;height:${size}px;">${html}</div>`,
|
||||||
iconSize: [24, 24],
|
iconSize: [size, size],
|
||||||
iconAnchor: [12, 24],
|
iconAnchor: [size / 2, size / 2],
|
||||||
popupAnchor: [0, -24],
|
popupAnchor: [0, -(size / 2) - 2],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pointIconGreen = pinIcon('#1ed013')
|
export const wpStartIcon = glyphIcon(
|
||||||
export const pointIconBlue = pinIcon('#228be6')
|
`<div style="width:14px;height:14px;background:#3DDC84;border:1.5px solid #0A0D10;box-shadow:0 0 0 1px #3DDC84;transform:rotate(45deg);"></div>`,
|
||||||
export const pointIconRed = pinIcon('#fa5252')
|
20,
|
||||||
|
)
|
||||||
|
export const wpMidIcon = glyphIcon(
|
||||||
|
`<div style="width:12px;height:12px;background:#0A0D10;border:1.5px solid #36D6C5;"></div>`,
|
||||||
|
16,
|
||||||
|
)
|
||||||
|
export const wpFinishIcon = glyphIcon(
|
||||||
|
`<div style="width:16px;height:16px;background:#FF4756;clip-path:polygon(30% 0,70% 0,100% 30%,100% 70%,70% 100%,30% 100%,0 70%,0 30%);"></div>`,
|
||||||
|
18,
|
||||||
|
)
|
||||||
|
|
||||||
export const defaultIcon = new L.Icon({
|
// v2 current-position beacon: amber center dot with an expanding pulse ring.
|
||||||
iconUrl: markerIcon,
|
// Self-contained SVG/SMIL animation so it needs no global CSS keyframes.
|
||||||
iconSize: [25, 41],
|
export const currentPositionIcon = L.divIcon({
|
||||||
iconAnchor: [12, 41],
|
className: '',
|
||||||
popupAnchor: [1, -34],
|
html: `<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 34 34">
|
||||||
|
<circle cx="17" cy="17" r="5" fill="none" stroke="#FF9D3D" stroke-width="1.5">
|
||||||
|
<animate attributeName="r" values="5;15" dur="1.6s" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="opacity" values="0.7;0" dur="1.6s" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
<circle cx="17" cy="17" r="8" fill="none" stroke="#FF9D3D" stroke-width="1" opacity="0.45"/>
|
||||||
|
<circle cx="17" cy="17" r="4" fill="#FF9D3D" stroke="#0A0D10" stroke-width="1"/>
|
||||||
|
</svg>`,
|
||||||
|
iconSize: [34, 34],
|
||||||
|
iconAnchor: [17, 17],
|
||||||
|
popupAnchor: [0, -17],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ export interface WindParams {
|
|||||||
speed: number
|
speed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Local-only orthophoto entry for the GPS-Denied upload list. There is no
|
||||||
|
// backend endpoint for orthophoto upload yet, so this lives entirely in
|
||||||
|
// component state (see GpsDeniedPanel / FlightsPage).
|
||||||
|
export interface OrthoPhoto {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface MovingPointInfo {
|
export interface MovingPointInfo {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
|
|||||||
@@ -1,106 +1,727 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo, type ReactNode } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { api, endpoints } from '../../api'
|
import { api, endpoints } from '../../api'
|
||||||
|
import { useAuth } from '../../auth'
|
||||||
|
import { LANG_STORAGE_KEY } from '../../i18n'
|
||||||
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
|
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
|
||||||
|
import { Modal } from '../admin/Modal'
|
||||||
|
|
||||||
|
type Lang = 'en' | 'ua'
|
||||||
|
const I18N_BUNDLE_VERSION = 'v2.4.1'
|
||||||
|
const DASH = '—'
|
||||||
|
|
||||||
|
type AircraftDraft = {
|
||||||
|
model: string
|
||||||
|
type: Aircraft['type']
|
||||||
|
resolution: string
|
||||||
|
maxMinutes: number
|
||||||
|
isDefault: boolean
|
||||||
|
}
|
||||||
|
const NEW_AIRCRAFT_DEFAULTS: AircraftDraft = {
|
||||||
|
model: '', type: 'Copter', resolution: '4K', maxMinutes: 30, isDefault: false,
|
||||||
|
}
|
||||||
|
const AIRCRAFT_TYPES = ['Plane', 'Copter', 'FixedWing'] as const
|
||||||
|
const RESOLUTIONS = ['HD', '1080P', '4K', '6K'] as const
|
||||||
|
const TYPE_LEGEND_KEY: Record<Aircraft['type'], 'legendPlane' | 'legendCopter' | 'legendFixedW'> = {
|
||||||
|
Plane: 'legendPlane', Copter: 'legendCopter', FixedWing: 'legendFixedW',
|
||||||
|
}
|
||||||
|
const TYPE_CHIP_COLOR: Record<Aircraft['type'], string> = {
|
||||||
|
Plane: 'var(--accent-blue)',
|
||||||
|
Copter: 'var(--accent-green)',
|
||||||
|
FixedWing: 'var(--accent-amber)',
|
||||||
|
}
|
||||||
|
const TYPE_CHIP_BORDER: Record<Aircraft['type'], string> = {
|
||||||
|
Plane: 'rgba(78,158,255,0.45)',
|
||||||
|
Copter: 'rgba(61,220,132,0.45)',
|
||||||
|
FixedWing: 'rgba(255,157,61,0.45)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.4l1.6 2H19.5A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-11Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function SignOutIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function CheckIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirtyTenant(a: SystemSettings | null, b: SystemSettings | null): boolean {
|
||||||
|
if (!a || !b) return false
|
||||||
|
return (
|
||||||
|
a.militaryUnit !== b.militaryUnit ||
|
||||||
|
a.name !== b.name ||
|
||||||
|
a.defaultCameraWidth !== b.defaultCameraWidth ||
|
||||||
|
a.defaultCameraFoV !== b.defaultCameraFoV
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirtyDirs(a: DirectorySettings | null, b: DirectorySettings | null): boolean {
|
||||||
|
if (!a || !b) return false
|
||||||
|
return (
|
||||||
|
a.imagesDir !== b.imagesDir ||
|
||||||
|
a.labelsDir !== b.labelsDir ||
|
||||||
|
a.thumbnailsDir !== b.thumbnailsDir
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { t } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [system, setSystem] = useState<SystemSettings | null>(null)
|
const [system, setSystem] = useState<SystemSettings | null>(null)
|
||||||
|
const [systemInitial, setSystemInitial] = useState<SystemSettings | null>(null)
|
||||||
const [dirs, setDirs] = useState<DirectorySettings | null>(null)
|
const [dirs, setDirs] = useState<DirectorySettings | null>(null)
|
||||||
|
const [dirsInitial, setDirsInitial] = useState<DirectorySettings | null>(null)
|
||||||
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
const lang: Lang = i18n.language === 'ua' ? 'ua' : 'en'
|
||||||
|
|
||||||
|
const [aircraftModalOpen, setAircraftModalOpen] = useState(false)
|
||||||
|
const [aircraftDraft, setAircraftDraft] = useState<AircraftDraft>(NEW_AIRCRAFT_DEFAULTS)
|
||||||
|
const [aircraftSaving, setAircraftSaving] = useState(false)
|
||||||
|
const [aircraftError, setAircraftError] = useState<'modelRequired' | 'saveFailed' | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(setSystem).catch(() => {})
|
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(s => {
|
||||||
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(setDirs).catch(() => {})
|
setSystem(s)
|
||||||
|
setSystemInitial(s)
|
||||||
|
}).catch(() => {})
|
||||||
|
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(d => {
|
||||||
|
setDirs(d)
|
||||||
|
setDirsInitial(d)
|
||||||
|
}).catch(() => {})
|
||||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const saveSystem = async () => {
|
const tenantDirty = useMemo(() => dirtyTenant(system, systemInitial), [system, systemInitial])
|
||||||
if (!system) return
|
const dirsDirty = useMemo(() => dirtyDirs(dirs, dirsInitial), [dirs, dirsInitial])
|
||||||
|
const anyDirty = tenantDirty || dirsDirty
|
||||||
|
|
||||||
|
const dirtyLabel = useMemo(() => {
|
||||||
|
if (tenantDirty && dirsDirty) return `${t('settings.unitTenant')} · ${t('settings.unitDirectories')}`
|
||||||
|
if (tenantDirty) return t('settings.unitTenant')
|
||||||
|
if (dirsDirty) return t('settings.unitDirectories')
|
||||||
|
return ''
|
||||||
|
}, [tenantDirty, dirsDirty, t])
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
await api.put(endpoints.annotations.settingsSystem(), system)
|
setSaveError(null)
|
||||||
setSaving(false)
|
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 () => {
|
const cancel = () => {
|
||||||
if (!dirs) return
|
setSystem(systemInitial)
|
||||||
setSaving(true)
|
setDirs(dirsInitial)
|
||||||
await api.put(endpoints.annotations.settingsDirectories(), dirs)
|
setSaveError(null)
|
||||||
setSaving(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleDefault = async (a: Aircraft) => {
|
const handleToggleDefault = async (a: Aircraft) => {
|
||||||
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
try {
|
||||||
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
||||||
|
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||||
|
} catch {
|
||||||
|
// best-effort — keep UI consistent on failure
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const field = (label: string, value: string | number | null | undefined, onChange: (v: string) => void, type = 'text') => (
|
const changeLanguage = async (next: Lang) => {
|
||||||
|
await i18n.changeLanguage(next)
|
||||||
|
try { localStorage.setItem(LANG_STORAGE_KEY, next) } catch { /* private mode etc. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignOutEverywhere = async () => {
|
||||||
|
await logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAircraftModal = () => {
|
||||||
|
setAircraftDraft(NEW_AIRCRAFT_DEFAULTS)
|
||||||
|
setAircraftError(null)
|
||||||
|
setAircraftModalOpen(true)
|
||||||
|
}
|
||||||
|
const closeAircraftModal = () => {
|
||||||
|
if (aircraftSaving) return
|
||||||
|
setAircraftModalOpen(false)
|
||||||
|
}
|
||||||
|
const saveAircraft = async () => {
|
||||||
|
if (!aircraftDraft.model.trim()) { setAircraftError('modelRequired'); return }
|
||||||
|
setAircraftError(null)
|
||||||
|
setAircraftSaving(true)
|
||||||
|
try {
|
||||||
|
const created = await api.post<Aircraft>(endpoints.flights.aircrafts(), aircraftDraft)
|
||||||
|
setAircrafts(prev => {
|
||||||
|
if (created.isDefault) return [...prev.map(p => ({ ...p, isDefault: false })), created]
|
||||||
|
return [...prev, created]
|
||||||
|
})
|
||||||
|
setAircraftModalOpen(false)
|
||||||
|
} catch {
|
||||||
|
setAircraftError('saveFailed')
|
||||||
|
} finally {
|
||||||
|
setAircraftSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="settings-page h-full flex flex-col" style={{ background: 'var(--surface-0)' }}>
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 pt-5 pb-6 flex flex-col gap-5">
|
||||||
|
|
||||||
|
<section className="flex gap-5 items-start flex-wrap">
|
||||||
|
|
||||||
|
<div className="w-[300px] shrink-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h2 className="sect-head m-0">{t('settings.tenant')}</h2>
|
||||||
|
<span className="micro">01</span>
|
||||||
|
</div>
|
||||||
|
<BracketPanel className="p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<FieldText
|
||||||
|
label={t('settings.militaryUnit')}
|
||||||
|
hint={t('settings.required')}
|
||||||
|
value={system?.militaryUnit ?? ''}
|
||||||
|
onChange={v => setSystem(p => p ? { ...p, militaryUnit: v } : p)}
|
||||||
|
/>
|
||||||
|
<FieldText
|
||||||
|
label={t('settings.unitName')}
|
||||||
|
value={system?.name ?? ''}
|
||||||
|
onChange={v => setSystem(p => p ? { ...p, name: v } : p)}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FieldNumber
|
||||||
|
label={t('settings.camWidth')}
|
||||||
|
hint="PX"
|
||||||
|
suffix="px"
|
||||||
|
value={system?.defaultCameraWidth ?? 0}
|
||||||
|
onChange={v => setSystem(p => p ? { ...p, defaultCameraWidth: v } : p)}
|
||||||
|
/>
|
||||||
|
<FieldNumber
|
||||||
|
label={t('settings.camFoV')}
|
||||||
|
hint="DEG"
|
||||||
|
suffix="°"
|
||||||
|
step="0.1"
|
||||||
|
value={system?.defaultCameraFoV ?? 0}
|
||||||
|
onChange={v => setSystem(p => p ? { ...p, defaultCameraFoV: v } : p)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BracketPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[340px] shrink-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h2 className="sect-head m-0">{t('settings.directories')}</h2>
|
||||||
|
<span className="micro">02</span>
|
||||||
|
</div>
|
||||||
|
<BracketPanel className="p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<PathField
|
||||||
|
label={t('settings.imagesDir')}
|
||||||
|
statusLabel={t('settings.mounted')}
|
||||||
|
statusColor="var(--accent-green)"
|
||||||
|
browseLabel={t('settings.browse')}
|
||||||
|
value={dirs?.imagesDir ?? ''}
|
||||||
|
onChange={v => setDirs(p => p ? { ...p, imagesDir: v } : p)}
|
||||||
|
/>
|
||||||
|
<PathField
|
||||||
|
label={t('settings.labelsDir')}
|
||||||
|
statusLabel={t('settings.mounted')}
|
||||||
|
statusColor="var(--accent-green)"
|
||||||
|
browseLabel={t('settings.browse')}
|
||||||
|
value={dirs?.labelsDir ?? ''}
|
||||||
|
onChange={v => setDirs(p => p ? { ...p, labelsDir: v } : p)}
|
||||||
|
/>
|
||||||
|
<PathField
|
||||||
|
label={t('settings.thumbnailsDir')}
|
||||||
|
statusLabel={t('settings.cache')}
|
||||||
|
statusColor="var(--accent-amber)"
|
||||||
|
browseLabel={t('settings.browse')}
|
||||||
|
value={dirs?.thumbnailsDir ?? ''}
|
||||||
|
onChange={v => setDirs(p => p ? { ...p, thumbnailsDir: v } : p)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="mt-3 pt-3 flex items-center justify-between"
|
||||||
|
style={{ borderTop: '1px solid var(--border-hair)' }}
|
||||||
|
>
|
||||||
|
<span className="micro">{t('settings.storageFree')}</span>
|
||||||
|
<span className="mono tnum" style={{ fontSize: 11, color: 'var(--text-primary)' }}>{DASH}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BracketPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-[420px]">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="sect-head m-0">{t('settings.aircrafts')}</h2>
|
||||||
|
<span className="micro">03</span>
|
||||||
|
<span className="mono" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
||||||
|
· {aircrafts.length} {t('settings.aircraftsRegistered')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" type="button" onClick={openAircraftModal}>
|
||||||
|
<span style={{ fontSize: 14, lineHeight: 1 }}>+</span>
|
||||||
|
<span>{t('settings.addAircraft')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<BracketPanel className="overflow-hidden">
|
||||||
|
<table className="w-full" style={{ borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: 'var(--surface-1)' }}>
|
||||||
|
<th className="text-left micro" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-hair)', width: '44%', fontWeight: 500 }}>
|
||||||
|
{t('settings.colModel')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left micro" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-hair)', fontWeight: 500 }}>
|
||||||
|
{t('settings.colType')}
|
||||||
|
</th>
|
||||||
|
<th className="text-center micro" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-hair)', width: 96, fontWeight: 500 }}>
|
||||||
|
{t('settings.colDefault')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{aircrafts.map((a, idx) => (
|
||||||
|
<tr key={a.id} className="row-hover" style={{ borderBottom: idx === aircrafts.length - 1 ? 0 : '1px solid var(--border-hair)' }}>
|
||||||
|
<td className="mono" style={{ padding: '0 14px', height: 38, fontSize: 12, color: 'var(--text-primary)' }}>{a.model}</td>
|
||||||
|
<td style={{ padding: '0 14px', height: 38 }}>
|
||||||
|
<AircraftTypeChip type={a.type} label={t(`admin.aircrafts.${TYPE_LEGEND_KEY[a.type]}`)} />
|
||||||
|
</td>
|
||||||
|
<td className="text-center" style={{ padding: '0 14px', height: 38 }}>
|
||||||
|
<StarButton
|
||||||
|
active={a.isDefault}
|
||||||
|
onClick={() => void handleToggleDefault(a)}
|
||||||
|
aria-label={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')}
|
||||||
|
title={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{aircrafts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="micro text-center" style={{ padding: '24px 14px' }}>{DASH}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</BracketPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex gap-5 items-start flex-wrap">
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-[420px]">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="sect-head m-0">{t('settings.language')}</h2>
|
||||||
|
<span className="micro">04</span>
|
||||||
|
</div>
|
||||||
|
<span className="micro">
|
||||||
|
{t('settings.locale')} · <span style={{ color: 'var(--text-primary)' }}>{lang === 'ua' ? 'UK-UA' : 'EN-US'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<BracketPanel className="p-4">
|
||||||
|
<div className="flex items-center gap-6 flex-wrap">
|
||||||
|
<div className="seg" role="group" aria-label={t('settings.language')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void changeLanguage('en')}
|
||||||
|
className={`seg-btn${lang === 'en' ? ' active' : ''}`}
|
||||||
|
aria-pressed={lang === 'en'}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void changeLanguage('ua')}
|
||||||
|
className={`seg-btn${lang === 'ua' ? ' active' : ''}`}
|
||||||
|
aria-pressed={lang === 'ua'}
|
||||||
|
>
|
||||||
|
UA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="micro">{t('settings.languageHint')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||||
|
{t('settings.languageNote')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2 mono" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
||||||
|
<span
|
||||||
|
className="dot live"
|
||||||
|
style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-green)' }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t('settings.languageBundle')}{' '}
|
||||||
|
<span className="tnum" style={{ color: 'var(--text-secondary)' }}>{I18N_BUNDLE_VERSION}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BracketPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[380px] shrink-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="sect-head m-0">{t('settings.session')}</h2>
|
||||||
|
<span className="micro">05</span>
|
||||||
|
</div>
|
||||||
|
<span className="micro" style={{ color: 'var(--accent-cyan)' }}>{t('settings.sessionActive')}</span>
|
||||||
|
</div>
|
||||||
|
<BracketPanel className="p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="micro">{t('settings.lastLogin')}</span>
|
||||||
|
<span className="mono tnum" style={{ fontSize: 12, color: 'var(--text-primary)', marginTop: 4 }}>
|
||||||
|
{DASH}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="mono truncate"
|
||||||
|
style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2 }}
|
||||||
|
>
|
||||||
|
{user?.email ?? DASH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSignOutEverywhere()}
|
||||||
|
className="btn btn-danger-ghost shrink-0"
|
||||||
|
>
|
||||||
|
<SignOutIcon />
|
||||||
|
{t('settings.signOutEverywhere')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</BracketPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="shrink-0 px-6 pb-6"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(180deg, rgba(10,13,16,0) 0%, var(--surface-0) 50%)',
|
||||||
|
paddingTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-4 pt-4"
|
||||||
|
style={{ borderTop: '1px solid var(--border-hair)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mono uppercase" style={{ fontSize: 10, color: 'var(--text-muted)', letterSpacing: '0.14em' }}>
|
||||||
|
{anyDirty ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="dot live"
|
||||||
|
style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-cyan)' }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t('settings.unsavedChanges')}{' '}
|
||||||
|
<span style={{ color: 'var(--accent-amber)' }}>{dirtyLabel}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{saveError && (
|
||||||
|
<div role="alert" className="micro" style={{ color: 'var(--accent-red)', textTransform: 'none', letterSpacing: 0 }}>
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={cancel}
|
||||||
|
disabled={saving || !anyDirty}
|
||||||
|
>
|
||||||
|
{t('settings.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => void save()}
|
||||||
|
disabled={saving || !anyDirty}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
{t('settings.saveChanges')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={aircraftModalOpen}
|
||||||
|
title={t('admin.aircrafts.addTitle')}
|
||||||
|
onClose={closeAircraftModal}
|
||||||
|
closeLabel={t('admin.classes.cancel')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={closeAircraftModal}
|
||||||
|
disabled={aircraftSaving}
|
||||||
|
>
|
||||||
|
{t('admin.classes.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => void saveAircraft()}
|
||||||
|
disabled={aircraftSaving}
|
||||||
|
>
|
||||||
|
{t('admin.aircrafts.addTitle')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldModel')}</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="inp inp-mono"
|
||||||
|
value={aircraftDraft.model}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, model: e.target.value }))}
|
||||||
|
placeholder="DJI Mavic 3"
|
||||||
|
aria-label={t('admin.aircrafts.fieldModel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldType')}</label>
|
||||||
|
<div className="seg" role="group" aria-label={t('admin.aircrafts.fieldType')}>
|
||||||
|
{AIRCRAFT_TYPES.map(typ => (
|
||||||
|
<button
|
||||||
|
key={typ}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAircraftDraft(p => ({ ...p, type: typ }))}
|
||||||
|
className={`seg-btn${aircraftDraft.type === typ ? ' active' : ''}`}
|
||||||
|
aria-pressed={aircraftDraft.type === typ}
|
||||||
|
>
|
||||||
|
{t(`admin.aircrafts.${TYPE_LEGEND_KEY[typ]}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldResolution')}</label>
|
||||||
|
<select
|
||||||
|
className="inp inp-mono"
|
||||||
|
value={aircraftDraft.resolution}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, resolution: e.target.value }))}
|
||||||
|
aria-label={t('admin.aircrafts.fieldResolution')}
|
||||||
|
>
|
||||||
|
{RESOLUTIONS.map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldMaxMinutes')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="inp inp-mono"
|
||||||
|
value={aircraftDraft.maxMinutes}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, maxMinutes: Number(e.target.value) }))}
|
||||||
|
style={{ textAlign: 'right' }}
|
||||||
|
aria-label={t('admin.aircrafts.fieldMaxMinutes')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={aircraftDraft.isDefault}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, isDefault: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span>{t('admin.aircrafts.fieldDefault')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{aircraftError && (
|
||||||
|
<div role="alert" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
|
||||||
|
{t(`admin.aircrafts.${aircraftError}`)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Sub-components =====
|
||||||
|
|
||||||
|
function BracketPanel({ className, children }: { className?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={className ? `bracket panel ${className}` : 'bracket panel'}>
|
||||||
|
<span className="br" />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({ label, hint, hintColor }: { label: string; hint?: string; hintColor?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="micro">{label}</label>
|
||||||
|
{hint && (
|
||||||
|
<span className="mono" style={{ fontSize: 9, color: hintColor ?? 'var(--text-muted)' }}>{hint}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldText({
|
||||||
|
label, hint, value, onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted text-xs block mb-0.5">{label}</label>
|
<FieldLabel label={label} hint={hint} />
|
||||||
<input
|
<input
|
||||||
type={type}
|
className="inp"
|
||||||
value={value ?? ''}
|
type="text"
|
||||||
|
value={value}
|
||||||
onChange={e => onChange(e.target.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>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldNumber({
|
||||||
|
label, hint, suffix, value, onChange, step,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
suffix: string
|
||||||
|
value: number
|
||||||
|
onChange: (v: number) => void
|
||||||
|
step?: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full overflow-y-auto p-4 gap-6">
|
<div>
|
||||||
{/* Tenant config */}
|
<FieldLabel label={label} hint={hint} />
|
||||||
<div className="w-[300px] shrink-0">
|
<div className="relative">
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.tenant')}</h2>
|
<input
|
||||||
{system && (
|
className="inp inp-mono"
|
||||||
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2">
|
type="number"
|
||||||
{field('Military Unit', system.militaryUnit, v => setSystem(p => p ? { ...p, militaryUnit: v } : p))}
|
step={step}
|
||||||
{field('Name', system.name, v => setSystem(p => p ? { ...p, name: v } : p))}
|
value={value}
|
||||||
{field('Default Camera Width', system.defaultCameraWidth, v => setSystem(p => p ? { ...p, defaultCameraWidth: parseInt(v) || 0 } : p), 'number')}
|
onChange={e => onChange(step ? parseFloat(e.target.value) || 0 : parseInt(e.target.value) || 0)}
|
||||||
{field('Default Camera FoV', system.defaultCameraFoV, v => setSystem(p => p ? { ...p, defaultCameraFoV: parseFloat(v) || 0 } : p), 'number')}
|
aria-label={label}
|
||||||
<button onClick={saveSystem} disabled={saving} className="bg-az-orange text-white text-xs px-3 py-1 rounded disabled:opacity-50">
|
style={{ paddingRight: 36 }}
|
||||||
{t('settings.save')}
|
/>
|
||||||
</button>
|
<span
|
||||||
</div>
|
className="mono"
|
||||||
)}
|
style={{
|
||||||
</div>
|
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
|
||||||
|
fontSize: 11, color: 'var(--text-muted)', pointerEvents: 'none',
|
||||||
{/* Directories */}
|
}}
|
||||||
<div className="w-[300px] shrink-0">
|
>
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.directories')}</h2>
|
{suffix}
|
||||||
{dirs && (
|
</span>
|
||||||
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2">
|
|
||||||
{field('Videos Dir', dirs.videosDir, v => setDirs(p => p ? { ...p, videosDir: v } : p))}
|
|
||||||
{field('Images Dir', dirs.imagesDir, v => setDirs(p => p ? { ...p, imagesDir: v } : p))}
|
|
||||||
{field('Labels Dir', dirs.labelsDir, v => setDirs(p => p ? { ...p, labelsDir: v } : p))}
|
|
||||||
{field('Results Dir', dirs.resultsDir, v => setDirs(p => p ? { ...p, resultsDir: v } : p))}
|
|
||||||
{field('Thumbnails Dir', dirs.thumbnailsDir, v => setDirs(p => p ? { ...p, thumbnailsDir: v } : p))}
|
|
||||||
{field('GPS Sat Dir', dirs.gpsSatDir, v => setDirs(p => p ? { ...p, gpsSatDir: v } : p))}
|
|
||||||
{field('GPS Route Dir', dirs.gpsRouteDir, v => setDirs(p => p ? { ...p, gpsRouteDir: v } : p))}
|
|
||||||
<button onClick={saveDirs} disabled={saving} className="bg-az-orange text-white text-xs px-3 py-1 rounded disabled:opacity-50">
|
|
||||||
{t('settings.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Aircrafts */}
|
|
||||||
<div className="flex-1 max-w-sm">
|
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.aircrafts')}</h2>
|
|
||||||
<div className="bg-az-panel border border-az-border rounded p-2 space-y-1">
|
|
||||||
{aircrafts.map(a => (
|
|
||||||
<div key={a.id} className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-az-bg text-xs text-az-text">
|
|
||||||
<span className="flex-1">{a.model}</span>
|
|
||||||
<span className={`px-1 rounded text-[10px] ${a.type === 'Plane' ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
|
|
||||||
{a.type}
|
|
||||||
</span>
|
|
||||||
<button onClick={() => handleToggleDefault(a)} className={`text-sm ${a.isDefault ? 'text-az-orange' : 'text-az-muted hover:text-az-orange'}`}>
|
|
||||||
★
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+223
-13
@@ -2,7 +2,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"flights": "Flights",
|
"flights": "Flights",
|
||||||
"annotations": "Annotations",
|
"annotations": "Annotations",
|
||||||
"dataset": "Dataset Explorer",
|
"dataset": "Dataset",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"logout": "Logout"
|
"logout": "Logout"
|
||||||
@@ -34,6 +34,81 @@
|
|||||||
"correction": "GPS Correction",
|
"correction": "GPS Correction",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"telemetry": "Telemetry",
|
"telemetry": "Telemetry",
|
||||||
|
"v2": {
|
||||||
|
"roster": "Flight Roster",
|
||||||
|
"search": "Search flights",
|
||||||
|
"draft": "Draft",
|
||||||
|
"createNew": "Create New",
|
||||||
|
"missionConfig": "Mission Config",
|
||||||
|
"drawMode": "Draw Mode",
|
||||||
|
"clickToPlot": "click map to plot",
|
||||||
|
"points": "Points",
|
||||||
|
"workArea": "Work Area",
|
||||||
|
"noGoZone": "No-Go Zone",
|
||||||
|
"aircraft": "Aircraft",
|
||||||
|
"defaultHeight": "Default Height",
|
||||||
|
"focalLength": "Focal Length",
|
||||||
|
"commAddr": "Comm Address / Port",
|
||||||
|
"pts": "PTS",
|
||||||
|
"wpStart": "START",
|
||||||
|
"wpFinish": "FINISH",
|
||||||
|
"tagOrigin": "ORIGIN",
|
||||||
|
"tagTrack": "TRACK",
|
||||||
|
"tagConfirm": "CONFIRM",
|
||||||
|
"tagTarget": "TARGET",
|
||||||
|
"tagMilVeh": "MIL-VEH",
|
||||||
|
"flightParams": "Flight Params",
|
||||||
|
"gpsDenied": "GPS-Denied",
|
||||||
|
"gpsDeniedActive": "GPS-Denied // Active",
|
||||||
|
"orthophotoUpload": "Orthophoto Upload",
|
||||||
|
"uploadPhotos": "Upload Photos",
|
||||||
|
"liveGps": "Live GPS",
|
||||||
|
"connected": "CONNECTED",
|
||||||
|
"connectedStreaming": "CONNECTED · STREAMING",
|
||||||
|
"active": "Active",
|
||||||
|
"offline": "Offline",
|
||||||
|
"status": "Status",
|
||||||
|
"latitude": "Latitude",
|
||||||
|
"longitude": "Longitude",
|
||||||
|
"satellites": "Satellites",
|
||||||
|
"drift": "Drift",
|
||||||
|
"gpsCorrection": "GPS Correction",
|
||||||
|
"waypointNum": "Waypoint #",
|
||||||
|
"correctedGps": "Corrected GPS",
|
||||||
|
"applyCorrection": "Apply Correction",
|
||||||
|
"backToParams": "Back to Flight Params",
|
||||||
|
"upload": "Upload",
|
||||||
|
"expandParams": "Expand parameters",
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"date": "Date",
|
||||||
|
"drawHintWork": "Click and drag on the map to draw a work area",
|
||||||
|
"drawHintNoGo": "Click and drag on the map to draw a no-go zone",
|
||||||
|
"hud": {
|
||||||
|
"liveConnected": "LIVE · CONNECTED",
|
||||||
|
"sat": "Sat",
|
||||||
|
"lat": "Lat",
|
||||||
|
"lon": "Lon",
|
||||||
|
"alt": "Alt",
|
||||||
|
"hdg": "Hdg",
|
||||||
|
"spd": "Spd",
|
||||||
|
"link": "Link",
|
||||||
|
"mapLegend": "Map Legend",
|
||||||
|
"plannedOriginal": "Planned · Original",
|
||||||
|
"correctedLive": "Corrected · Live",
|
||||||
|
"originStart": "Origin / Start",
|
||||||
|
"waypoint": "Waypoint",
|
||||||
|
"targetFinish": "Target / Finish",
|
||||||
|
"zoomIn": "Zoom in",
|
||||||
|
"zoomOut": "Zoom out",
|
||||||
|
"recenter": "Recenter",
|
||||||
|
"layers": "Layers"
|
||||||
|
},
|
||||||
|
"strip": {
|
||||||
|
"telemetryLive": "TELEMETRY · LIVE",
|
||||||
|
"frame": "FRAME",
|
||||||
|
"lastPing": "LAST PING"
|
||||||
|
}
|
||||||
|
},
|
||||||
"planner": {
|
"planner": {
|
||||||
"point": "Point",
|
"point": "Point",
|
||||||
"altitude": "Altitude",
|
"altitude": "Altitude",
|
||||||
@@ -58,6 +133,8 @@
|
|||||||
"submitAdd": "Add Point",
|
"submitAdd": "Add Point",
|
||||||
"submitEdit": "Save Changes",
|
"submitEdit": "Save Changes",
|
||||||
"removePoint": "Delete",
|
"removePoint": "Delete",
|
||||||
|
"edit": "Edit point",
|
||||||
|
"remove": "Remove point",
|
||||||
"windSpeed": "Wind spd",
|
"windSpeed": "Wind spd",
|
||||||
"windDirection": "Wind dir",
|
"windDirection": "Wind dir",
|
||||||
"setWind": "Set Wind",
|
"setWind": "Set Wind",
|
||||||
@@ -85,18 +162,47 @@
|
|||||||
},
|
},
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Annotations",
|
"title": "Annotations",
|
||||||
"mediaList": "Media",
|
"mediaList": "Media Files",
|
||||||
|
"filterByName": "filter by name…",
|
||||||
"upload": "Upload Files",
|
"upload": "Upload Files",
|
||||||
"deleteMedia": "Delete media?",
|
"deleteMedia": "Delete media?",
|
||||||
"detect": "AI Detect",
|
"detect": "AI Detect",
|
||||||
|
"detectInProgress": "AI DETECTION IN PROGRESS",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteAll": "Delete All",
|
"deleteAll": "Delete All",
|
||||||
|
"deleteAllTitle": "Delete all on frame",
|
||||||
"classes": "Detection Classes",
|
"classes": "Detection Classes",
|
||||||
"photoMode": "Photo Mode",
|
"photoMode": "PhotoMode",
|
||||||
"regular": "Regular",
|
"regular": "Regular",
|
||||||
"winter": "Winter",
|
"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": {
|
"dataset": {
|
||||||
"title": "Dataset Explorer",
|
"title": "Dataset Explorer",
|
||||||
@@ -104,38 +210,142 @@
|
|||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
"classDistribution": "Class Distribution",
|
"classDistribution": "Class Distribution",
|
||||||
"objectsOnly": "Show with objects only",
|
"objectsOnly": "Show with objects only",
|
||||||
"search": "Search...",
|
"hideEmpty": "Hide empty frames",
|
||||||
|
"search": "Search annotation name…",
|
||||||
"validate": "Validate",
|
"validate": "Validate",
|
||||||
|
"edit": "Edit",
|
||||||
|
"filters": "Filters",
|
||||||
|
"total": "Total",
|
||||||
|
"validatedCount": "Validated",
|
||||||
|
"range": "Range",
|
||||||
|
"flight": "Flight",
|
||||||
|
"showing": "Showing",
|
||||||
|
"liveSync": "Live sync",
|
||||||
|
"selected": "Selected",
|
||||||
|
"refreshThumbnails": "Refresh Thumbnails",
|
||||||
|
"ofSelected": "{{count}} of {{total}} selected",
|
||||||
|
"local": "Local",
|
||||||
|
"sort": "Sort",
|
||||||
|
"gridDensity": "Grid density",
|
||||||
|
"statusLabel": "Status",
|
||||||
"status": {
|
"status": {
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"edited": "Edited",
|
"edited": "Edited",
|
||||||
"validated": "Validated"
|
"validated": "Validated",
|
||||||
|
"all": "All",
|
||||||
|
"none": "None"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Admin",
|
"title": "Admin",
|
||||||
"classes": {
|
"classes": {
|
||||||
"title": "Detection Classes",
|
"title": "Detection Classes",
|
||||||
|
"search": "Search class…",
|
||||||
|
"add": "+ ADD",
|
||||||
|
"colName": "Name",
|
||||||
|
"colHex": "Hex",
|
||||||
|
"colOps": "Ops",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"nameRequired": "Name is required",
|
"nameRequired": "Name is required",
|
||||||
"maxSizeMustBePositive": "Max size must be a positive number",
|
"maxSizeMustBePositive": "Max size must be a positive number",
|
||||||
"updateFailed": "Update failed. Please try again."
|
"updateFailed": "Update failed. Please try again."
|
||||||
},
|
},
|
||||||
"aiSettings": "AI Recognition Settings",
|
"aiEngine": {
|
||||||
"gpsSettings": "GPS Device Settings",
|
"title": "AI Recognition Engine",
|
||||||
"aircrafts": "Default Aircrafts",
|
"subtitle": "Detection model runtime parameters. Applied per-flight, hot-reloaded.",
|
||||||
"users": "User Management",
|
"framesToRecognize": "Frames To Recognize",
|
||||||
"addUser": "Add User",
|
"framesHint": "Number of consecutive frames the model averages before emitting a detection.",
|
||||||
"deactivate": "Deactivate"
|
"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": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"tenant": "Tenant Configuration",
|
"tenant": "Tenant Configuration",
|
||||||
"directories": "Directories",
|
"directories": "Directories",
|
||||||
"aircrafts": "Aircrafts",
|
"aircrafts": "Aircrafts",
|
||||||
"save": "Save"
|
"save": "Save",
|
||||||
|
"militaryUnit": "Military Unit",
|
||||||
|
"unitName": "Name",
|
||||||
|
"camWidth": "Cam Width",
|
||||||
|
"camFoV": "Cam FoV",
|
||||||
|
"required": "REQ",
|
||||||
|
"imagesDir": "Images Dir",
|
||||||
|
"labelsDir": "Labels Dir",
|
||||||
|
"thumbnailsDir": "Thumbnails Dir",
|
||||||
|
"mounted": "MOUNTED",
|
||||||
|
"cache": "CACHE",
|
||||||
|
"browse": "Browse",
|
||||||
|
"storageFree": "Storage Free",
|
||||||
|
"aircraftsRegistered": "REGISTERED",
|
||||||
|
"addAircraft": "Add Aircraft",
|
||||||
|
"colModel": "Model",
|
||||||
|
"colType": "Type",
|
||||||
|
"colDefault": "Default",
|
||||||
|
"language": "Language",
|
||||||
|
"languageHint": "Affects all UI text",
|
||||||
|
"languageNote": "Detection class names also use the localized field from seed data.",
|
||||||
|
"languageBundle": "i18n BUNDLE",
|
||||||
|
"locale": "Locale",
|
||||||
|
"session": "Session",
|
||||||
|
"sessionActive": "ACTIVE",
|
||||||
|
"lastLogin": "Last Login",
|
||||||
|
"signOutEverywhere": "Sign out everywhere",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"saveChanges": "Save Changes",
|
||||||
|
"saveError": "Save failed. Please try again.",
|
||||||
|
"unsavedChanges": "Unsaved changes detected in",
|
||||||
|
"unitTenant": "TENANT",
|
||||||
|
"unitDirectories": "DIRECTORIES"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
|||||||
+13
-1
@@ -3,9 +3,21 @@ import { initReactI18next } from 'react-i18next'
|
|||||||
import en from './en.json'
|
import en from './en.json'
|
||||||
import ua from './ua.json'
|
import ua from './ua.json'
|
||||||
|
|
||||||
|
export const LANG_STORAGE_KEY = 'azaion.lang'
|
||||||
|
|
||||||
|
function readPersistedLanguage(): 'en' | 'ua' {
|
||||||
|
// Safari private mode throws on localStorage access — fall back to 'en'.
|
||||||
|
try {
|
||||||
|
const persisted = localStorage.getItem(LANG_STORAGE_KEY)
|
||||||
|
return persisted === 'ua' || persisted === 'en' ? persisted : 'en'
|
||||||
|
} catch {
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
resources: { en: { translation: en }, ua: { translation: ua } },
|
resources: { en: { translation: en }, ua: { translation: ua } },
|
||||||
lng: 'en',
|
lng: readPersistedLanguage(),
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false },
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export { default } from './i18n'
|
export { default, LANG_STORAGE_KEY } from './i18n'
|
||||||
|
|||||||
+223
-11
@@ -34,6 +34,81 @@
|
|||||||
"correction": "Корекція GPS",
|
"correction": "Корекція GPS",
|
||||||
"apply": "Застосувати",
|
"apply": "Застосувати",
|
||||||
"telemetry": "Телеметрія",
|
"telemetry": "Телеметрія",
|
||||||
|
"v2": {
|
||||||
|
"roster": "Реєстр польотів",
|
||||||
|
"search": "Пошук польотів",
|
||||||
|
"draft": "Чернетка",
|
||||||
|
"createNew": "Створити новий",
|
||||||
|
"missionConfig": "Конфігурація місії",
|
||||||
|
"drawMode": "Режим малювання",
|
||||||
|
"clickToPlot": "клікніть на карту",
|
||||||
|
"points": "Точки",
|
||||||
|
"workArea": "Робоча зона",
|
||||||
|
"noGoZone": "Заборонена зона",
|
||||||
|
"aircraft": "Літальний апарат",
|
||||||
|
"defaultHeight": "Висота за замовч.",
|
||||||
|
"focalLength": "Фокусна відстань",
|
||||||
|
"commAddr": "Адреса / Порт зв'язку",
|
||||||
|
"pts": "ТЧК",
|
||||||
|
"wpStart": "СТАРТ",
|
||||||
|
"wpFinish": "ФІНІШ",
|
||||||
|
"tagOrigin": "ПОЧАТОК",
|
||||||
|
"tagTrack": "ТРЕК",
|
||||||
|
"tagConfirm": "ПІДТВ.",
|
||||||
|
"tagTarget": "ЦІЛЬ",
|
||||||
|
"tagMilVeh": "ВІЙСЬК-ТЕХ",
|
||||||
|
"flightParams": "Параметри польоту",
|
||||||
|
"gpsDenied": "GPS-Denied",
|
||||||
|
"gpsDeniedActive": "GPS-Denied // Активно",
|
||||||
|
"orthophotoUpload": "Завантаження ортофото",
|
||||||
|
"uploadPhotos": "Завантажити фото",
|
||||||
|
"liveGps": "GPS Потік",
|
||||||
|
"connected": "З'ЄДНАНО",
|
||||||
|
"connectedStreaming": "З'ЄДНАНО · ПОТІК",
|
||||||
|
"active": "Активно",
|
||||||
|
"offline": "Офлайн",
|
||||||
|
"status": "Статус",
|
||||||
|
"latitude": "Широта",
|
||||||
|
"longitude": "Довгота",
|
||||||
|
"satellites": "Супутники",
|
||||||
|
"drift": "Відхилення",
|
||||||
|
"gpsCorrection": "Корекція GPS",
|
||||||
|
"waypointNum": "Точка №",
|
||||||
|
"correctedGps": "Скориговані GPS",
|
||||||
|
"applyCorrection": "Застосувати корекцію",
|
||||||
|
"backToParams": "Назад до параметрів",
|
||||||
|
"upload": "Завантажити",
|
||||||
|
"expandParams": "Розгорнути параметри",
|
||||||
|
"collapse": "Згорнути",
|
||||||
|
"date": "Дата",
|
||||||
|
"drawHintWork": "Клікніть і потягніть на карті, щоб намалювати робочу зону",
|
||||||
|
"drawHintNoGo": "Клікніть і потягніть на карті, щоб намалювати заборонену зону",
|
||||||
|
"hud": {
|
||||||
|
"liveConnected": "ЕФІР · З'ЄДНАНО",
|
||||||
|
"sat": "Супут",
|
||||||
|
"lat": "Шир",
|
||||||
|
"lon": "Довг",
|
||||||
|
"alt": "Вис",
|
||||||
|
"hdg": "Курс",
|
||||||
|
"spd": "Швид",
|
||||||
|
"link": "Зв'язок",
|
||||||
|
"mapLegend": "Легенда карти",
|
||||||
|
"plannedOriginal": "Планований · Оригінал",
|
||||||
|
"correctedLive": "Скоригований · Ефір",
|
||||||
|
"originStart": "Початок / Старт",
|
||||||
|
"waypoint": "Точка маршруту",
|
||||||
|
"targetFinish": "Ціль / Фініш",
|
||||||
|
"zoomIn": "Збільшити",
|
||||||
|
"zoomOut": "Зменшити",
|
||||||
|
"recenter": "Центрувати",
|
||||||
|
"layers": "Шари"
|
||||||
|
},
|
||||||
|
"strip": {
|
||||||
|
"telemetryLive": "ТЕЛЕМЕТРІЯ · ЕФІР",
|
||||||
|
"frame": "КАДР",
|
||||||
|
"lastPing": "ОСТ. ПІНГ"
|
||||||
|
}
|
||||||
|
},
|
||||||
"planner": {
|
"planner": {
|
||||||
"point": "Точка",
|
"point": "Точка",
|
||||||
"altitude": "Висота",
|
"altitude": "Висота",
|
||||||
@@ -58,6 +133,8 @@
|
|||||||
"submitAdd": "Додати точку",
|
"submitAdd": "Додати точку",
|
||||||
"submitEdit": "Зберегти зміни",
|
"submitEdit": "Зберегти зміни",
|
||||||
"removePoint": "Видалити",
|
"removePoint": "Видалити",
|
||||||
|
"edit": "Редагувати точку",
|
||||||
|
"remove": "Видалити точку",
|
||||||
"windSpeed": "Шв. вітру",
|
"windSpeed": "Шв. вітру",
|
||||||
"windDirection": "Напр. вітру",
|
"windDirection": "Напр. вітру",
|
||||||
"setWind": "Вітер",
|
"setWind": "Вітер",
|
||||||
@@ -85,18 +162,49 @@
|
|||||||
},
|
},
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Анотації",
|
"title": "Анотації",
|
||||||
"mediaList": "Медіа",
|
"mediaList": "Медіа файли",
|
||||||
|
"filterByName": "фільтр за назвою…",
|
||||||
"upload": "Завантажити файли",
|
"upload": "Завантажити файли",
|
||||||
"deleteMedia": "Видалити медіа?",
|
"deleteMedia": "Видалити медіа?",
|
||||||
"detect": "AI Розпізнавання",
|
"detect": "AI Розпізнавання",
|
||||||
|
"detectInProgress": "AI РОЗПІЗНАВАННЯ ТРИВАЄ",
|
||||||
"save": "Зберегти",
|
"save": "Зберегти",
|
||||||
"delete": "Видалити",
|
"delete": "Видалити",
|
||||||
"deleteAll": "Видалити все",
|
"deleteAll": "Видалити все",
|
||||||
|
"deleteAllTitle": "Видалити все на кадрі",
|
||||||
"classes": "Класи детекцій",
|
"classes": "Класи детекцій",
|
||||||
"photoMode": "Режим фото",
|
"photoMode": "Режим фото",
|
||||||
"regular": "Звичайний",
|
"regular": "Звичайний",
|
||||||
"winter": "Зимовий",
|
"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": {
|
"dataset": {
|
||||||
"title": "Датасет",
|
"title": "Датасет",
|
||||||
@@ -104,38 +212,142 @@
|
|||||||
"editor": "Редактор",
|
"editor": "Редактор",
|
||||||
"classDistribution": "Розподіл класів",
|
"classDistribution": "Розподіл класів",
|
||||||
"objectsOnly": "Тільки з об'єктами",
|
"objectsOnly": "Тільки з об'єктами",
|
||||||
"search": "Пошук...",
|
"hideEmpty": "Приховати порожні кадри",
|
||||||
|
"search": "Пошук за назвою анотації…",
|
||||||
"validate": "Валідувати",
|
"validate": "Валідувати",
|
||||||
|
"edit": "Редагувати",
|
||||||
|
"filters": "Фільтри",
|
||||||
|
"total": "Всього",
|
||||||
|
"validatedCount": "Валідовано",
|
||||||
|
"range": "Діапазон",
|
||||||
|
"flight": "Політ",
|
||||||
|
"showing": "Показано",
|
||||||
|
"liveSync": "Жива синхронізація",
|
||||||
|
"selected": "Вибрано",
|
||||||
|
"refreshThumbnails": "Оновити мініатюри",
|
||||||
|
"ofSelected": "{{count}} з {{total}} вибрано",
|
||||||
|
"local": "Локально",
|
||||||
|
"sort": "Сортування",
|
||||||
|
"gridDensity": "Щільність сітки",
|
||||||
|
"statusLabel": "Статус",
|
||||||
"status": {
|
"status": {
|
||||||
"created": "Створено",
|
"created": "Створено",
|
||||||
"edited": "Відредаговано",
|
"edited": "Відредаговано",
|
||||||
"validated": "Валідовано"
|
"validated": "Валідовано",
|
||||||
|
"all": "Всі",
|
||||||
|
"none": "Жоден"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Адмін",
|
"title": "Адмін",
|
||||||
"classes": {
|
"classes": {
|
||||||
"title": "Класи детекцій",
|
"title": "Класи детекцій",
|
||||||
|
"search": "Пошук класу…",
|
||||||
|
"add": "+ ДОДАТИ",
|
||||||
|
"colName": "Назва",
|
||||||
|
"colHex": "Hex",
|
||||||
|
"colOps": "Дії",
|
||||||
"edit": "Редагувати",
|
"edit": "Редагувати",
|
||||||
|
"delete": "Видалити",
|
||||||
"save": "Зберегти",
|
"save": "Зберегти",
|
||||||
"cancel": "Скасувати",
|
"cancel": "Скасувати",
|
||||||
"nameRequired": "Назва обов'язкова",
|
"nameRequired": "Назва обов'язкова",
|
||||||
"maxSizeMustBePositive": "Максимальний розмір має бути додатнім числом",
|
"maxSizeMustBePositive": "Максимальний розмір має бути додатнім числом",
|
||||||
"updateFailed": "Не вдалося оновити. Спробуйте ще раз."
|
"updateFailed": "Не вдалося оновити. Спробуйте ще раз."
|
||||||
},
|
},
|
||||||
"aiSettings": "AI Налаштування",
|
"aiEngine": {
|
||||||
"gpsSettings": "GPS Пристрій",
|
"title": "AI Розпізнавання",
|
||||||
"aircrafts": "Літальні апарати",
|
"subtitle": "Параметри роботи моделі. Застосовуються до польоту, гаряче перезавантаження.",
|
||||||
"users": "Користувачі",
|
"framesToRecognize": "Кадрів для розпізнавання",
|
||||||
"addUser": "Додати користувача",
|
"framesHint": "Кількість послідовних кадрів, які модель усереднює перед видачею детекції.",
|
||||||
"deactivate": "Деактивувати"
|
"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": {
|
"settings": {
|
||||||
"title": "Налаштування",
|
"title": "Налаштування",
|
||||||
"tenant": "Конфігурація",
|
"tenant": "Конфігурація",
|
||||||
"directories": "Директорії",
|
"directories": "Директорії",
|
||||||
"aircrafts": "Літальні апарати",
|
"aircrafts": "Літальні апарати",
|
||||||
"save": "Зберегти"
|
"save": "Зберегти",
|
||||||
|
"militaryUnit": "Військова частина",
|
||||||
|
"unitName": "Назва",
|
||||||
|
"camWidth": "Ширина кадру",
|
||||||
|
"camFoV": "Кут огляду",
|
||||||
|
"required": "ОБ.",
|
||||||
|
"imagesDir": "Директорія зображень",
|
||||||
|
"labelsDir": "Директорія міток",
|
||||||
|
"thumbnailsDir": "Директорія мініатюр",
|
||||||
|
"mounted": "ПІД'ЄДНАНО",
|
||||||
|
"cache": "КЕШ",
|
||||||
|
"browse": "Огляд",
|
||||||
|
"storageFree": "Вільно",
|
||||||
|
"aircraftsRegistered": "ЗАРЕЄСТРОВАНО",
|
||||||
|
"addAircraft": "Додати апарат",
|
||||||
|
"colModel": "Модель",
|
||||||
|
"colType": "Тип",
|
||||||
|
"colDefault": "За замовч.",
|
||||||
|
"language": "Мова",
|
||||||
|
"languageHint": "Впливає на весь UI",
|
||||||
|
"languageNote": "Назви класів детекцій теж беруться з локалізованого поля seed-даних.",
|
||||||
|
"languageBundle": "i18n БАНДЛ",
|
||||||
|
"locale": "Локаль",
|
||||||
|
"session": "Сесія",
|
||||||
|
"sessionActive": "АКТИВНА",
|
||||||
|
"lastLogin": "Останній вхід",
|
||||||
|
"signOutEverywhere": "Вийти всюди",
|
||||||
|
"cancel": "Скасувати",
|
||||||
|
"saveChanges": "Зберегти зміни",
|
||||||
|
"saveError": "Не вдалося зберегти. Спробуйте ще раз.",
|
||||||
|
"unsavedChanges": "Незбережені зміни в",
|
||||||
|
"unitTenant": "КОНФІГУРАЦІЇ",
|
||||||
|
"unitDirectories": "ДИРЕКТОРІЯХ"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"confirm": "Підтвердити",
|
"confirm": "Підтвердити",
|
||||||
|
|||||||
+613
-19
@@ -1,31 +1,625 @@
|
|||||||
@import "tailwindcss";
|
@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 {
|
@theme {
|
||||||
--color-az-bg: #1e1e1e;
|
/* v2 — AZAION design system. v1 az-* names below are aliases so legacy
|
||||||
--color-az-panel: #2b2b2b;
|
pages still render until they're migrated to v2 utilities. */
|
||||||
--color-az-header: #343a40;
|
--color-surface-0: #0A0D10;
|
||||||
--color-az-border: #495057;
|
--color-surface-1: #13171C;
|
||||||
--color-az-muted: #6c757d;
|
--color-surface-2: #1A1F26;
|
||||||
--color-az-text: #adb5bd;
|
--color-surface-input: #0A0D10;
|
||||||
--color-az-orange: #fd7e14;
|
--color-border-hair: #252B34;
|
||||||
--color-az-blue: #228be6;
|
--color-border-raised: #3B4451;
|
||||||
--color-az-red: #fa5252;
|
--color-text-primary: #E8ECF1;
|
||||||
--color-az-green: #40c057;
|
--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 {
|
body {
|
||||||
margin: 0;
|
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 {
|
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
|
||||||
width: 6px;
|
.tnum { font-variant-numeric: tabular-nums; }
|
||||||
height: 6px;
|
|
||||||
|
.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);
|
.hint { font-size: 11px; color: var(--text-muted); line-height: 1.45; }
|
||||||
border-radius: 3px;
|
|
||||||
|
/* 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 {
|
export interface Aircraft {
|
||||||
id: string
|
id: string
|
||||||
model: string
|
model: string
|
||||||
type: 'Plane' | 'Copter'
|
type: 'Plane' | 'Copter' | 'FixedWing'
|
||||||
isDefault: boolean
|
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 {
|
export interface Waypoint {
|
||||||
|
|||||||
Vendored
+2
@@ -8,6 +8,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_OWM_API_KEY?: string
|
readonly VITE_OWM_API_KEY?: string
|
||||||
readonly VITE_OWM_BASE_URL?: string
|
readonly VITE_OWM_BASE_URL?: string
|
||||||
readonly VITE_SATELLITE_TILE_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 {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -105,14 +105,12 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
|||||||
// Act
|
// Act
|
||||||
await clickEdit('1')
|
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 row1 = getRow('1')
|
||||||
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
|
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
|
||||||
expect(nameInput).toBeInTheDocument()
|
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.
|
// Assert — row 2 stays read-only: the row still shows the plain text name.
|
||||||
const row2 = getRow('2')
|
const row2 = getRow('2')
|
||||||
@@ -246,31 +244,17 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
|||||||
// Act
|
// Act
|
||||||
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
|
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)
|
expect(patchCalls.length).toBe(0)
|
||||||
const alert = within(row1).getByRole('alert')
|
const alert = screen.getByRole('alert')
|
||||||
expect(alert.textContent ?? '').toMatch(/name is required|назва обов/i)
|
expect(alert.textContent ?? '').toMatch(/name is required|назва обов/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('non-positive maxSizeM → no PATCH; maxSizeMustBePositive error visible', async () => {
|
// The maxSizeM field is no longer editable inline in v2 (mockup shows
|
||||||
// Arrange
|
// name-only). The original "non-positive maxSizeM" validation test is
|
||||||
const patchCalls = capturePatchCalls()
|
// removed — the constraint is now enforced by a separate edit-class
|
||||||
renderWithProviders(<AdminPage />)
|
// flow (not yet built) rather than inline.
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AC-6: backend error is surfaced inline', () => {
|
describe('AC-6: backend error is surfaced inline', () => {
|
||||||
@@ -299,10 +283,11 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
|||||||
// Act
|
// Act
|
||||||
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
|
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))
|
await waitFor(() => expect(patchCount).toBe(1))
|
||||||
const row1After = getRow('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(alert.textContent ?? '').toMatch(/update failed|не вдалося оновити/i)
|
||||||
expect(within(row1After).getByDisplayValue('will-fail')).toBeInTheDocument()
|
expect(within(row1After).getByDisplayValue('will-fail')).toBeInTheDocument()
|
||||||
expect(alertCalls).toBe(0)
|
expect(alertCalls).toBe(0)
|
||||||
@@ -317,7 +302,7 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
|||||||
// Arrange — capture POST; second GET returns 3 classes.
|
// Arrange — capture POST; second GET returns 3 classes.
|
||||||
const postCalls: { body: unknown }[] = []
|
const postCalls: { body: unknown }[] = []
|
||||||
let getCount = 0
|
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(
|
server.use(
|
||||||
http.post('/api/admin/classes', async ({ request }) => {
|
http.post('/api/admin/classes', async ({ request }) => {
|
||||||
postCalls.push({ body: await request.json() })
|
postCalls.push({ body: await request.json() })
|
||||||
@@ -332,13 +317,15 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
|||||||
renderWithProviders(<AdminPage />)
|
renderWithProviders(<AdminPage />)
|
||||||
await screen.findByText('class-a')
|
await screen.findByText('class-a')
|
||||||
|
|
||||||
// Act — scope to the classes table panel (both the class-add row and
|
// Act — v2 layout: click the top "+ ADD" button to open an inline
|
||||||
// the user-add row use placeholder="Name" + a `+` button; disambiguate
|
// add-row at the top of the table, type the name, click the save
|
||||||
// by walking up from the class-a cell to the enclosing panel).
|
// (cyan checkmark, aria-label "Save") icon button.
|
||||||
const classesPanel = (getRow('1').closest('table') as HTMLElement).parentElement as HTMLElement
|
const classesPanel = getRow('1').closest('aside') as HTMLElement
|
||||||
const addNameInput = within(classesPanel).getByPlaceholderText('Name') as HTMLInputElement
|
await userEvent.click(within(classesPanel).getByRole('button', { name: /^\+ add$|^\+ додати$/i }))
|
||||||
await userEvent.type(addNameInput, 'fresh')
|
const addRow = within(classesPanel).getByText('+', { selector: 'td' }).closest('tr') as HTMLElement
|
||||||
await userEvent.click(within(classesPanel).getByRole('button', { name: '+' }))
|
const nameInput = within(addRow).getByPlaceholderText('Name') as HTMLInputElement
|
||||||
|
await userEvent.type(nameInput, 'fresh')
|
||||||
|
await userEvent.click(within(addRow).getByRole('button', { name: /^save$|^зберегти$/i }))
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await waitFor(() => expect(postCalls.length).toBe(1))
|
await waitFor(() => expect(postCalls.length).toBe(1))
|
||||||
|
|||||||
Vendored
+7
-4
@@ -1,8 +1,11 @@
|
|||||||
import type { Aircraft } from '../../src/types'
|
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[] = [
|
export const seedAircraft: Aircraft[] = [
|
||||||
{ id: 'aircraft-1', model: 'Bayraktar TB2', type: 'Plane', isDefault: true },
|
{ id: 'AC-001', model: 'DJI Mavic 3', type: 'Copter', isDefault: true, resolution: '4K', maxMinutes: 46 },
|
||||||
{ id: 'aircraft-2', model: 'DJI Mavic 3', type: 'Copter', isDefault: false },
|
{ id: 'AC-002', model: 'Matrice 300 RTK', type: 'Copter', isDefault: false, resolution: '4K', maxMinutes: 55 },
|
||||||
{ id: 'aircraft-3', model: 'Leleka-100', type: 'Plane', isDefault: false },
|
{ 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.
|
// AC-08 timing assertions.
|
||||||
|
|
||||||
export const seedFlights: Flight[] = [
|
export const seedFlights: Flight[] = [
|
||||||
{ id: 'flight-1', name: 'Recon Alpha', createdDate: '2026-05-01T10: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: 'aircraft-1' },
|
{ 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: 'aircraft-2' },
|
{ 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: 'aircraft-3' },
|
{ 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: 'aircraft-1' },
|
{ id: 'flight-5', name: 'Strike Echo', createdDate: '2026-05-05T16:00:00Z', aircraftId: 'AC-001' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const liveGpsFlightId = 'flight-1'
|
export const liveGpsFlightId = 'flight-1'
|
||||||
|
|||||||
+28
-16
@@ -6,11 +6,23 @@
|
|||||||
"TCP",
|
"TCP",
|
||||||
"UDP",
|
"UDP",
|
||||||
"Esc",
|
"Esc",
|
||||||
"OK"
|
"OK",
|
||||||
|
"//",
|
||||||
|
"|",
|
||||||
|
"▾",
|
||||||
|
"▲",
|
||||||
|
"▼",
|
||||||
|
"—"
|
||||||
],
|
],
|
||||||
"src/components/Header.tsx": [
|
"src/components/Header.tsx": [
|
||||||
"No flights",
|
"No flights",
|
||||||
"Filter..."
|
"Filter...",
|
||||||
|
"— SELECT —",
|
||||||
|
"LINK",
|
||||||
|
"Toggle language",
|
||||||
|
"UA",
|
||||||
|
"EN",
|
||||||
|
"⚙"
|
||||||
],
|
],
|
||||||
"src/components/HelpModal.tsx": [
|
"src/components/HelpModal.tsx": [
|
||||||
"How to Annotate",
|
"How to Annotate",
|
||||||
@@ -36,20 +48,20 @@
|
|||||||
],
|
],
|
||||||
"src/features/admin/AdminPage.tsx": [
|
"src/features/admin/AdminPage.tsx": [
|
||||||
"Name",
|
"Name",
|
||||||
"Color",
|
"#",
|
||||||
"Frame Period Recognition",
|
"+",
|
||||||
"Frame Recognition Seconds",
|
"0.0.0.0",
|
||||||
"Probability Threshold",
|
"P",
|
||||||
"Device Address",
|
"C",
|
||||||
"Port",
|
"F",
|
||||||
"Protocol",
|
"%",
|
||||||
"Email",
|
"NMEA",
|
||||||
"Role",
|
"UBX",
|
||||||
"Status",
|
"MAVLINK",
|
||||||
"Annotator",
|
"SAT",
|
||||||
"Admin",
|
"MIN",
|
||||||
"Viewer",
|
"Increment",
|
||||||
"Password"
|
"Decrement"
|
||||||
],
|
],
|
||||||
"src/features/annotations/AnnotationsSidebar.tsx": [
|
"src/features/annotations/AnnotationsSidebar.tsx": [
|
||||||
"Download annotation"
|
"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 })
|
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 }) => {
|
http.post('/api/flights/aircraft', async ({ request }) => {
|
||||||
const body = (await request.json()) as Record<string, unknown>
|
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 { adminHandlers } from './admin'
|
||||||
|
import { adminSettingsHandlers } from './admin-settings'
|
||||||
import { flightsHandlers } from './flights'
|
import { flightsHandlers } from './flights'
|
||||||
import { annotationsHandlers } from './annotations'
|
import { annotationsHandlers } from './annotations'
|
||||||
import { detectHandlers } from './detect'
|
import { detectHandlers } from './detect'
|
||||||
@@ -12,6 +13,7 @@ import { tilesHandlers } from './tiles'
|
|||||||
// the seeded baseline. Per-test overrides land via `server.use(...)`.
|
// the seeded baseline. Per-test overrides land via `server.use(...)`.
|
||||||
export const defaultHandlers = [
|
export const defaultHandlers = [
|
||||||
...adminHandlers,
|
...adminHandlers,
|
||||||
|
...adminSettingsHandlers,
|
||||||
...flightsHandlers,
|
...flightsHandlers,
|
||||||
...annotationsHandlers,
|
...annotationsHandlers,
|
||||||
...detectHandlers,
|
...detectHandlers,
|
||||||
@@ -23,6 +25,7 @@ export const defaultHandlers = [
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
adminHandlers,
|
adminHandlers,
|
||||||
|
adminSettingsHandlers,
|
||||||
flightsHandlers,
|
flightsHandlers,
|
||||||
annotationsHandlers,
|
annotationsHandlers,
|
||||||
detectHandlers,
|
detectHandlers,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|||||||
import { http, HttpResponse } from 'msw'
|
import { http, HttpResponse } from 'msw'
|
||||||
import { server } from './msw/server'
|
import { server } from './msw/server'
|
||||||
import { jsonResponse } from './msw/helpers'
|
import { jsonResponse } from './msw/helpers'
|
||||||
import { renderWithProviders, screen, waitFor, userEvent, within } from './helpers/render'
|
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { SettingsPage } from '../src/features/settings'
|
import { SettingsPage } from '../src/features/settings'
|
||||||
import { seedAircraft } from './fixtures/seed_aircraft'
|
import { seedAircraft } from './fixtures/seed_aircraft'
|
||||||
@@ -18,16 +18,9 @@ import type { SystemSettings, DirectorySettings } from '../src/types'
|
|||||||
// AC-3 (NFT-PERF-09) — Deadline: wall-clock from PUT response/error
|
// AC-3 (NFT-PERF-09) — Deadline: wall-clock from PUT response/error
|
||||||
// to error visibility ≤ 2 s.
|
// to error visibility ≤ 2 s.
|
||||||
//
|
//
|
||||||
// Production today (`SettingsPage.saveSystem` / `saveDirs`) does
|
// v2 SettingsPage wraps `save()` in try/catch/finally and renders an inline
|
||||||
// setSaving(true); await api.put(...); setSaving(false)
|
// role="alert" in the sticky footer when the PUT rejects. The three contract
|
||||||
// with no try/finally and no error region in the JSX. Both AC-1 and AC-2 are
|
// tests below assert that wiring directly.
|
||||||
// drift today: the button stays disabled forever and no alert appears. The
|
|
||||||
// AC-3 deadline assertion is also vacuously failing (no DOM element to find).
|
|
||||||
// We mark the contract assertions `it.fails()` and pin the current drift with
|
|
||||||
// control tests, so:
|
|
||||||
// - The drift is documented in the test suite.
|
|
||||||
// - The contract tests will start passing the moment SettingsPage wires
|
|
||||||
// try/finally + an error region — no edits to this file required.
|
|
||||||
|
|
||||||
const SYSTEM_SEED: SystemSettings = {
|
const SYSTEM_SEED: SystemSettings = {
|
||||||
id: 'sys-1',
|
id: 'sys-1',
|
||||||
@@ -84,163 +77,93 @@ function rigSettingsEnv(failure: SettingsFailure): SettingsRig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SettingsPage renders two "Save" buttons (one per panel) once both GETs
|
* SettingsPage (v2) renders a single sticky-footer "Save Changes" button that
|
||||||
* resolve. We always exercise the *system* panel — its handler (`saveSystem`)
|
* persists whichever panels are dirty in parallel. The footer button is the
|
||||||
* has the same try-finally drift as `saveDirs`, and scoping the query to
|
* only Save affordance; per-panel Save buttons no longer exist. We must mark
|
||||||
* "Tenant Configuration" makes the selector unambiguous regardless of which
|
* the Tenant panel as dirty by editing a field before the footer button
|
||||||
* GET resolves first.
|
* becomes enabled — selecting the Military Unit input by accessible name and
|
||||||
|
* typing a single character is enough to flip the dirty flag.
|
||||||
*/
|
*/
|
||||||
async function findSystemSaveButton(): Promise<HTMLElement> {
|
async function findSystemSaveButton(): Promise<HTMLElement> {
|
||||||
const systemHeading = await screen.findByRole('heading', { name: /Tenant Configuration/i })
|
// Wait until the data has loaded (heading is present immediately, but the
|
||||||
const panel = systemHeading.parentElement as HTMLElement
|
// input is rendered only after the GET resolves).
|
||||||
return within(panel).getByRole('button', { name: /^Save$/i })
|
await screen.findByRole('heading', { name: /Tenant Configuration/i })
|
||||||
|
return screen.getByRole('button', { name: /^Save Changes$/i })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeTenantDirty(): Promise<void> {
|
||||||
|
const militaryUnit = await screen.findByLabelText(/Military Unit/i)
|
||||||
|
await userEvent.type(militaryUnit, '!')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderAndClickSave(): Promise<void> {
|
async function renderAndClickSave(): Promise<void> {
|
||||||
renderWithProviders(<SettingsPage />)
|
renderWithProviders(<SettingsPage />)
|
||||||
|
await makeTenantDirty()
|
||||||
const saveButton = await findSystemSaveButton()
|
const saveButton = await findSystemSaveButton()
|
||||||
await userEvent.click(saveButton)
|
await userEvent.click(saveButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AZ-477 — Settings save resilience + 2 s error budget', () => {
|
describe('AZ-477 — Settings save resilience + 2 s error budget', () => {
|
||||||
// Production today has no try/catch around the settings-save api.put().
|
|
||||||
// When MSW returns 500 (or HttpResponse.error()), the rejected promise
|
|
||||||
// becomes an unhandled rejection at the process level and Vitest fails
|
|
||||||
// the run with exit code 1 — even though every test assertion passes.
|
|
||||||
// This handler swallows the *expected* rejection pattern only, so any
|
|
||||||
// unexpected unhandled rejection still surfaces as a hard failure.
|
|
||||||
// The drift itself is asserted by the it.fails() contract tests above
|
|
||||||
// ("Save button stays disabled" / "no DOM error region").
|
|
||||||
let suppressedRejections: unknown[] = []
|
|
||||||
const onUnhandled = (reason: unknown): void => {
|
|
||||||
const msg =
|
|
||||||
reason instanceof Error
|
|
||||||
? reason.message
|
|
||||||
: typeof reason === 'string'
|
|
||||||
? reason
|
|
||||||
: ''
|
|
||||||
if (
|
|
||||||
msg.startsWith('500: upstream failure') ||
|
|
||||||
msg.startsWith('Failed to fetch') ||
|
|
||||||
msg === 'Network error' ||
|
|
||||||
msg.includes('network error')
|
|
||||||
) {
|
|
||||||
suppressedRejections.push(reason)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Re-throw — surface unexpected rejections to the test runner.
|
|
||||||
throw reason
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
seedBearer()
|
seedBearer()
|
||||||
suppressedRejections = []
|
|
||||||
process.on('unhandledRejection', onUnhandled)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
clearBearer()
|
clearBearer()
|
||||||
process.off('unhandledRejection', onUnhandled)
|
|
||||||
// Sanity: every test in this file expects exactly one swallowed
|
|
||||||
// rejection (the settings PUT). If a test triggers more — or zero — the
|
|
||||||
// drift assumption changed and the harness should flag it.
|
|
||||||
if (suppressedRejections.length > 1) {
|
|
||||||
throw new Error(
|
|
||||||
`AZ-477 harness: expected at most 1 suppressed rejection, got ${suppressedRejections.length}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery', () => {
|
describe('AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery', () => {
|
||||||
it.fails(
|
it('PUT 500 → Save button is no longer disabled within 2 s', async () => {
|
||||||
'PUT 500 → Save button is no longer disabled within 2 s',
|
rigSettingsEnv({ kind: 'http', status: 500 })
|
||||||
async () => {
|
|
||||||
// Drift: saveSystem awaits api.put() outside a try/finally; on a
|
|
||||||
// rejected promise the trailing `setSaving(false)` is never reached
|
|
||||||
// and the button stays disabled forever.
|
|
||||||
rigSettingsEnv({ kind: 'http', status: 500 })
|
|
||||||
await renderAndClickSave()
|
|
||||||
const saveButton = await findSystemSaveButton()
|
|
||||||
await waitFor(
|
|
||||||
() => 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.
|
|
||||||
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 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()
|
const saveButton = await findSystemSaveButton()
|
||||||
expect(saveButton).toBeDisabled()
|
await waitFor(
|
||||||
|
() => expect(saveButton).not.toBeDisabled(),
|
||||||
|
{ timeout: 2000 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AC-2 (FT-N-14 / NFT-RES-06) — network drop', () => {
|
describe('AC-2 (FT-N-14 / NFT-RES-06) — network drop', () => {
|
||||||
it.fails(
|
it('network error → Save button is no longer disabled within 2 s', async () => {
|
||||||
'network error → Save button is no longer disabled within 2 s',
|
rigSettingsEnv({ kind: 'network' })
|
||||||
async () => {
|
await renderAndClickSave()
|
||||||
rigSettingsEnv({ kind: 'network' })
|
const saveButton = await findSystemSaveButton()
|
||||||
await renderAndClickSave()
|
await waitFor(
|
||||||
const saveButton = await findSystemSaveButton()
|
() => expect(saveButton).not.toBeDisabled(),
|
||||||
await waitFor(
|
{ timeout: 2000 },
|
||||||
() => expect(saveButton).not.toBeDisabled(),
|
)
|
||||||
{ timeout: 2000 },
|
})
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
it.fails(
|
it('network error → an in-DOM error region (role="alert") appears within 2 s', async () => {
|
||||||
'network error → an in-DOM error region (role="alert") appears within 2 s',
|
rigSettingsEnv({ kind: 'network' })
|
||||||
async () => {
|
await renderAndClickSave()
|
||||||
rigSettingsEnv({ kind: 'network' })
|
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||||
await renderAndClickSave()
|
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
||||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
})
|
||||||
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AC-3 (NFT-PERF-09) — deadline ≤ 2 s', () => {
|
describe('AC-3 (NFT-PERF-09) — deadline ≤ 2 s', () => {
|
||||||
it.fails(
|
it('500 → DOM error region visible within 2000 ms of the response', async () => {
|
||||||
'500 → DOM error region visible within 2000 ms of the response',
|
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
|
||||||
async () => {
|
await renderAndClickSave()
|
||||||
// The deadline is measured from the moment the 500 response is
|
const alertEl = await screen.findByRole('alert', {}, { timeout: 2500 })
|
||||||
// returned by MSW (rig.responseAt.value) to the moment role="alert"
|
const alertVisibleAt = performance.now()
|
||||||
// is found. Today the alert never appears; the assertion is set so
|
expect(rig.responseAt.value).not.toBeNull()
|
||||||
// it will pass the moment the alert is wired AND comes up under the
|
const elapsed = alertVisibleAt - (rig.responseAt.value as number)
|
||||||
// 2-second budget.
|
// Elapsed must be ≥ 0 (response landed first) AND ≤ 2000 ms.
|
||||||
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
|
expect(elapsed).toBeGreaterThanOrEqual(0)
|
||||||
await renderAndClickSave()
|
expect(elapsed).toBeLessThanOrEqual(2000)
|
||||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2500 })
|
expect(alertEl).toBeInTheDocument()
|
||||||
const alertVisibleAt = performance.now()
|
})
|
||||||
expect(rig.responseAt.value).not.toBeNull()
|
|
||||||
const elapsed = alertVisibleAt - (rig.responseAt.value as number)
|
|
||||||
// Elapsed must be ≥ 0 (response landed first) AND ≤ 2000 ms.
|
|
||||||
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 { server } from './msw/server'
|
||||||
import { setToken, setNavigateToLogin } from '../src/api'
|
import { setToken, setNavigateToLogin } from '../src/api'
|
||||||
import { __resetBootstrapInflightForTests } from '../src/auth'
|
import { __resetBootstrapInflightForTests } from '../src/auth'
|
||||||
|
import { resetAdminSettingsSeed } from './msw/handlers/admin-settings'
|
||||||
|
|
||||||
// JSDOM polyfills for browser APIs production code touches at mount time.
|
// JSDOM polyfills for browser APIs production code touches at mount time.
|
||||||
// These are no-op stubs — tests that exercise the actual behavior install
|
// 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
|
// 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.
|
// a never-resolving fixture in test N does not leak into test N+1.
|
||||||
__resetBootstrapInflightForTests()
|
__resetBootstrapInflightForTests()
|
||||||
|
// v2 admin settings — module-scoped seed mutates on PATCH; reset between tests.
|
||||||
|
resetAdminSettingsSeed()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/components/savedannotationscontext.tsx","./src/features/admin/adminpage.tsx","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/videoplayer.tsx","./src/features/annotations/classcolors.ts","./src/features/annotations/thumbnail.ts","./src/features/dataset/datasetpage.tsx","./src/features/flights/altitudechart.tsx","./src/features/flights/altitudedialog.tsx","./src/features/flights/drawcontrol.tsx","./src/features/flights/flightlistsidebar.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightparamspanel.tsx","./src/features/flights/flightspage.tsx","./src/features/flights/jsoneditordialog.tsx","./src/features/flights/mappoint.tsx","./src/features/flights/minimap.tsx","./src/features/flights/waypointlist.tsx","./src/features/flights/windeffect.tsx","./src/features/flights/flightplanutils.ts","./src/features/flights/mapicons.ts","./src/features/flights/types.ts","./src/features/login/loginpage.tsx","./src/features/settings/settingspage.tsx","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/types/index.ts"],"version":"5.7.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/index.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/auth/index.ts","./src/class-colors/classcolors.ts","./src/class-colors/index.ts","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/components/savedannotationscontext.tsx","./src/components/index.ts","./src/features/admin/adminpage.tsx","./src/features/admin/classeditrow.tsx","./src/features/admin/modal.tsx","./src/features/admin/numberstepper.tsx","./src/features/admin/index.ts","./src/features/admin/useaisettings.ts","./src/features/admin/usegpssettings.ts","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/scrubber.tsx","./src/features/annotations/videoplayer.tsx","./src/features/annotations/index.ts","./src/features/annotations/thumbnail.ts","./src/features/annotations/time.ts","./src/features/dataset/datasetleftpanel.tsx","./src/features/dataset/datasetpage.tsx","./src/features/dataset/index.ts","./src/features/flights/altitudechart.tsx","./src/features/flights/altitudedialog.tsx","./src/features/flights/drawcontrol.tsx","./src/features/flights/flightlistsidebar.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightparamspanel.tsx","./src/features/flights/flightspage.tsx","./src/features/flights/jsoneditordialog.tsx","./src/features/flights/mappoint.tsx","./src/features/flights/minimap.tsx","./src/features/flights/waypointlist.tsx","./src/features/flights/windeffect.tsx","./src/features/flights/flightplanutils.ts","./src/features/flights/index.ts","./src/features/flights/mapicons.ts","./src/features/flights/types.ts","./src/features/login/loginpage.tsx","./src/features/login/index.ts","./src/features/settings/settingspage.tsx","./src/features/settings/index.ts","./src/hooks/index.ts","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/i18n/index.ts","./src/types/index.ts"],"version":"5.7.3"}
|
||||||
Reference in New Issue
Block a user