Refactor project structure and dependencies; rename package to azaion-ui, update version to 0.0.1, and remove unused files. Introduce new routing and authentication features in App component.

This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-03-25 03:10:15 +02:00
parent e407308284
commit 157a33096a
112 changed files with 6530 additions and 17843 deletions
+11
View File
@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
-28
View File
@@ -1,28 +0,0 @@
# Azaion Suite
Azaion Suite allows users to run detections on videos or photos for military-related objects, like
military vehicles, tanks, cars, military men, motos, planes, and masked objects.
Also it allows to do GPS marking by video / photos from GPS camera pointing downwards and start coordinates.
## Application Structure
The application now combines two parts:
- **Main Annotator App** (accessible at `/`) - The main annotation interface
- **Admin Dashboard** (accessible at `/admin`) - Administrative interface for user management and system controls
## Install
```shell
npm i -g yarn
yarn install
```
## Development
`yarn start`
The application will start on http://localhost:3000:
- Navigate to `/` for the main annotator interface
- Navigate to `/admin` for the admin dashboard (requires admin login)
## Build Production
`yarn run build`
Binary file not shown.
+419
View File
@@ -0,0 +1,419 @@
# Azaion UI Design
## Responsive Breakpoints
| Device | Min Width | Viewport | Aspect Ratio |
|--------|-----------|----------|--------------|
| Mobile | 640px | 390px | 32:20 |
| Tablet/PC | 1280px | 780px / 1280×800 | 16:10 |
| Mini Laptop | 1280px+ | 1280px | Responsive |
## Pages
| Page | Wireframe |
|------|-----------|
| Login | *(no wireframe — see Login Page Layout below)* |
| Flights | [flights.html](./flights.html) (includes GPS-Denied panel via toggle) |
| Annotations | [annotations.html](./annotations.html) |
| Dataset Explorer | [dataset_explorer.html](./dataset_explorer.html) |
| Admin | [admin.html](./admin.html) |
| Settings | [settings.html](./settings.html) |
## Global Header
All pages share a common header:
```
[AZAION Logo] [Flight Selector ▼] | Flights | Annotations | Dataset | Admin | [User] [Settings] [Logout]
```
### Flight Selector
The flight selector is a dropdown in the header. Clicking the selector opens a dropdown panel containing:
- Text filter input (search by flight name)
- Scrollable list of flights showing: flight name, created date
- Clicking a flight selects it and closes the dropdown
The selected flight is the **global context** for the application. When a flight is selected:
- **Annotations tab**: media list filters by `GET /media?flightId=<selectedFlightId>`
- **Dataset Explorer**: flight filter pre-selects the current flight
- **Flights tab**: the selected flight is highlighted in the flight list sidebar
The selector persists the selected flight in `UserSettings` (see [Database Schema](../00_database_schema.md)). On app load, the last selected flight is restored.
## Mobile Bottom Navigation
On mobile devices, a bottom navigation bar replaces the header tabs:
```
[ FL ] [ ANN ] [ DATA ] [ ADM ] [ ⚙ ]
```
## Flights Page Layout
Flights and GPS-Denied share a single page. The left panel toggles between Flight Parameters and GPS-Denied mode via a toggle button.
### Flight List Sidebar (far left, ~200px)
- List of flights (e.g. FL01, FL02, FL03) with created date
- "Create New" button
- Telemetry section with DatePicker
### Left Panel (~260px, toggleable)
**Flight Parameters mode** (default):
- Aircraft selector (dropdown)
- Default Height (input)
- Camera settings: FocalLength (mm), SensorWidth (mm), Altitude (m)
- Communication Address / Port
- Waypoints list: Start, Point1, Point2... (with labels), Finish
- **GPS-Denied toggle** button: switches this panel to GPS-Denied mode
- Upload action
**GPS-Denied mode** (toggled):
- **Orthophoto Upload**: List of uploaded photos with name and GPS coordinates, upload button
- **Live GPS**: Real-time GPS data via SSE (status, lat, lon, satellites)
- **GPS Correction**: Waypoint number input, corrected GPS input, apply button
- **Back to Flight** button: returns to Flight Parameters mode
### Map View (right, ~800px)
Interactive map displaying waypoints connected by the flight path.
In GPS-Denied mode, the map additionally shows:
- Original flight path (red dashed line)
- Corrected path (green solid line) overlay
- Waypoint markers with correction indicators
## Login Page Layout
Displayed before authentication. No header navigation — only the login form.
### Login Form (centered, ~400px wide)
- Email input
- Password input
- "Login" button
- Status / error message area
### Unlock Progress States
After successful login, the UI shows a progress screen while the binary-split unlock flow completes:
```
Authenticating → Downloading key → Decrypting → Starting services → Ready
```
Each state is displayed with a spinner or progress indicator. On **Ready**, the UI navigates to the main application.
---
## Annotations Tab Layout
All panels are separated by draggable splitters. Panel widths persist across sessions via `UserSettings` table.
### Left Panel (~250px)
#### Media List
- List of media files (photos, videos) for the selected flight
- Columns: File name, Duration (for videos)
- Files with existing annotations are highlighted (darker background)
- Clicking a media item loads it in the viewer; double-click starts playback
- Text filter input above the list to search by file name
- Context menu: "Delete media" (with confirmation dialog)
- Media files are loaded from the backend via `GET /media?flightId=`. The media sidebar shows files associated with the selected flight via the Media API.
#### Detection Classes (below media list)
- DataGrid with columns: Key (color + number), Name
- PhotoMode switcher at bottom: Regular (0) | Winter (20) | Night (40)
- Selecting a class sets the active drawing class; `yoloId = classId + photoModeOffset`
- Keyboard: press `19` to select class by number
- Captions hidden when panel width < 230px
### Main Viewer (center)
#### Canvas Editor
Interactive annotation drawing tool overlaid on the image/video frame:
- **Draw**: click-and-drag to create a bounding box
- **Resize**: 8 directional handles on selected box
- **Move**: drag a selected box to reposition
- **Multi-select**: Ctrl+click to select multiple boxes
- **Zoom**: Ctrl+mouse wheel to zoom in/out
- **Pan**: Ctrl+drag to pan when zoomed
- **Crosshair**: cursor shows crosshair with active class name hint
- All coordinates are clamped to image bounds (normalized 0.01.0)
- **Tile zoom**: when opening a tile-based detection result (from large image split processing), the editor automatically zooms and centers on the specific tile region within the full image
#### Bounding Box Labels
Each bounding box displays:
- Affiliation icon (see [Affiliation Icons](#affiliation-icons) below)
- Combat readiness indicator (see [Combat Readiness](#combat-readiness) below)
- Detection class name
- Confidence percentage (shown only when < 99.5%)
#### Video Controls
- Progress bar (click/drag to seek)
- Toolbar buttons: Play, Pause, Stop, Previous frame, Next frame, Save, Delete selected, Delete all, Mute/Unmute, Volume slider
- Frame-by-frame step buttons: 1, 5, 10, 30, 60 frames — these jump the exact number of frames forward (not time-based). The web video player must support frame-accurate seeking. Frame duration = 1 / video FPS.
- AI Detect button — triggers AI detection on the current frame/media
- GPS panel toggle — shows/hides the MapMatcher panel below the viewer
- Sound detections button (placeholder for future audio analysis)
- Drone maintenance button (placeholder for future UAV diagnostics)
- Status bar: current time / total time, help text, status messages
#### Video Annotation Time-Window Display
During video playback, annotations are displayed on the canvas based on their `Time` (video playback position). An annotation is visible when the current playback position falls within a time window around its `Time`:
| Threshold | Value | Description |
|-----------|-------|-------------|
| Before | 50 ms | Show annotation this much before its `Time` |
| After | 150 ms | Keep annotation visible this long after its `Time` |
The UI maintains an interval lookup (e.g. interval tree) keyed by `[Time - 50ms, Time + 150ms]` for each annotation. On each position update from the video player, the UI queries for all overlapping intervals and renders those annotations on the canvas. Annotations outside the window are cleared.
#### AI Detection Progress
When AI detection is triggered, a modal dialog appears showing:
- Scrolling log of detection progress
- Stop button (Esc to dismiss)
- Detection results are streamed via SSE (`DetectionEvent`)
#### Camera Config (per-session)
Camera settings are adjustable per annotation session. They are displayed when the user opens the current flight's settings panel (accessed from the Flight Parameters panel on the Flights tab). These override the global defaults for GSD-based detection size validation.
- Altitude: slider (010000 m) + numeric input (505000, step 10)
- FocalLength: numeric input (0.1100 mm, step 0.05)
- SensorWidth: numeric input (0.1100 mm, step 0.05)
See [Annotations — Camera Settings](../01_annotations.md#camera-settings-per-session) for API endpoints.
### Annotations List (right sidebar, ~200px)
- DataGrid with columns: Frame time (e.g. "00:12"), Class name
- Rows are colored with a gradient matching the detection class colors (see [Annotation Row Gradient](#annotation-row-gradient) below)
- Double-click a row to seek the video to that frame and zoom to the annotation
- Multi-select with Ctrl for bulk delete
- Selecting a row highlights the corresponding bounding box on the canvas
### Help System
- Menu: Help > "How to annotate" opens a help window (see [Annotation Quality Guidelines](#annotation-quality-guidelines) below)
- Step-by-step guidance in the status bar: Initial → Play Video → Pause for Annotations → Annotation Help
---
## Dataset Explorer Layout
### Left Panel (~250px)
#### Detection Classes
- Same DetectionClasses control as the Annotations tab (class list + PhotoMode switcher)
- Selecting a class filters the grid to show only annotations of that class
#### Filters
- "Show with objects only" checkbox — hides annotations with no detections
- Text search input — search by annotation name (throttled, 400ms debounce)
### Filter Bar (top of main area)
- Date range picker (from / to)
- Flight filter dropdown
- Status filter buttons: None, Created, Edited, Validated
### Main Area (tabbed)
#### Annotations Tab (default)
- Virtualized grid of annotation thumbnail tiles (responsive, min 180px per tile)
- Each tile shows: thumbnail image, annotation name, created date + creator email
- Seed annotations are highlighted with a red border (`IsSeed` flag)
- Double-click a tile to open it in the Editor tab
- Multi-select for bulk validation
- Keyboard: Del (delete), Enter (save), V (validate), Up/Down/PageUp/PageDown (navigate)
#### Editor Tab (hidden until opened)
- Full Canvas Editor (same as Annotations tab) for inline annotation editing
- Esc to close and return to grid
- Keyboard: 19 (class select), Enter (save), Del (delete selected), X (delete all)
#### Class Distribution Tab
- Horizontal bar chart showing annotation count per detection class
- Each bar is colored with the class color
- Displays: colored bar, class label, count
### Status Bar (bottom)
- Validate button (visible when annotations are selected) — bulk-validates selected annotations
- Refresh thumbnails button — regenerates the thumbnail database with progress bar
- Selected annotation name
- Status text
---
## Admin Tab Layout
### Detection Classes Table (left, ~340px)
- Numbered rows (016) with columns: #, Name, Icon, Delete
- Sample classes: Military Vehicle, Truck, Car, Artillery, Active Mine
### AI Recognition Settings (center)
- #Frames To Recognize (default: 4)
- Min Seconds Between Recognition (default: 2)
- Min Confidence Threshold % (default: 25)
### GPS Device Settings (center, below AI)
- Device Address, Port, Protocol
### Default Aircrafts Sidebar (right, ~280px)
- List of default aircraft with type indicator (P=Plane, C=Copter)
- Star icon for default selection
### User Management (separate section / sub-page)
User CRUD is hosted on the remote Admin API. In the React UI, this is a sub-section of the Admin tab or a separate page accessible from the Admin area. Full details are in a separate repository; the initial implementation provides basic user list and create/edit/deactivate functionality. See [Authentication — User Management](../10_auth.md#user-management-admin-only) for endpoints.
- User table: Name, Email, Role, Status (Active/Inactive)
- Create user form: Name, Email, Password, Role selector
- Edit user: inline edit or modal
- Deactivate user (soft delete — sets Status to Inactive)
---
## Settings Tab Layout
### Tenant Configuration (left column, ~300px)
- Military Unit
- Name
- Default Camera Width (px)
- Default Camera FoV (°)
### Directories (center column, ~300px)
- Images Dir
- Labels Dir
- Thumbnails Dir
### Aircrafts Table (right)
- Columns: Model, Type (Plane/Copter), Default toggle
---
## Resizable Panels
All multi-panel layouts (Annotations, Dataset Explorer) use draggable splitters between columns. Users can resize panels by dragging the splitter. Panel widths are saved to the `UserSettings` table in the database and restored on next session. Each user has their own stored layout preferences.
---
## Confirmation Dialogs
Destructive actions require a confirmation dialog before execution:
| Action | Dialog message |
|--------|---------------|
| Delete media file | "Delete this media file?" |
| Delete selected annotations | "Delete selected annotations?" |
| Delete all annotations | "Delete all annotations for this frame?" |
| Deactivate user | "Deactivate this user?" |
Dialogs use a standard modal with Cancel / Confirm buttons. Cancel is the default focused button.
---
## Annotation Quality Guidelines
The help window (accessible from Help > "How to annotate") contains the following annotation quality rules:
1. Annotations must contain objects of the best clarity and quality. Heavily blurred or smoke-obscured objects are not suitable.
2. The more angles of the same object the better. If the video shows an object small at first and then the camera zooms in, both the small and large views should be annotated. If an object is static and the angle doesn't change, one annotation is enough; if it moves and is visible from different sides, annotate once per angle.
3. Objects with shapes that significantly differ from a rectangle: do not include protruding parts (e.g. a tank's barrel) if it adds too much empty space to the bounding box. Similarly for trenches — if they span a large area, create several smaller annotations of the trench segments rather than one large box with too much background.
4. Any existing marks on the video (OSD overlays, HUD elements, etc.) must not be inside annotations. An annotation must contain only the specific object without lines or overlays on it.
5. Each video should have 23 empty frames saved without any objects or annotations, for better training (negative samples). Simply press Enter on a few different frames with nothing in them.
6. Objects of the same class must be visually similar, while objects of different classes must be visually distinct. Since this is a dataset for neural network training (not a military equipment catalog), class assignment should follow visual similarity for better recognition. For example, self-propelled artillery on tracks should be annotated as "Armored Vehicle" because it visually resembles a tank more than a mortar.
---
## Affiliation Icons
Bounding box labels display an affiliation icon using NATO MIL-STD-2525-style shapes:
| Affiliation | Shape | Color | Description |
|-------------|-------|-------|-------------|
| Friendly | Rectangle | Light blue (#87CEEB) | Blue filled rectangle with dark outline |
| Hostile | Diamond | Red (#FF0000) | Red filled diamond (rotated square) with dark outline |
| Unknown | Quatrefoil | Yellow (#FFD700) | Yellow filled four-lobed clover shape with dark outline |
| None | *(no icon)* | — | No affiliation icon displayed |
The React UI renders these as inline SVGs next to the class label inside each bounding box overlay.
---
## Combat Readiness
Bounding box labels display a combat readiness indicator alongside the affiliation icon:
| Value | Indicator | Description |
|-------|-----------|-------------|
| Ready | Green dot | Target is combat-ready |
| NotReady | Gray dot | Target is not combat-ready |
| Unknown | *(no indicator)* | Readiness not assessed |
The indicator is rendered as a small circle next to the affiliation icon inside the bounding box overlay.
---
## Annotation Row Gradient
In the Annotations list (right sidebar), each row's background is a horizontal linear gradient composed of the colors of all detections in that annotation:
- Each detection contributes a gradient stop using its class color
- The color opacity is modulated by the detection's confidence (higher confidence = more opaque)
- If no detections exist, the row has a subtle light gray background (`#40DDDDDD`)
- The gradient runs left-to-right, with equal-width stops per detection
This provides a quick visual summary of what classes are present in each annotation and how confident the detections are.
---
## Keyboard Shortcuts
### Annotations Tab
| Key | Action |
|-----|--------|
| `19` / `NumPad19` | Select detection class |
| `Space` | Pause / resume playback |
| `Left` | Previous frame |
| `Right` | Next frame |
| `Ctrl+Left` | Skip 5 seconds back |
| `Ctrl+Right` | Skip 5 seconds forward |
| `Enter` | Save annotations and continue |
| `Del` | Delete selected annotations |
| `X` | Delete all annotations |
| `R` | Trigger AI detection |
| `M` | Mute / unmute |
| `VolumeUp` / `VolumeDown` | Volume +/- 5 |
| `PageUp` / `PageDown` | Previous / next media file |
### Dataset Explorer
| Key | Action |
|-----|--------|
| `19` | Select detection class |
| `Enter` | Save annotations |
| `Del` | Delete selected annotations |
| `X` | Delete all annotations |
| `Esc` | Close editor, return to grid |
| `Up` / `Down` | Navigate thumbnails |
| `PageUp` / `PageDown` | Page navigation |
| `V` | Validate selected annotations |
---
## Localization
The UI supports Ukrainian (UA) and English (EN) localization. Language is selectable per user session.
Detection class names use Ukrainian display names from the seed data alongside the English `Name`. See [Admin — Default Seed Classes](../07_admin.md#default-seed-classes).
---
## Color Scheme
- Primary: Dark navy/blue (#343a40)
- Secondary: Orange/amber accents (#fd7e14)
- Background: Dark gray (#1e1e1e)
- Text: White/light gray (#ffffff, #adb5bd)
- Success: Green (#40c057)
- Primary buttons: Blue (#228be6)
- Danger: Red (#fa5252)
+236
View File
@@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Azaion Admin Tab Wireframe</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
az: { bg: '#1e1e1e', panel: '#2b2b2b', header: '#343a40', border: '#495057', muted: '#6c757d', text: '#adb5bd', orange: '#fd7e14', blue: '#228be6', red: '#fa5252', green: '#40c057' }
}
}
}
}
</script>
</head>
<body class="bg-az-bg text-white h-screen flex flex-col font-sans">
<header class="flex items-center bg-az-header px-4 py-2 gap-2 border-b border-az-border">
<span class="font-extrabold text-az-orange tracking-wider">AZAION</span>
<span class="bg-az-border border border-az-muted text-white px-2.5 py-1 rounded text-sm">FL02 <span class="text-az-orange ml-1"></span></span>
<nav class="flex gap-0.5 ml-4">
<a href="flights.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Flights</a>
<a href="annotations.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Annotations</a>
<a href="dataset_explorer.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Dataset Explorer</a>
<a href="admin.html" class="px-4 py-1.5 text-sm bg-az-bg text-white font-semibold rounded-t">Admin</a>
</nav>
<div class="ml-auto flex items-center gap-3 text-sm text-az-text">
<span>user@azaion.com</span>
<a href="settings.html" class="hover:text-white"></a>
<a href="#" class="hover:text-white"></a>
</div>
</header>
<div class="flex flex-1 overflow-hidden">
<div class="w-[340px] bg-az-panel border-r border-az-border flex flex-col shrink-0">
<h3 class="px-3 py-2.5 text-xs text-az-text uppercase tracking-wide border-b border-az-border">Detection Classes</h3>
<div class="flex-1 overflow-y-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-az-text text-[11px] uppercase tracking-wide border-b border-az-border">
<th class="px-3 py-2 text-left w-10">#</th>
<th class="px-2 py-2 text-left">Name</th>
<th class="px-2 py-2 text-center w-12">Icon</th>
<th class="px-2 py-2 text-center w-10"></th>
</tr>
</thead>
<tbody>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">0</td>
<td class="px-2 py-2">ArmorVehicle</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#fa5252]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">1</td>
<td class="px-2 py-2">Truck</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#40c057]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">2</td>
<td class="px-2 py-2">Vehicle</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#228be6]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">3</td>
<td class="px-2 py-2">Artillery</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#FFFF00]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">4</td>
<td class="px-2 py-2">Shadow</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#FF00FF]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">5</td>
<td class="px-2 py-2">Trenches</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#00FFFF]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">6</td>
<td class="px-2 py-2">MilitaryMan</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#188021]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">7</td>
<td class="px-2 py-2">TyreTracks</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#800000]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">8</td>
<td class="px-2 py-2">AdditionArmoredTank</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#008000]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">9</td>
<td class="px-2 py-2">Smoke</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#000080]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">10</td>
<td class="px-2 py-2">Plane</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#000080]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">11</td>
<td class="px-2 py-2">Moto</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#808000]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">12</td>
<td class="px-2 py-2">CamouflageNet</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#800080]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">13</td>
<td class="px-2 py-2">CamouflageBranches</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#2f4f4f]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">14</td>
<td class="px-2 py-2">Roof</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#1e90ff]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">15</td>
<td class="px-2 py-2">Building</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#ffb6c1]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">16</td>
<td class="px-2 py-2">Caponier</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#ffb6c1]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">17</td>
<td class="px-2 py-2">Ammo</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#33658a]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2 text-az-text">18</td>
<td class="px-2 py-2">Protect.Struct</td>
<td class="px-2 py-2 text-center"><span class="inline-block w-5 h-5 rounded bg-[#969647]"></span></td>
<td class="px-2 py-2 text-center"><button class="text-az-muted hover:text-az-red text-xs"></button></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex-1 flex flex-col overflow-y-auto p-4 gap-6">
<div>
<h3 class="text-xs text-az-text uppercase tracking-wide mb-3">AI Recognition Settings</h3>
<div class="bg-az-panel rounded border border-az-border p-4 space-y-3 max-w-md">
<div class="flex items-center justify-between">
<label class="text-sm text-az-text"># Frames To Recognize</label>
<input type="number" value="4" class="w-20 bg-az-border border border-az-muted text-white px-2.5 py-1.5 rounded text-sm text-right">
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-az-text">Min Seconds Between Recognition</label>
<input type="number" value="2" class="w-20 bg-az-border border border-az-muted text-white px-2.5 py-1.5 rounded text-sm text-right">
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-az-text">Min Confidence Threshold (%)</label>
<input type="number" value="25" class="w-20 bg-az-border border border-az-muted text-white px-2.5 py-1.5 rounded text-sm text-right">
</div>
</div>
</div>
<div>
<h3 class="text-xs text-az-text uppercase tracking-wide mb-3">GPS Device Settings</h3>
<div class="bg-az-panel rounded border border-az-border p-4 space-y-3 max-w-md">
<div>
<label class="block text-[11px] text-az-text mb-1">Device Address</label>
<input type="text" placeholder="192.168.1.100" class="w-full bg-az-border border border-az-muted text-white px-2.5 py-1.5 rounded text-sm">
</div>
<div>
<label class="block text-[11px] text-az-text mb-1">Device Port</label>
<input type="number" placeholder="9001" class="w-full bg-az-border border border-az-muted text-white px-2.5 py-1.5 rounded text-sm">
</div>
<div>
<label class="block text-[11px] text-az-text mb-1">Protocol</label>
<select class="w-full bg-az-border border border-az-muted text-white px-2.5 py-1.5 rounded text-sm">
<option>NMEA</option><option>UBX</option><option>MAVLink</option>
</select>
</div>
</div>
</div>
</div>
<div class="w-[280px] bg-az-panel border-l border-az-border flex flex-col shrink-0">
<h3 class="px-3 py-2.5 text-xs text-az-text uppercase tracking-wide border-b border-az-border">Default Aircrafts</h3>
<div class="flex-1 overflow-y-auto">
<div class="flex items-center gap-2 px-3 py-2.5 text-sm border-b border-neutral-800 bg-az-border">
<span class="w-5 h-5 rounded bg-az-blue flex items-center justify-center text-[9px]">P</span>
<span>DJI Mavic 3</span>
<span class="ml-auto text-az-orange text-xs"></span>
</div>
<div class="flex items-center gap-2 px-3 py-2.5 text-sm border-b border-neutral-800 hover:bg-neutral-700">
<span class="w-5 h-5 rounded bg-az-green flex items-center justify-center text-[9px]">C</span>
<span>Matrice 300 RTK</span>
</div>
<div class="flex items-center gap-2 px-3 py-2.5 text-sm border-b border-neutral-800 hover:bg-neutral-700">
<span class="w-5 h-5 rounded bg-az-orange flex items-center justify-center text-[9px]">P</span>
<span>Fixed Wing Scout</span>
</div>
</div>
</div>
</div>
</body>
</html>
+161
View File
@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Azaion Annotations Tab Wireframe</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
az: { bg: '#1e1e1e', panel: '#2b2b2b', header: '#343a40', border: '#495057', muted: '#6c757d', text: '#adb5bd', orange: '#fd7e14', blue: '#228be6', red: '#fa5252', green: '#40c057' }
}
}
}
}
</script>
</head>
<body class="bg-az-bg text-white h-screen flex flex-col font-sans">
<header class="flex items-center bg-az-header px-4 py-2 gap-2 border-b border-az-border">
<span class="font-extrabold text-az-orange tracking-wider">AZAION</span>
<span class="bg-az-border border border-az-muted text-white px-2.5 py-1 rounded text-sm">FL03 <span class="text-az-orange ml-1"></span></span>
<nav class="flex gap-0.5 ml-4">
<a href="flights.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Flights</a>
<a href="annotations.html" class="px-4 py-1.5 text-sm bg-az-bg text-white font-semibold rounded-t">Annotations</a>
<a href="dataset_explorer.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Dataset Explorer</a>
<a href="admin.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Admin</a>
</nav>
<div class="ml-auto flex items-center gap-3 text-sm text-az-text">
<span>user@azaion.com</span>
<a href="settings.html" class="hover:text-white"></a>
<a href="#" class="hover:text-white"></a>
</div>
</header>
<div class="flex flex-1 overflow-hidden">
<aside class="w-64 bg-az-panel border-r border-az-border flex flex-col shrink-0">
<h3 class="px-3 py-2.5 text-xs text-az-text uppercase tracking-wide border-b border-az-border">Media Files</h3>
<div class="flex-1 overflow-y-auto min-h-0">
<div class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 bg-az-border">
<span class="w-4 h-4 rounded-sm bg-az-blue flex items-center justify-center text-[9px]">P</span> Photobook1
</div>
<div class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span class="w-4 h-4 rounded-sm bg-az-orange flex items-center justify-center text-[9px]">V</span> Video 02
</div>
<div class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span class="w-4 h-4 rounded-sm bg-az-orange flex items-center justify-center text-[9px]">V</span> Video 03
</div>
<div class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span class="w-4 h-4 rounded-sm bg-az-blue flex items-center justify-center text-[9px]">P</span> Photo002
</div>
<div class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span class="w-4 h-4 rounded-sm bg-az-blue flex items-center justify-center text-[9px]">P</span> Photo003
</div>
<div class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span class="w-4 h-4 rounded-sm bg-az-orange flex items-center justify-center text-[9px]">V</span> Video 04
</div>
</div>
<h3 class="px-3 py-2.5 text-xs text-az-text uppercase tracking-wide border-b border-az-border">Detection Classes</h3>
<div class="overflow-y-auto">
<div class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span class="inline-block w-2.5 h-2.5 rounded-sm flex-shrink-0" style="background:#FF0000"></span> MilVeh
</div>
<div class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span class="inline-block w-2.5 h-2.5 rounded-sm flex-shrink-0" style="background:#00FF00"></span> Truck
</div>
<div class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span class="inline-block w-2.5 h-2.5 rounded-sm flex-shrink-0" style="background:#0000FF"></span> Vehicle
</div>
<div class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span class="inline-block w-2.5 h-2.5 rounded-sm flex-shrink-0" style="background:#FFFF00"></span> Artillery
</div>
<div class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span class="inline-block w-2.5 h-2.5 rounded-sm flex-shrink-0" style="background:#FF00FF"></span> Shadow
</div>
<div class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span class="inline-block w-2.5 h-2.5 rounded-sm flex-shrink-0" style="background:#00FFFF"></span> Trenches
</div>
</div>
<div class="p-3 border-t border-az-border">
<label class="block text-az-text mb-1.5 text-[11px] uppercase tracking-wide">PhotoMode</label>
<div class="flex gap-2">
<label class="flex items-center gap-1.5 cursor-pointer text-sm">
<input type="radio" name="photomode" checked class="rounded-full"> Regular
</label>
<label class="flex items-center gap-1.5 cursor-pointer text-sm">
<input type="radio" name="photomode" class="rounded-full"> Winter
</label>
<label class="flex items-center gap-1.5 cursor-pointer text-sm">
<input type="radio" name="photomode" class="rounded-full"> Night
</label>
</div>
</div>
</aside>
<main class="flex-1 flex flex-col">
<div class="flex-1 relative bg-neutral-900 flex items-center justify-center overflow-hidden">
<div class="w-[90%] aspect-video bg-gradient-to-br from-[#2d4a3e] via-[#3a5548] to-[#4a6858] rounded relative overflow-hidden">
<div class="absolute top-[35%] inset-x-0 h-px bg-white/10"></div>
<div class="absolute top-[25%] left-[30%] w-[20%] h-[30%] border-2 border-az-red rounded-sm">
<span class="absolute -top-5 left-0 bg-az-red text-white text-[10px] px-1.5 py-px rounded whitespace-nowrap">Mil. vehicle</span>
</div>
<div class="absolute top-[40%] left-[60%] w-[15%] h-[25%] border-2 border-az-orange rounded-sm">
<span class="absolute -top-5 left-0 bg-az-orange text-white text-[10px] px-1.5 py-px rounded whitespace-nowrap">Mil. vehicle</span>
</div>
</div>
</div>
<div class="bg-az-panel border-t border-az-border px-3 py-1.5">
<div class="w-full h-1 bg-az-border rounded mb-2 cursor-pointer">
<div class="w-[35%] h-full bg-az-blue rounded"></div>
</div>
<div class="flex items-center justify-center gap-1">
<button class="bg-az-border text-white w-8 h-7 rounded text-xs flex items-center justify-center hover:bg-az-muted"></button>
<button class="bg-az-border text-white w-8 h-7 rounded text-xs flex items-center justify-center hover:bg-az-muted"></button>
<button class="bg-az-border text-white w-8 h-7 rounded text-xs flex items-center justify-center hover:bg-az-muted"></button>
<button class="bg-az-blue text-white w-8 h-7 rounded text-xs flex items-center justify-center"></button>
<button class="bg-az-border text-white w-8 h-7 rounded text-xs flex items-center justify-center hover:bg-az-muted">▶▶</button>
<button class="bg-az-border text-white w-8 h-7 rounded text-xs flex items-center justify-center hover:bg-az-muted"></button>
<span class="w-6"></span>
<button class="bg-az-border text-white w-8 h-7 rounded text-xs flex items-center justify-center hover:bg-az-muted">1</button>
<button class="bg-az-border text-white w-8 h-7 rounded text-xs flex items-center justify-center hover:bg-az-muted">5</button>
<button class="bg-az-border text-white w-8 h-7 rounded text-xs flex items-center justify-center hover:bg-az-muted">10</button>
<button class="bg-az-border text-white w-8 h-7 rounded text-xs flex items-center justify-center hover:bg-az-muted">30</button>
<button class="bg-az-border text-white w-8 h-7 rounded text-xs flex items-center justify-center hover:bg-az-muted">60</button>
</div>
</div>
</main>
<aside class="w-52 bg-az-panel border-l border-az-border flex flex-col shrink-0">
<h3 class="px-3 py-2.5 text-xs text-az-text uppercase tracking-wide border-b border-az-border">Annotations</h3>
<div class="flex-1 overflow-y-auto">
<div class="flex justify-between items-center px-3 py-2 text-sm border-b border-neutral-800 cursor-pointer hover:opacity-90" style="background:linear-gradient(90deg,rgba(255,0,0,0.25),transparent)">
<span class="text-az-text text-xs">00:12</span>
<span>MilVeh</span>
</div>
<div class="flex justify-between items-center px-3 py-2 text-sm border-b border-neutral-800 cursor-pointer hover:opacity-90" style="background:linear-gradient(90deg,rgba(0,255,0,0.25),transparent)">
<span class="text-az-text text-xs">00:18</span>
<span>Truck</span>
</div>
<div class="flex justify-between items-center px-3 py-2 text-sm border-b border-neutral-800 cursor-pointer hover:opacity-90" style="background:linear-gradient(90deg,rgba(0,0,255,0.25),transparent)">
<span class="text-az-text text-xs">00:24</span>
<span>Vehicle</span>
</div>
<div class="flex justify-between items-center px-3 py-2 text-sm border-b border-neutral-800 cursor-pointer hover:opacity-90" style="background:linear-gradient(90deg,rgba(255,255,0,0.25),transparent)">
<span class="text-az-text text-xs">00:31</span>
<span>Artillery</span>
</div>
<div class="flex justify-between items-center px-3 py-2 text-sm border-b border-neutral-800 cursor-pointer hover:opacity-90" style="background:linear-gradient(90deg,rgba(255,0,0,0.25),transparent)">
<span class="text-az-text text-xs">00:45</span>
<span>MilVeh</span>
</div>
</div>
</aside>
</div>
</body>
</html>
+166
View File
@@ -0,0 +1,166 @@
<!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 Wireframe</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
az: { bg: '#1e1e1e', panel: '#2b2b2b', header: '#343a40', border: '#495057', muted: '#6c757d', text: '#adb5bd', orange: '#fd7e14', blue: '#228be6', red: '#fa5252', green: '#40c057' }
}
}
}
}
</script>
</head>
<body class="bg-az-bg text-white h-screen flex flex-col font-sans">
<header class="flex items-center bg-az-header px-4 py-2 gap-2 border-b border-az-border">
<span class="font-extrabold text-az-orange tracking-wider">AZAION</span>
<span class="bg-az-border border border-az-muted text-white px-2.5 py-1 rounded text-sm">FL03 <span class="text-az-orange ml-1"></span></span>
<nav class="flex gap-0.5 ml-4">
<a href="flights.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Flights</a>
<a href="annotations.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Annotations</a>
<a href="dataset_explorer.html" class="px-4 py-1.5 text-sm bg-az-bg text-white font-semibold rounded-t">Dataset Explorer</a>
<a href="admin.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Admin</a>
</nav>
<div class="ml-auto flex items-center gap-3 text-sm text-az-text">
<span>user@azaion.com</span>
<a href="settings.html" class="hover:text-white"></a>
<a href="#" class="hover:text-white"></a>
</div>
</header>
<div class="flex-1 flex overflow-hidden p-4 gap-3">
<aside class="w-64 shrink-0 flex flex-col gap-3 border-r border-az-border pr-3">
<h2 class="text-sm font-semibold text-white">Detection Classes</h2>
<ul class="flex flex-col gap-1.5 text-sm text-az-text">
<li class="flex items-center gap-2"><span class="w-2.5 h-2.5 rounded-full shrink-0" style="background:#fa5252"></span>ArmorVehicle</li>
<li class="flex items-center gap-2"><span class="w-2.5 h-2.5 rounded-full shrink-0" style="background:#40c057"></span>Truck</li>
<li class="flex items-center gap-2"><span class="w-2.5 h-2.5 rounded-full shrink-0" style="background:#228be6"></span>Vehicle</li>
<li class="flex items-center gap-2"><span class="w-2.5 h-2.5 rounded-full shrink-0" style="background:#FFFF00"></span>Artillery</li>
<li class="flex items-center gap-2"><span class="w-2.5 h-2.5 rounded-full shrink-0" style="background:#FF00FF"></span>Shadow</li>
<li class="flex items-center gap-2"><span class="w-2.5 h-2.5 rounded-full shrink-0" style="background:#00FFFF"></span>Trenches</li>
</ul>
<label class="flex items-center gap-2 text-sm text-az-text cursor-pointer">
<input type="checkbox" class="rounded border-az-border bg-az-panel text-az-blue">
Show with objects only
</label>
<input type="text" placeholder="Search..." class="bg-az-border border border-az-muted text-white px-2.5 py-1 rounded text-sm w-full placeholder:text-az-muted">
</aside>
<div class="flex-1 flex flex-col overflow-hidden gap-3 min-w-0">
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-1">
<span class="text-[11px] text-az-text uppercase tracking-wide">Date</span>
<input type="date" value="2025-02-09" class="bg-az-border border border-az-muted text-white px-2.5 py-1 rounded text-sm w-36">
<input type="date" value="2025-02-11" class="bg-az-border border border-az-muted text-white px-2.5 py-1 rounded text-sm w-36">
</div>
<div class="w-px h-6 bg-az-border mx-1"></div>
<div class="flex items-center gap-1">
<span class="text-[11px] text-az-text uppercase tracking-wide">Flight</span>
<select class="bg-az-border border border-az-muted text-white px-2.5 py-1 rounded text-sm min-w-[120px]">
<option>All</option>
</select>
</div>
<div class="w-px h-6 bg-az-border mx-1"></div>
<button class="px-3 py-1 rounded-full text-xs border border-az-blue bg-az-blue text-white">Created</button>
<button class="px-3 py-1 rounded-full text-xs border border-az-muted text-az-text hover:border-az-text hover:text-white">Edited / None</button>
<button class="px-3 py-1 rounded-full text-xs border border-az-blue bg-az-blue text-white">Validated</button>
</div>
<div class="flex-1 overflow-y-auto">
<div class="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2">
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#3a5548] to-[#2d4a3e]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-green text-white">Validated</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#4a5568] to-[#2d3748]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-orange text-white">Created</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#553c2e] to-[#44322a]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-green text-white">Validated</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#2d4a3e] to-[#3a5548]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-blue text-white">Edited</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#3a4a58] to-[#2d3748]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-border text-az-text">None</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#4a5548] to-[#3a4538]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-green text-white">Validated</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#553c2e] to-[#44322a]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-orange text-white">Created</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#2d4a3e] to-[#4a5548]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-green text-white">Validated</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#3a5548] to-[#2d4a3e]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-blue text-white">Edited</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#4a5568] to-[#2d3748]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-border text-az-text">None</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#2d4a3e] to-[#3a5548]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-green text-white">Validated</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#553c2e] to-[#44322a]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-orange text-white">Created</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#3a4a58] to-[#2d3748]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-green text-white">Validated</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#4a5548] to-[#3a4538]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-blue text-white">Edited</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#3a5548] to-[#2d4a3e]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-border text-az-text">None</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#2d4a3e] to-[#553c2e]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-green text-white">Validated</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#4a5568] to-[#3a4a58]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-orange text-white">Created</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#3a5548] to-[#4a5548]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-green text-white">Validated</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#553c2e] to-[#2d4a3e]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-blue text-white">Edited</span>
</div>
<div class="aspect-square bg-az-panel rounded border border-az-border hover:border-az-blue cursor-pointer relative transition-colors">
<div class="w-full h-full flex items-center justify-center"><div class="w-3/5 h-3/5 rounded bg-gradient-to-br from-[#2d3748] to-[#4a5568]"></div></div>
<span class="absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full bg-az-border text-az-text">None</span>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
+214
View File
@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Azaion Flights Tab Wireframe</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
az: { bg: '#1e1e1e', panel: '#2b2b2b', header: '#343a40', border: '#495057', muted: '#6c757d', text: '#adb5bd', orange: '#fd7e14', blue: '#228be6', red: '#fa5252', green: '#40c057' }
}
}
}
}
</script>
</head>
<body class="bg-az-bg text-white h-screen flex flex-col font-sans">
<header class="flex items-center bg-az-header px-4 py-2 gap-2 border-b border-az-border">
<span class="font-extrabold text-az-orange tracking-wider">AZAION</span>
<span class="bg-az-border border border-az-muted text-white px-2.5 py-1 rounded text-sm">FL02 <span class="text-az-orange ml-1"></span></span>
<nav class="flex gap-0.5 ml-4">
<a href="flights.html" class="px-4 py-1.5 text-sm bg-az-bg text-white font-semibold rounded-t">Flights</a>
<a href="annotations.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Annotations</a>
<a href="dataset_explorer.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Dataset Explorer</a>
<a href="admin.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Admin</a>
</nav>
<div class="ml-auto flex items-center gap-3 text-sm text-az-text">
<span>user@azaion.com</span>
<a href="settings.html" class="hover:text-white"></a>
<a href="#" class="hover:text-white"></a>
</div>
</header>
<div class="flex flex-1 overflow-hidden">
<aside class="w-[200px] bg-az-panel border-r border-az-border flex flex-col shrink-0">
<h3 class="px-3 py-2.5 text-xs text-az-text uppercase tracking-wide border-b border-az-border">Flights</h3>
<div class="flex-1 overflow-y-auto">
<div class="flex justify-between items-center px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 bg-az-border">
<span>FL02</span><span class="text-az-orange text-xs"></span>
</div>
<div class="flex justify-between items-center px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span>FL01</span>
</div>
<div class="flex justify-between items-center px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span>FL03</span>
</div>
<div class="flex justify-between items-center px-3 py-2 text-sm cursor-pointer border-b border-neutral-800 hover:bg-neutral-700">
<span>FL04</span>
</div>
</div>
<button class="mx-3 my-2 py-1.5 bg-az-blue text-white rounded text-xs hover:brightness-110">+ Create New</button>
<div class="border-t border-az-border p-3">
<label class="block text-[11px] text-az-text uppercase tracking-wide mb-1.5">Telemetry</label>
<input type="date" value="2025-03-01" class="w-full bg-az-border border border-az-muted text-white px-2 py-1 rounded text-xs">
</div>
</aside>
<div class="w-[260px] bg-az-panel border-r border-az-border overflow-y-auto shrink-0 relative">
<div id="flightParams" class="p-3">
<h3 class="text-xs text-az-text uppercase tracking-wide mb-3">Flight Parameters</h3>
<div class="mb-2.5">
<label class="block text-[11px] text-az-text mb-1">Aircraft</label>
<select class="w-full bg-az-border border border-az-muted text-white px-2 py-1.5 rounded text-xs">
<option>Select aircraft...</option><option>DJI Mavic 3</option>
</select>
</div>
<div class="mb-2.5">
<label class="block text-[11px] text-az-text mb-1">Default Height (m)</label>
<input type="number" value="100" class="w-full bg-az-border border border-az-muted text-white px-2 py-1.5 rounded text-xs">
</div>
<div class="mb-2.5">
<label class="block text-[11px] text-az-text mb-1">Camera FOV / Length / Field</label>
<input type="text" placeholder="FOV parameters" class="w-full bg-az-border border border-az-muted text-white px-2 py-1.5 rounded text-xs">
</div>
<div class="mb-4">
<label class="block text-[11px] text-az-text mb-1">Communication Addr / Port</label>
<input type="text" placeholder="192.168.1.1:8080" class="w-full bg-az-border border border-az-muted text-white px-2 py-1.5 rounded text-xs">
</div>
<h3 class="text-xs text-az-text uppercase tracking-wide mb-2">Waypoints</h3>
<div class="space-y-0">
<div class="flex items-center gap-2 py-1.5 text-xs border-b border-neutral-800">
<span class="text-az-orange font-semibold min-w-[24px]">A1</span> Start
</div>
<div class="flex items-center gap-2 py-1.5 text-xs border-b border-neutral-800">
<span class="text-az-orange font-semibold min-w-[24px]">A1</span> Point1 <span class="text-az-text text-[11px] ml-auto">Track Conf</span>
</div>
<div class="flex items-center gap-2 py-1.5 text-xs border-b border-neutral-800">
<span class="text-az-orange font-semibold min-w-[24px]">A1</span> Point2 <span class="text-az-text text-[11px] ml-auto">MilVeh</span>
</div>
<div class="flex items-center gap-2 py-1.5 text-xs border-b border-neutral-800">
<span class="text-az-orange font-semibold min-w-[24px]">A3</span> Point3
</div>
<div class="flex items-center gap-2 py-1.5 text-xs border-b border-neutral-800">
<span class="text-az-orange font-semibold min-w-[24px]">A3</span> Point4 <span class="text-az-text text-[11px] ml-auto">Con</span>
</div>
<div class="flex items-center gap-2 py-1.5 text-xs border-b border-neutral-800">
<span class="text-az-orange font-semibold min-w-[24px]"></span> Finish
</div>
</div>
<div class="flex gap-1.5 mt-2.5">
<button onclick="toggleGpsDenied()" class="px-2.5 py-1 rounded border border-az-red text-az-red text-[11px] hover:bg-az-red/10">GPS-Denied</button>
<button class="px-2.5 py-1 rounded border border-az-green text-az-green text-[11px] hover:bg-az-green/10">Upload</button>
</div>
</div>
<div id="gpsDenied" class="hidden p-3">
<div class="flex items-center justify-between px-0 py-0 mb-3">
<h3 class="text-xs text-az-text uppercase tracking-wide">GPS-Denied</h3>
<span class="px-2 py-0.5 rounded-full text-[10px] border border-az-red text-az-red">Active</span>
</div>
<div class="mb-3">
<h4 class="text-[11px] text-az-text uppercase tracking-wide mb-2">Orthophoto Upload</h4>
<div class="space-y-1.5">
<div class="flex items-center gap-2 bg-az-border rounded px-2.5 py-2 text-xs">
<span class="w-5 h-5 rounded bg-az-blue flex items-center justify-center text-[9px] shrink-0">P</span>
<span class="flex-1 truncate">ortho_001.jpg</span>
<span class="text-az-text text-[10px] shrink-0">48.85,2.35</span>
</div>
<div class="flex items-center gap-2 bg-az-border rounded px-2.5 py-2 text-xs">
<span class="w-5 h-5 rounded bg-az-blue flex items-center justify-center text-[9px] shrink-0">P</span>
<span class="flex-1 truncate">ortho_002.jpg</span>
<span class="text-az-text text-[10px] shrink-0">48.86,2.36</span>
</div>
<div class="flex items-center gap-2 bg-az-border rounded px-2.5 py-2 text-xs">
<span class="w-5 h-5 rounded bg-az-blue flex items-center justify-center text-[9px] shrink-0">P</span>
<span class="flex-1 truncate">ortho_003.jpg</span>
<span class="text-az-text text-[10px] shrink-0">48.87,2.37</span>
</div>
</div>
<button class="w-full mt-2 py-1.5 border border-dashed border-az-muted text-az-text text-xs rounded hover:border-az-blue hover:text-white">+ Upload Photos</button>
</div>
<div class="mb-3">
<h4 class="text-[11px] text-az-text uppercase tracking-wide mb-2">Live GPS</h4>
<div class="bg-az-border rounded p-2.5 text-xs space-y-1.5">
<div class="flex justify-between">
<span class="text-az-text">Status</span>
<span class="text-az-green">Connected</span>
</div>
<div class="flex justify-between">
<span class="text-az-text">Lat</span>
<span>48.8566</span>
</div>
<div class="flex justify-between">
<span class="text-az-text">Lon</span>
<span>2.3522</span>
</div>
<div class="flex justify-between">
<span class="text-az-text">Satellites</span>
<span>12</span>
</div>
</div>
</div>
<div class="mb-3">
<h4 class="text-[11px] text-az-text uppercase tracking-wide mb-2">GPS Correction</h4>
<div class="space-y-2">
<div>
<label class="block text-[11px] text-az-text mb-1">Waypoint #</label>
<input type="number" value="3" class="w-full bg-az-border border border-az-muted text-white px-2 py-1.5 rounded text-xs">
</div>
<div>
<label class="block text-[11px] text-az-text mb-1">Corrected GPS</label>
<input type="text" placeholder="48.8566, 2.3522" class="w-full bg-az-border border border-az-muted text-white px-2 py-1.5 rounded text-xs">
</div>
<button class="w-full py-1.5 bg-az-blue text-white rounded text-xs hover:brightness-110">Apply Correction</button>
</div>
</div>
<button onclick="toggleGpsDenied()" class="w-full py-1.5 border border-az-border text-az-text text-xs rounded hover:bg-az-border hover:text-white">Back to Flight</button>
</div>
</div>
<div class="flex-1 bg-neutral-900 relative overflow-hidden">
<div class="w-full h-full bg-gradient-to-b from-[#1a2a1a] via-[#2a3a2a] to-[#1a2a1a] relative">
<div class="absolute inset-0" style="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"></div>
<svg class="absolute inset-0 w-full h-full" viewBox="0 0 800 600" preserveAspectRatio="none">
<polyline points="150,450 250,350 350,280 450,320 550,250 650,200" fill="none" stroke="#fa5252" stroke-width="2" stroke-dasharray="6,4"/>
<polyline points="150,460 255,358 360,290 455,328 555,260 650,210" fill="none" stroke="#40c057" stroke-width="1.5" opacity="0.6"/>
</svg>
<div class="absolute w-3 h-3 bg-az-green border-2 border-white rounded-full -translate-x-1/2 -translate-y-1/2" style="left:18.75%;top:75%"></div>
<div class="absolute w-3 h-3 bg-az-blue border-2 border-white rounded-full -translate-x-1/2 -translate-y-1/2" style="left:31.25%;top:58.3%"></div>
<div class="absolute w-3 h-3 bg-az-blue border-2 border-white rounded-full -translate-x-1/2 -translate-y-1/2" style="left:43.75%;top:46.7%"></div>
<div class="absolute w-3 h-3 bg-az-blue border-2 border-white rounded-full -translate-x-1/2 -translate-y-1/2" style="left:56.25%;top:53.3%"></div>
<div class="absolute w-3 h-3 bg-az-blue border-2 border-white rounded-full -translate-x-1/2 -translate-y-1/2" style="left:68.75%;top:41.7%"></div>
<div class="absolute w-3 h-3 bg-az-red border-2 border-white rounded-full -translate-x-1/2 -translate-y-1/2" style="left:81.25%;top:33.3%"></div>
<div class="absolute top-3 right-3 bg-az-panel/90 border border-az-border rounded px-3 py-2 text-[10px]">
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-az-red"></span> Original path</div>
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-az-green"></span> Corrected path</div>
</div>
<span class="absolute bottom-2 right-2 text-[11px] text-az-muted">~800px</span>
</div>
</div>
</div>
<script>
function toggleGpsDenied() {
const fp = document.getElementById('flightParams');
const gps = document.getElementById('gpsDenied');
fp.classList.toggle('hidden');
gps.classList.toggle('hidden');
}
</script>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Azaion Settings Tab Wireframe</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
az: { bg: '#1e1e1e', panel: '#2b2b2b', header: '#343a40', border: '#495057', muted: '#6c757d', text: '#adb5bd', orange: '#fd7e14', blue: '#228be6', red: '#fa5252', green: '#40c057' }
}
}
}
}
</script>
</head>
<body class="bg-az-bg text-white h-screen flex flex-col font-sans">
<header class="flex items-center bg-az-header px-4 py-2 gap-2 border-b border-az-border">
<span class="font-extrabold text-az-orange tracking-wider">AZAION</span>
<span class="bg-az-border border border-az-muted text-white px-2.5 py-1 rounded text-sm">FL02 <span class="text-az-orange ml-1"></span></span>
<nav class="flex gap-0.5 ml-4">
<a href="flights.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Flights</a>
<a href="annotations.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Annotations</a>
<a href="dataset_explorer.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Dataset Explorer</a>
<a href="admin.html" class="px-4 py-1.5 text-sm text-az-text hover:text-white rounded-t">Admin</a>
</nav>
<div class="ml-auto flex items-center gap-3 text-sm text-az-text">
<span>user@azaion.com</span>
<a href="settings.html" class="hover:text-white text-white font-semibold"></a>
<a href="#" class="hover:text-white"></a>
</div>
</header>
<div class="flex flex-1 overflow-hidden p-4 gap-6">
<div class="w-[300px] shrink-0 space-y-5">
<div>
<h3 class="text-xs text-az-text uppercase tracking-wide mb-3">Tenant</h3>
<div class="bg-az-panel rounded border border-az-border p-4 space-y-3">
<div>
<label class="block text-[11px] text-az-text mb-1">Military Unit</label>
<input type="text" value="72nd Brigade" class="w-full bg-az-border border border-az-muted text-white px-2.5 py-1.5 rounded text-sm">
</div>
<div>
<label class="block text-[11px] text-az-text mb-1">Name</label>
<input type="text" value="Alpha Company" class="w-full bg-az-border border border-az-muted text-white px-2.5 py-1.5 rounded text-sm">
</div>
<div>
<label class="block text-[11px] text-az-text mb-1">Default Camera Width (px)</label>
<input type="number" value="1920" class="w-full bg-az-border border border-az-muted text-white px-2.5 py-1.5 rounded text-sm">
</div>
<div>
<label class="block text-[11px] text-az-text mb-1">Default Camera FoV (°)</label>
<input type="number" value="84" class="w-full bg-az-border border border-az-muted text-white px-2.5 py-1.5 rounded text-sm">
</div>
</div>
</div>
</div>
<div class="w-[300px] shrink-0">
<h3 class="text-xs text-az-text uppercase tracking-wide mb-3">Directories</h3>
<div class="bg-az-panel rounded border border-az-border p-4 space-y-3">
<div>
<label class="block text-[11px] text-az-text mb-1">Images Dir</label>
<input type="text" value="/data/images" class="w-full bg-az-border border border-az-muted text-white px-2.5 py-1.5 rounded text-sm">
</div>
<div>
<label class="block text-[11px] text-az-text mb-1">Labels Dir</label>
<input type="text" value="/data/labels" class="w-full bg-az-border border border-az-muted text-white px-2.5 py-1.5 rounded text-sm">
</div>
<div>
<label class="block text-[11px] text-az-text mb-1">Thumbnails Dir</label>
<input type="text" value="/data/thumbnails" class="w-full bg-az-border border border-az-muted text-white px-2.5 py-1.5 rounded text-sm">
</div>
</div>
</div>
<div class="flex-1">
<h3 class="text-xs text-az-text uppercase tracking-wide mb-3">Aircrafts</h3>
<div class="bg-az-panel rounded border border-az-border overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="text-az-text text-[11px] uppercase tracking-wide border-b border-az-border">
<th class="px-3 py-2 text-left">Model</th>
<th class="px-3 py-2 text-left">Type</th>
<th class="px-3 py-2 text-center w-20">Default</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-neutral-800 bg-az-border">
<td class="px-3 py-2.5">DJI Mavic 3</td>
<td class="px-3 py-2.5 text-az-text">Plane</td>
<td class="px-3 py-2.5 text-center"><span class="text-az-orange"></span></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2.5">Matrice 300 RTK</td>
<td class="px-3 py-2.5 text-az-text">Copter</td>
<td class="px-3 py-2.5 text-center"><span class="text-az-muted"></span></td>
</tr>
<tr class="border-b border-neutral-800 hover:bg-neutral-700">
<td class="px-3 py-2.5">Fixed Wing Scout</td>
<td class="px-3 py-2.5 text-az-text">Plane</td>
<td class="px-3 py-2.5 text-center"><span class="text-az-muted"></span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
File diff suppressed because one or more lines are too long
+63
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AZAION</title>
<script type="module" crossorigin src="/assets/index-o2ENlayJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index--amdfC0Y.css">
</head>
<body class="bg-[#1e1e1e] text-[#adb5bd]">
<div id="root"></div>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AZAION</title>
</head>
<body class="bg-[#1e1e1e] text-[#adb5bd]">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/annotations/ {
proxy_pass http://annotations:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400;
}
location /api/flights/ {
proxy_pass http://flights:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/admin/ {
proxy_pass http://admin:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/resource/ {
proxy_pass http://resource:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/detect/ {
proxy_pass http://detections:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 600;
}
location /api/loader/ {
proxy_pass http://loader:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/gps-denied-desktop/ {
proxy_pass http://gps-denied-desktop:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/gps-denied-onboard/ {
proxy_pass http://gps-denied-onboard:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/autopilot/ {
proxy_pass http://autopilot:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
try_files $uri $uri/ /index.html;
}
client_max_body_size 500M;
}
+2292 -1407
View File
File diff suppressed because it is too large Load Diff
+21 -45
View File
@@ -1,54 +1,30 @@
{
"name": "azaion.webui",
"version": "0.1.1",
"name": "azaion-ui",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/material": "^7.0.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^13.5.0",
"http-proxy-middleware": "^3.0.1",
"i18next": "^24.2.2",
"leaflet": "^1.9.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.8",
"react-icons": "^5.5.0",
"react-router-dom": "^6.26.2",
"react-scripts": "^5.0.1",
"web-vitals": "^2.1.4",
"yarn": "^1.22.22"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"react-i18next": "^15.4.1",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.4.0"
},
"devDependencies": {
"@playwright/test": "^1.55.1",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"typescript": "^4.9.5"
"@tailwindcss/vite": "^4.1.1",
"@types/leaflet": "^1.9.17",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.1.1",
"typescript": "~5.7.2",
"vite": "^6.2.0"
}
}
-79
View File
@@ -1,79 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});
-28
View File
@@ -1,28 +0,0 @@
{
"classes": [
{ "Id": 0, "Name": "Heavy truck" },
{ "Id": 1, "Name": "Truck" },
{ "Id": 2, "Name": "Car" },
{ "Id": 3, "Name": "Big Foot" },
{ "Id": 4, "Name": "Shadow" },
{ "Id": 5, "Name": "Pit" },
{ "Id": 6, "Name": "Person" },
{ "Id": 7, "Name": "TyreTracks" },
{ "Id": 8, "Name": "Bus" },
{ "Id": 9, "Name": "Smoke" },
{ "Id": 10, "Name": "Plane" },
{ "Id": 11, "Name": "Moto" },
{ "Id": 12, "Name": "Mesh" },
{ "Id": 13, "Name": "Branches" }
],
"colors": [
"#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#000000",
"#800000", "#008000", "#000080", "#808000", "#800080", "#008080", "#808080",
"#C00000", "#00C000", "#0000C0", "#C0C000", "#C000C0", "#00C0C0", "#C0C0C0",
"#400000", "#004000", "#000040", "#404000", "#400040", "#004040", "#404040",
"#200000", "#002000", "#000020", "#202000", "#200020", "#002020", "#202020",
"#600000", "#006000", "#000060", "#606000", "#600060", "#006060", "#606060",
"#A00000", "#00A000", "#0000A0", "#A0A000", "#A000A0", "#00A0A0", "#A0A0A0",
"#E00000", "#00E000", "#0000E0", "#E0E000", "#E000E0", "#00E0E0", "#E0E0E0"
]
}
-24
View File
@@ -1,24 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo128.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Azaion Suite</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
-128
View File
@@ -1,128 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 299.9 317.1" style="enable-background:new 0 0 299.9 317.1;" xml:space="preserve">
<style type="text/css">
.st0{fill:#2668B0;}
.st1{fill:#DEC354;}
.st2{fill:url(#SVGID_1_);}
.st3{fill:url(#SVGID_00000165956440124760579960000011888077842116744085_);}
.st4{fill:url(#SVGID_00000085942576275962151710000009003104917087656334_);}
.st5{fill:url(#SVGID_00000065057939898373943770000009142597344814158991_);}
.st6{fill:url(#SVGID_00000042707575094020397520000008210262669309136559_);}
.st7{fill:url(#SVGID_00000132081218125063996810000014374475908356398251_);}
.st8{fill:#FFFFFF;}
</style>
<g id="Layer_2_00000152245310683770285410000018119804670128610436_">
<polygon class="st0" points="0.1,169.5 12.1,166.5 12.1,51.2 0,20.1 "/>
<polygon class="st1" points="241,169.1 229.1,166.1 229,50.7 241.1,19.7 "/>
<polygon class="st0" points="119.6,153.6 107.7,158.3 107.7,18 119.8,0 "/>
<polygon class="st1" points="119.5,153.8 131.8,158.3 131.9,18 119.8,0 "/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="120.3286" y1="259.6066" x2="120.3286" y2="78.8283" gradientTransform="matrix(1 0 0 -1 0 320)">
<stop offset="3.000000e-02" style="stop-color:#FFFFFF"/>
<stop offset="0.1" style="stop-color:#EDEDED"/>
<stop offset="0.26" style="stop-color:#C0BEBF"/>
<stop offset="0.48" style="stop-color:#767474"/>
<stop offset="0.7" style="stop-color:#231F20"/>
</linearGradient>
<polygon class="st2" points="0.1,169.5 47,153.1 82.2,166.3 102.5,157.3 119.5,149.8 157.9,166.7 194,153.3 240.6,168.8 121.1,255
"/>
<linearGradient id="SVGID_00000114788463727787464510000017845518411005303225_" gradientUnits="userSpaceOnUse" x1="58.437" y1="149.7064" x2="68.2775" y2="175.7949" gradientTransform="matrix(1 0 0 -1 0 320)">
<stop offset="0.27" style="stop-color:#231F20"/>
<stop offset="0.28" style="stop-color:#2C2829"/>
<stop offset="0.37" style="stop-color:#767474"/>
<stop offset="0.44" style="stop-color:#B1AFB0"/>
<stop offset="0.51" style="stop-color:#DBDADA"/>
<stop offset="0.56" style="stop-color:#F5F5F5"/>
<stop offset="0.59" style="stop-color:#FFFFFF"/>
</linearGradient>
<polygon style="fill:url(#SVGID_00000114788463727787464510000017845518411005303225_);" points="47,153.1 42,154.8 76.9,169.3
82.1,166.4 "/>
<linearGradient id="SVGID_00000102533530282145531860000014371344717116336017_" gradientUnits="userSpaceOnUse" x1="-45.5872" y1="149.6743" x2="-35.8351" y2="175.5215" gradientTransform="matrix(-1 0 0 -1 136.44 320)">
<stop offset="0.27" style="stop-color:#231F20"/>
<stop offset="0.28" style="stop-color:#2C2829"/>
<stop offset="0.37" style="stop-color:#767474"/>
<stop offset="0.44" style="stop-color:#B1AFB0"/>
<stop offset="0.51" style="stop-color:#DBDADA"/>
<stop offset="0.56" style="stop-color:#F5F5F5"/>
<stop offset="0.59" style="stop-color:#FFFFFF"/>
</linearGradient>
<polygon style="fill:url(#SVGID_00000102533530282145531860000014371344717116336017_);" points="194,153.3 199,155 163.6,169.4
157.9,166.7 "/>
<linearGradient id="SVGID_00000075841790156840098560000006699003196656470950_" gradientUnits="userSpaceOnUse" x1="119.6532" y1="175.4803" x2="119.6532" y2="164.7475" gradientTransform="matrix(1 0 0 -1 0 320)">
<stop offset="3.000000e-02" style="stop-color:#FFFFFF"/>
<stop offset="0.44" style="stop-color:#9B9A9A"/>
<stop offset="0.83" style="stop-color:#454142"/>
<stop offset="1" style="stop-color:#231F20"/>
</linearGradient>
<polygon style="fill:url(#SVGID_00000075841790156840098560000006699003196656470950_);" points="119.5,149.8 133.7,156.1
119.5,154.4 105.6,156 "/>
<linearGradient id="SVGID_00000080899814652598392920000002338730188455222667_" gradientUnits="userSpaceOnUse" x1="19.1991" y1="43.9763" x2="111.4936" y2="184.9104" gradientTransform="matrix(1 0 0 -1 0 320)">
<stop offset="0.1" style="stop-color:#FFFFFF"/>
<stop offset="0.17" style="stop-color:#FCFCFC"/>
<stop offset="0.2" style="stop-color:#F5F4F4"/>
<stop offset="0.23" style="stop-color:#E7E7E7"/>
<stop offset="0.26" style="stop-color:#D4D4D4"/>
<stop offset="0.28" style="stop-color:#BCBBBB"/>
<stop offset="0.3" style="stop-color:#9E9C9D"/>
<stop offset="0.32" style="stop-color:#7A7878"/>
<stop offset="0.33" style="stop-color:#524F50"/>
<stop offset="0.35" style="stop-color:#252122"/>
<stop offset="0.35" style="stop-color:#231F20"/>
<stop offset="0.37" style="stop-color:#262324"/>
<stop offset="0.39" style="stop-color:#322F30"/>
<stop offset="0.4" style="stop-color:#464344"/>
<stop offset="0.42" style="stop-color:#625F60"/>
<stop offset="0.44" style="stop-color:#868484"/>
<stop offset="0.46" style="stop-color:#B2B0B0"/>
<stop offset="0.47" style="stop-color:#E5E4E4"/>
<stop offset="0.47" style="stop-color:#E7E6E6"/>
</linearGradient>
<polygon style="fill:url(#SVGID_00000080899814652598392920000002338730188455222667_);" points="0.1,169.5 0.9,169.3 122.5,253.9
121.1,255 "/>
<linearGradient id="SVGID_00000182519911580066729170000000492446570726535336_" gradientUnits="userSpaceOnUse" x1="-393.4447" y1="33.4059" x2="-285.4408" y2="198.3462" gradientTransform="matrix(-1 0 0 -1 -163.84 320)">
<stop offset="0.1" style="stop-color:#FFFFFF"/>
<stop offset="0.17" style="stop-color:#FCFCFC"/>
<stop offset="0.2" style="stop-color:#F5F4F4"/>
<stop offset="0.23" style="stop-color:#E7E7E7"/>
<stop offset="0.26" style="stop-color:#D4D4D4"/>
<stop offset="0.28" style="stop-color:#BCBBBB"/>
<stop offset="0.3" style="stop-color:#9E9C9D"/>
<stop offset="0.32" style="stop-color:#7A7878"/>
<stop offset="0.33" style="stop-color:#524F50"/>
<stop offset="0.35" style="stop-color:#252122"/>
<stop offset="0.35" style="stop-color:#231F20"/>
<stop offset="0.37" style="stop-color:#262324"/>
<stop offset="0.39" style="stop-color:#322F30"/>
<stop offset="0.4" style="stop-color:#464344"/>
<stop offset="0.42" style="stop-color:#625F60"/>
<stop offset="0.44" style="stop-color:#868484"/>
<stop offset="0.46" style="stop-color:#B2B0B0"/>
<stop offset="0.47" style="stop-color:#E5E4E4"/>
<stop offset="0.47" style="stop-color:#E7E6E6"/>
</linearGradient>
<polygon style="fill:url(#SVGID_00000182519911580066729170000000492446570726535336_);" points="241.1,169 240.4,168.7 119.7,254
121.1,255 "/>
</g>
<g id="Layer_3">
<g>
<path class="st8" d="M175.3,195.5h-1.6v-10.9l19.7,8.5v-8.5h1.6v10.9l-19.7-8.5V195.5L175.3,195.5L175.3,195.5z"/>
<path class="st8" d="M150.7,195.6c-0.6,0-1.2-0.1-1.7-0.3c-0.5-0.2-1-0.5-1.4-0.9c-0.4-0.4-0.7-0.9-0.9-1.4
c-0.2-0.5-0.3-1.1-0.3-1.7v-2.4c0-0.6,0.1-1.2,0.3-1.7c0.2-0.5,0.5-1,0.9-1.4c0.4-0.4,0.9-0.7,1.4-0.9s1.1-0.3,1.7-0.3h12.6
c0.6,0,1.2,0.1,1.7,0.3s1,0.5,1.4,0.9c0.4,0.4,0.7,0.9,0.9,1.4c0.2,0.5,0.3,1.1,0.3,1.7v2.4c0,0.6-0.1,1.2-0.3,1.7s-0.5,1-0.9,1.4
c-0.4,0.4-0.9,0.7-1.4,0.9c-0.5,0.2-1.1,0.3-1.7,0.3H150.7z M150.7,186.1c-0.4,0-0.7,0.1-1.1,0.2c-0.3,0.1-0.6,0.3-0.9,0.6
c-0.2,0.2-0.5,0.5-0.6,0.9s-0.2,0.7-0.2,1.1v2.4c0,0.4,0.1,0.7,0.2,1.1c0.1,0.3,0.3,0.6,0.6,0.9c0.3,0.2,0.5,0.5,0.9,0.6
c0.3,0.1,0.7,0.2,1.1,0.2h12.6c0.4,0,0.7-0.1,1.1-0.2c0.3-0.1,0.6-0.3,0.9-0.6c0.2-0.3,0.5-0.5,0.6-0.9c0.1-0.3,0.2-0.7,0.2-1.1
v-2.4c0-0.4-0.1-0.7-0.2-1.1c-0.1-0.3-0.3-0.6-0.6-0.9c-0.3-0.2-0.5-0.4-0.9-0.6c-0.3-0.2-0.7-0.2-1.1-0.2H150.7z"/>
<path class="st8" d="M137.2,195.6v-13h1.9v13L137.2,195.6L137.2,195.6z"/>
<path class="st8" d="M57,184.5l11.9,11.1H51.4l1.5-1.6h12l-7.8-7.3l-9.5,8.9h-2.3L57,184.5L57,184.5L57,184.5z"/>
<path class="st8" d="M96,184.6v1.6L78.7,194H96v1.6H75v-1.6l17.4-7.8H75v-1.6H96L96,184.6z"/>
<path class="st8" d="M116.1,182.6l13.8,13h-20.4l1.8-1.8h14l-9.1-8.6L105,195.6h-2.7L116.1,182.6L116.1,182.6L116.1,182.6z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

-25
View File
@@ -1,25 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo128.png",
"type": "image/png",
"sizes": "128x128"
},
{
"src": "logo256.png",
"type": "image/png",
"sizes": "256x256"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
-3
View File
@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
-38
View File
@@ -1,38 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
-8
View File
@@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+38 -14
View File
@@ -1,19 +1,43 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import AnnotationMain from './components/AnnotationMain/AnnotationMain';
import Admin from './components/Admin/Admin.tsx';
import { Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './auth/AuthContext'
import { FlightProvider } from './components/FlightContext'
import ProtectedRoute from './auth/ProtectedRoute'
import LoginPage from './features/login/LoginPage'
import FlightsPage from './features/flights/FlightsPage'
import AnnotationsPage from './features/annotations/AnnotationsPage'
import DatasetPage from './features/dataset/DatasetPage'
import AdminPage from './features/admin/AdminPage'
import SettingsPage from './features/settings/SettingsPage'
import Header from './components/Header'
const App: React.FC = () => {
export default function App() {
return (
<Router>
<div style={{ width: '100%', height: '100vh' }}> {/* Use full viewport height */}
<AuthProvider>
<Routes>
<Route path="/" element={<AnnotationMain />} />
<Route path="/admin/*" element={<Admin />} />
<Route path="/login" element={<LoginPage />} />
<Route
path="/*"
element={
<ProtectedRoute>
<FlightProvider>
<div className="flex flex-col h-screen">
<Header />
<div className="flex-1 overflow-hidden">
<Routes>
<Route path="/flights" element={<FlightsPage />} />
<Route path="/annotations" element={<AnnotationsPage />} />
<Route path="/dataset" element={<DatasetPage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/flights" replace />} />
</Routes>
</div>
</Router>
);
};
export default App;
</div>
</FlightProvider>
</ProtectedRoute>
}
/>
</Routes>
</AuthProvider>
)
}
+65
View File
@@ -0,0 +1,65 @@
let accessToken: string | null = null
export function setToken(token: string | null) {
accessToken = token
}
export function getToken() {
return accessToken
}
async function handleResponse<T>(res: Response): Promise<T> {
if (res.status === 204) return undefined as T
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`${res.status}: ${text || res.statusText}`)
}
return res.json()
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers)
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`)
if (options.body && typeof options.body === 'string') headers.set('Content-Type', 'application/json')
let res = await fetch(url, { ...options, headers })
if (res.status === 401 && accessToken) {
const refreshed = await refreshToken()
if (refreshed) {
headers.set('Authorization', `Bearer ${accessToken}`)
res = await fetch(url, { ...options, headers })
} else {
setToken(null)
window.location.href = '/login'
throw new Error('Session expired')
}
}
return handleResponse<T>(res)
}
async function refreshToken(): Promise<boolean> {
try {
const res = await fetch('/api/admin/auth/refresh', { method: 'POST', credentials: 'include' })
if (!res.ok) return false
const data = await res.json()
setToken(data.token)
return true
} catch {
return false
}
}
export const api = {
get: <T>(url: string) => request<T>(url),
post: <T>(url: string, body?: unknown) =>
request<T>(url, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(url: string, body?: unknown) =>
request<T>(url, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
patch: <T>(url: string, body?: unknown) =>
request<T>(url, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
delete: <T>(url: string) => request<T>(url, { method: 'DELETE' }),
upload: <T>(url: string, formData: FormData) =>
request<T>(url, { method: 'POST', body: formData }),
}
+25
View File
@@ -0,0 +1,25 @@
import { getToken } from './client'
export function createSSE<T>(
url: string,
onMessage: (data: T) => void,
onError?: (err: Event) => void,
): () => void {
const token = getToken()
const fullUrl = token ? `${url}${url.includes('?') ? '&' : '?'}access_token=${token}` : url
const source = new EventSource(fullUrl)
source.onmessage = (e) => {
try {
const parsed = JSON.parse(e.data) as T
onMessage(parsed)
} catch { /* ignore parse errors */ }
}
source.onerror = (e) => {
onError?.(e)
}
return () => source.close()
}
+54
View File
@@ -0,0 +1,54 @@
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
import { api, setToken } from '../api/client'
import type { AuthUser } from '../types'
interface AuthState {
user: AuthUser | null
loading: boolean
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
hasPermission: (perm: string) => boolean
}
const AuthContext = createContext<AuthState>(null!)
export function useAuth() {
return useContext(AuthContext)
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get<{ user: AuthUser; token: string }>('/api/admin/auth/refresh')
.then(data => {
setToken(data.token)
setUser(data.user)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const login = useCallback(async (email: string, password: string) => {
const data = await api.post<{ token: string; user: AuthUser }>('/api/admin/auth/login', { email, password })
setToken(data.token)
setUser(data.user)
}, [])
const logout = useCallback(async () => {
try { await api.post('/api/admin/auth/logout') } catch {}
setToken(null)
setUser(null)
}, [])
const hasPermission = useCallback((perm: string) => {
return user?.permissions.includes(perm) ?? false
}, [user])
return (
<AuthContext.Provider value={{ user, loading, login, logout, hasPermission }}>
{children}
</AuthContext.Provider>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from './AuthContext'
import type { ReactNode } from 'react'
export default function ProtectedRoute({ children }: { children: ReactNode }) {
const { user, loading } = useAuth()
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-az-bg">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-az-orange border-t-transparent" />
</div>
)
}
if (!user) return <Navigate to="/login" replace />
return <>{children}</>
}
-108
View File
@@ -1,108 +0,0 @@
import React, { useState, useEffect } from 'react';
import AdminLogin from './AdminLogin.tsx';
import AdminDashboard from './AdminDashboardNew.tsx';
import { ServerInfo } from './types';
const Admin: React.FC = () => {
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => {
// Check if user is already logged in
const checkAuth = async (): Promise<void> => {
const token = localStorage.getItem('authToken');
if (!token) {
setIsLoading(false);
return;
}
try {
// Verify token is still valid
let API_BASE = 'https://api.azaion.com';
try {
const res = await fetch('/__server-info', { method: 'GET' });
if (res.ok) {
const info: ServerInfo = await res.json();
if (info && info.proxyEnabled) {
API_BASE = '/proxy';
}
}
} catch (_) {
// ignore; fall back to direct API_BASE
}
const res = await fetch(`${API_BASE}/currentuser`, {
method: 'GET',
headers: {
'Authorization': token.startsWith('Bearer ') ? token : `Bearer ${token}`
}
});
if (res.ok) {
const data = await res.json();
const roleVal = (data && (data.role !== undefined ? data.role : (data.data && data.data.role))) ?? null;
const toNum = (r: any): number => {
if (typeof r === 'number') return r;
if (typeof r === 'string') {
const m = r.match(/-?\d+/);
if (m) return Number(m[0]);
}
return Number(r);
};
const roleNum = toNum(roleVal);
if (roleNum === 40 || roleNum === 1000) {
setIsLoggedIn(true);
} else {
localStorage.removeItem('authToken');
localStorage.removeItem('loginResponse');
}
} else {
localStorage.removeItem('authToken');
localStorage.removeItem('loginResponse');
}
} catch (error) {
console.error('Auth check failed:', error);
localStorage.removeItem('authToken');
localStorage.removeItem('loginResponse');
}
setIsLoading(false);
};
checkAuth();
}, []);
const handleLoginSuccess = (): void => {
setIsLoggedIn(true);
};
const handleLogout = (): void => {
setIsLoggedIn(false);
};
if (isLoading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: 'linear-gradient(180deg, #0b1022 0%, #0f172a 100%)',
color: '#e5e7eb'
}}>
<div>Loading...</div>
</div>
);
}
if (!isLoggedIn) {
return <AdminLogin onLoginSuccess={handleLoginSuccess} />;
}
return <AdminDashboard onLogout={handleLogout} />;
};
export default Admin;
-154
View File
@@ -1,154 +0,0 @@
import React, { useEffect, useCallback } from 'react';
import AdminHeader from './components/AdminHeader.tsx';
import AdminSidebar from './components/AdminSidebar.tsx';
import AdminContent from './components/AdminContent.tsx';
import useAdminOperations from './hooks/useAdminOperations.ts';
import { extractToken } from './utils/parsers.ts';
interface AdminDashboardProps {
onLogout: () => void;
}
const AdminDashboard: React.FC<AdminDashboardProps> = ({ onLogout }) => {
const {
currentOpKey,
outputTitle,
outputMeta,
output,
status,
users,
searchValue,
operations,
api,
setStatusMessage,
setOutputMeta,
updateOutputTitle,
handleUserSearch
} = useAdminOperations();
const ensureAuth = useCallback((): void => {
const AUTH_TOKEN = localStorage.getItem('authToken');
if (!AUTH_TOKEN) {
const last = localStorage.getItem('loginResponse');
if (last) {
try {
const data = JSON.parse(last);
const token = extractToken(data);
if (token) localStorage.setItem('authToken', token);
} catch {
// ignore parse errors
}
}
}
if (!localStorage.getItem('authToken')) onLogout();
}, [onLogout]);
const handleOpClick = (opKey: string): void => {
if (!operations[opKey].hasForm) {
operations[opKey].run();
} else {
// For operations with forms, show the form
operations[opKey].run();
}
};
const handleLogout = (): void => {
localStorage.removeItem('authToken');
localStorage.removeItem('loginResponse');
onLogout();
};
const handleUserUpdate = (): void => {
// Refresh the current operation if it's user-related
if (currentOpKey === 'list-users') {
operations['list-users'].run(searchValue);
} else if (currentOpKey === 'current-user') {
operations['current-user'].run();
}
};
useEffect(() => {
ensureAuth();
// Default: run list users
operations['list-users'].run();
// Expose functions for inline onclick handlers (for forms that still use HTML)
(window as any).adminDashboard = {
submitCreateUser: () => {
const emailEl = document.getElementById('createUserEmail') as HTMLInputElement;
const passwordEl = document.getElementById('createUserPassword') as HTMLInputElement;
const roleEl = document.getElementById('createUserRole') as HTMLSelectElement;
const email = emailEl?.value?.trim() || '';
const password = passwordEl?.value || '';
const role = roleEl?.value || '';
if (!email || !password || !role) {
setStatusMessage('Please fill in all fields');
return;
}
operations['create-user'].run({
email,
password,
role: parseInt(role, 10)
});
},
openUploadModal: () => {
// This will be handled by the AdminContent component
// We'll need to pass a callback to open the modal
console.log('Upload modal should open');
},
openClearFolderModal: () => {
// This will be handled by the AdminContent component
// We'll need to pass a callback to open the modal
console.log('Clear folder modal should open');
}
};
return () => {
delete (window as any).adminDashboard;
};
}, [ensureAuth, operations, setStatusMessage]);
return (
<>
<div style={{ minHeight: '100vh', background: 'linear-gradient(180deg, #0b1022 0%, #0f172a 100%)', color: '#e5e7eb' }}>
<AdminHeader onLogout={handleLogout} />
<main style={{
display: 'grid',
gridTemplateColumns: '180px 1fr',
gap: '16px',
padding: '16px',
minHeight: 'calc(100vh - 80px)'
}}>
<AdminSidebar
operations={operations}
currentOpKey={currentOpKey}
onOpClick={handleOpClick}
/>
<AdminContent
currentOpKey={currentOpKey}
outputTitle={outputTitle}
outputMeta={outputMeta}
output={output}
status={status}
users={users}
searchValue={searchValue}
onUserSearch={handleUserSearch}
api={api}
setStatusMessage={setStatusMessage}
setOutputMeta={setOutputMeta}
updateOutputTitle={updateOutputTitle}
onUserUpdate={handleUserUpdate}
operations={operations}
/>
</main>
</div>
</>
);
};
export default AdminDashboard;
-165
View File
@@ -1,165 +0,0 @@
import React, { useState } from 'react';
interface AdminLoginProps {
onLoginSuccess: () => void;
}
const AdminLogin: React.FC<AdminLoginProps> = ({ onLoginSuccess }) => {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
let API_BASE = 'https://api.azaion.com';
try {
const res = await fetch('/__server-info', { method: 'GET' });
if (res.ok) {
const info = await res.json();
if (info && info.proxyEnabled) {
API_BASE = '/proxy';
}
}
} catch (_) {
// ignore; fall back to direct API_BASE
}
const response = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('authToken', data.token || data.data?.token);
localStorage.setItem('loginResponse', JSON.stringify(data));
onLoginSuccess();
} else {
const errorData = await response.json();
setError(errorData.message || 'Login failed');
}
} catch (err) {
setError('Network error. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: 'linear-gradient(180deg, #0b1022 0%, #0f172a 100%)',
color: '#e5e7eb'
}}>
<div style={{
background: '#1e293b',
padding: '2rem',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
width: '100%',
maxWidth: '400px'
}}>
<h2 style={{
textAlign: 'center',
marginBottom: '1.5rem',
color: '#f8fafc'
}}>
Admin Login
</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '1rem' }}>
<label style={{
display: 'block',
marginBottom: '0.5rem',
color: '#cbd5e1'
}}>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #475569',
borderRadius: '4px',
background: '#334155',
color: '#f8fafc',
fontSize: '1rem'
}}
/>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label style={{
display: 'block',
marginBottom: '0.5rem',
color: '#cbd5e1'
}}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #475569',
borderRadius: '4px',
background: '#334155',
color: '#f8fafc',
fontSize: '1rem'
}}
/>
</div>
{error && (
<div style={{
color: '#ef4444',
marginBottom: '1rem',
textAlign: 'center'
}}>
{error}
</div>
)}
<button
type="submit"
disabled={isLoading}
style={{
width: '100%',
padding: '0.75rem',
background: isLoading ? '#64748b' : '#3b82f6',
color: '#ffffff',
border: 'none',
borderRadius: '4px',
fontSize: '1rem',
cursor: isLoading ? 'not-allowed' : 'pointer',
transition: 'background-color 0.2s'
}}
>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
</div>
);
};
export default AdminLogin;
@@ -1,360 +0,0 @@
import React, { useState, useEffect } from 'react';
import UsersList from './UsersList.tsx';
import HardwareCharts from './HardwareCharts.tsx';
import ListResources from './ListResources.tsx';
import CreateUserModal from './CreateUserModal.tsx';
import UploadFileModal from './UploadFileModal.tsx';
import ClearFolderModal from './ClearFolderModal.tsx';
import NotificationBadge from './NotificationBadge.tsx';
import { User, ApiFunction, CreateUserFormData } from '../types';
interface AdminContentProps {
currentOpKey: string;
outputTitle: string;
outputMeta: string;
output: string;
status: string;
users: User[] | null;
searchValue: string;
onUserSearch: (searchEmail: string) => void;
api: ApiFunction;
setStatusMessage: (message: string) => void;
setOutputMeta: (meta: string) => void;
updateOutputTitle: (opKey: string, extra?: string) => void;
onUserUpdate?: () => void;
operations: any;
}
const AdminContent: React.FC<AdminContentProps> = ({
currentOpKey,
outputTitle,
outputMeta,
output,
status,
users,
searchValue,
onUserSearch,
api,
setStatusMessage,
setOutputMeta,
updateOutputTitle,
onUserUpdate,
operations
}) => {
const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false);
const [isUploadFileModalOpen, setIsUploadFileModalOpen] = useState(false);
const [isClearFolderModalOpen, setIsClearFolderModalOpen] = useState(false);
const [localSearchValue, setLocalSearchValue] = useState(searchValue);
const [notification, setNotification] = useState<{
message: string;
type: 'success' | 'error';
isVisible: boolean;
}>({
message: '',
type: 'success',
isVisible: false
});
// Ref to store the current timeout
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
// Update local search value when prop changes
useEffect(() => {
setLocalSearchValue(searchValue);
}, [searchValue]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// Handle input change with throttling
const handleSearchChange = (value: string) => {
setLocalSearchValue(value);
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout
timeoutRef.current = setTimeout(() => {
onUserSearch(value);
}, 700);
};
// Handle Enter key press for immediate search
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
// Clear any pending timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
onUserSearch(localSearchValue);
}
};
const handleCreateUser = async (formData: CreateUserFormData) => {
// This function is called after successful user creation
// Refresh the user list to show the new user
console.log('Refreshing user list after creation');
onUserSearch(searchValue);
// Show success notification
setNotification({
message: `User "${formData.email}" has been created successfully!`,
type: 'success',
isVisible: true
});
};
const handleCreateUserError = (error: string) => {
// Show error notification
setNotification({
message: error,
type: 'error',
isVisible: true
});
};
const closeNotification = () => {
setNotification(prev => ({ ...prev, isVisible: false }));
};
// Handle operation-specific modals
useEffect(() => {
if (currentOpKey === 'upload-file') {
setIsUploadFileModalOpen(true);
} else if (currentOpKey === 'clear-folder') {
setIsClearFolderModalOpen(true);
}
}, [currentOpKey]);
const handleUploadFileSuccess = (message: string) => {
setNotification({
message,
type: 'success',
isVisible: true
});
};
const handleUploadFileError = (error: string) => {
setNotification({
message: error,
type: 'error',
isVisible: true
});
};
const handleClearFolderSuccess = (message: string) => {
setNotification({
message,
type: 'success',
isVisible: true
});
};
const handleClearFolderError = (error: string) => {
setNotification({
message: error,
type: 'error',
isVisible: true
});
};
return (
<section style={{
border: '1px solid #1f2937',
borderRadius: '12px',
background: 'rgba(17, 24, 39, 0.7)',
padding: '12px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<h3 style={{ margin: 0, fontSize: '20px' }}>{outputTitle}</h3>
{currentOpKey === 'list-users' && (
<input
type="email"
placeholder="🔍 Search by email..."
value={localSearchValue}
onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={handleKeyDown}
style={{
height: '40px',
padding: '0 16px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#1f2937',
color: '#e5e7eb',
fontSize: '14px',
width: '500px',
outline: 'none',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #6366f1';
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
}}
/>
)}
</div>
{currentOpKey === 'list-users' && (
<button
onClick={() => setIsCreateUserModalOpen(true)}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #059669',
background: 'linear-gradient(135deg, #10b981, #059669)',
color: '#ffffff',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(16, 185, 129, 0.3)',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(16, 185, 129, 0.4)';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.transform = 'translateY(0)';
(e.target as HTMLElement).style.boxShadow = '0 2px 4px rgba(16, 185, 129, 0.3)';
}}
>
<span style={{ fontSize: '16px' }}>+</span>
Create User
</button>
)}
</div>
<div style={{ color: '#94a3b8', fontSize: '14px' }}>{outputMeta}</div>
<div
className={`human-output ${currentOpKey === 'list-users' || currentOpKey === 'current-user' ? 'users-grid' : ''}`}
style={{
display: 'grid',
gridTemplateColumns: currentOpKey === 'list-users' || currentOpKey === 'current-user'
? 'repeat(auto-fit, minmax(360px, 1fr))'
: 'repeat(auto-fill, minmax(360px, 1fr))',
gap: '24px',
marginTop: '16px'
}}
>
{/* Render React components based on current operation */}
{currentOpKey === 'show-chart' && users && (
<HardwareCharts users={users} />
)}
{(currentOpKey === 'list-users' || currentOpKey === 'current-user') && (
<UsersList
users={users}
onSearch={onUserSearch}
searchValue={localSearchValue}
api={api}
setStatusMessage={setStatusMessage}
onUserUpdate={onUserUpdate}
/>
)}
{currentOpKey === 'list-resources' && (
<ListResources
api={api}
setStatusMessage={setStatusMessage}
setOutputMeta={setOutputMeta}
updateOutputTitle={updateOutputTitle}
/>
)}
{/* Fallback for HTML content */}
{output && (
<div dangerouslySetInnerHTML={{ __html: output }} />
)}
</div>
{status && (
<div style={{
color: '#94a3b8',
fontSize: '12px',
marginTop: '8px',
padding: '8px',
background: '#0b1223',
borderRadius: '4px'
}}>
Status: {status}
</div>
)}
{/* Create User Modal */}
<CreateUserModal
isOpen={isCreateUserModalOpen}
onClose={() => {
setIsCreateUserModalOpen(false);
// Reset form when closing
}}
onSubmit={handleCreateUser}
onError={handleCreateUserError}
api={api}
setStatusMessage={setStatusMessage}
/>
{/* Upload File Modal */}
<UploadFileModal
isOpen={isUploadFileModalOpen}
onClose={() => {
setIsUploadFileModalOpen(false);
// Switch back to list-users when closing
if (currentOpKey === 'upload-file') {
operations['list-users'].run();
}
}}
onSuccess={handleUploadFileSuccess}
onError={handleUploadFileError}
api={api}
setStatusMessage={setStatusMessage}
/>
{/* Clear Folder Modal */}
<ClearFolderModal
isOpen={isClearFolderModalOpen}
onClose={() => {
setIsClearFolderModalOpen(false);
// Switch back to list-users when closing
if (currentOpKey === 'clear-folder') {
operations['list-users'].run();
}
}}
onSuccess={handleClearFolderSuccess}
onError={handleClearFolderError}
api={api}
setStatusMessage={setStatusMessage}
/>
{/* Notification Badge */}
<NotificationBadge
message={notification.message}
type={notification.type}
isVisible={notification.isVisible}
onClose={closeNotification}
/>
</section>
);
};
export default AdminContent;
@@ -1,46 +0,0 @@
import React from 'react';
interface AdminHeaderProps {
onLogout: () => void;
}
const AdminHeader: React.FC<AdminHeaderProps> = ({ onLogout }) => {
return (
<header style={{
padding: '16px',
borderBottom: '1px solid #1f2937',
position: 'sticky' as const,
top: 0,
background: 'rgba(15, 23, 42, 0.85)',
backdropFilter: 'blur(6px)'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px'
}}>
<h1 style={{ margin: '0', fontSize: '20px' }}>Azaion Admin</h1>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
onClick={onLogout}
style={{
height: '36px',
padding: '0 12px',
borderRadius: '8px',
border: '1px solid #1f2937',
background: '#111827',
color: '#e5e7eb',
cursor: 'pointer'
}}
>
Logout
</button>
</div>
</div>
</header>
);
};
export default AdminHeader;
@@ -1,61 +0,0 @@
import React from 'react';
import { OPERATIONS_CONFIG } from '../config/constants.ts';
import { Operation } from '../types';
interface AdminSidebarProps {
operations: Record<string, Operation>;
currentOpKey: string;
onOpClick: (key: string) => void;
}
const AdminSidebar: React.FC<AdminSidebarProps> = ({ operations, currentOpKey, onOpClick }) => {
return (
<aside style={{
border: '1px solid #1f2937',
borderRadius: '12px',
background: 'rgba(17, 24, 39, 0.7)',
overflow: 'hidden'
}}>
<div style={{ maxHeight: 'calc(100vh - 260px)', overflow: 'auto' }}>
{Object.entries(operations).map(([key, op]) => {
const config = OPERATIONS_CONFIG[key] || {};
return (
<div
key={key}
onClick={() => onOpClick(key)}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 12px',
cursor: 'pointer',
background: currentOpKey === key ? 'rgba(99, 102, 241, 0.15)' : 'transparent',
borderLeft: currentOpKey === key ? '3px solid #6366f1' : '3px solid transparent',
borderBottom: 'none'
}}
onMouseEnter={(e) => {
if (currentOpKey !== key) {
(e.target as HTMLElement).style.background = 'rgba(99, 102, 241, 0.08)';
}
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.background = currentOpKey === key ? 'rgba(99, 102, 241, 0.15)' : 'transparent';
}}
>
<div style={{ flex: 1 }}>
<div style={{ color: '#cbd5e1', fontSize: '14px', fontWeight: '500' }}>
{config.title || op.title}
</div>
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
{config.description || ''}
</div>
</div>
</div>
);
})}
</div>
</aside>
);
};
export default AdminSidebar;
@@ -1,313 +0,0 @@
import React, { useState } from 'react';
import { ApiFunction } from '../types';
interface ClearFolderModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (message: string) => void;
onError: (error: string) => void;
api: ApiFunction;
setStatusMessage: (message: string) => void;
}
const ClearFolderModal: React.FC<ClearFolderModalProps> = ({
isOpen,
onClose,
onSuccess,
onError,
api,
setStatusMessage
}) => {
const [folderPath, setFolderPath] = useState<string>('');
const [isClearing, setIsClearing] = useState(false);
const [confirmText, setConfirmText] = useState<string>('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!folderPath.trim()) {
setStatusMessage('Please enter a folder path');
return;
}
if (confirmText !== 'CLEAR') {
setStatusMessage('Please type "CLEAR" to confirm');
return;
}
setIsClearing(true);
setStatusMessage('Clearing folder...');
try {
const result = await api('/clear-folder', {
method: 'POST',
json: { folderPath: folderPath.trim() }
});
console.log('Clear folder result:', result);
setStatusMessage('Folder cleared successfully!');
onSuccess(`Folder "${folderPath}" has been cleared successfully!`);
handleClose();
} catch (e: any) {
console.error('Clear folder error:', e);
const errorMessage = `Failed to clear folder: ${e.message}`;
setStatusMessage(errorMessage);
onError(errorMessage);
} finally {
setIsClearing(false);
}
};
const handleClose = () => {
setFolderPath('');
setConfirmText('');
onClose();
};
if (!isOpen) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: '#1f2937',
border: '1px solid #374151',
borderRadius: '12px',
padding: '24px',
width: '90%',
maxWidth: '500px',
maxHeight: '90vh',
overflow: 'auto'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px'
}}>
<h3 style={{
margin: 0,
fontSize: '20px',
fontWeight: '600',
color: '#ef4444'
}}>
Clear Folder
</h3>
<button
onClick={handleClose}
style={{
background: 'none',
border: 'none',
color: '#94a3b8',
fontSize: '24px',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.color = '#e5e7eb';
(e.target as HTMLElement).style.backgroundColor = '#374151';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.color = '#94a3b8';
(e.target as HTMLElement).style.backgroundColor = 'transparent';
}}
>
×
</button>
</div>
{/* Warning Message */}
<div style={{
backgroundColor: '#7f1d1d',
border: '1px solid #ef4444',
borderRadius: '8px',
padding: '12px',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span style={{ fontSize: '20px' }}></span>
<div>
<div style={{ color: '#ef4444', fontWeight: '600', fontSize: '14px' }}>
Warning: This action cannot be undone!
</div>
<div style={{ color: '#fca5a5', fontSize: '12px' }}>
All files in the specified folder will be permanently deleted.
</div>
</div>
</div>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Folder Path *
</label>
<input
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="/uploads/documents"
required
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #ef4444';
e.target.style.boxShadow = '0 0 0 3px rgba(239, 68, 68, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
/>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Type "CLEAR" to confirm *
</label>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="CLEAR"
required
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #ef4444';
e.target.style.boxShadow = '0 0 0 3px rgba(239, 68, 68, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
/>
</div>
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'flex-end'
}}>
<button
type="button"
onClick={handleClose}
disabled={isClearing}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #374151',
background: 'transparent',
color: '#94a3b8',
fontSize: '14px',
fontWeight: '500',
cursor: isClearing ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: isClearing ? 0.6 : 1
}}
onMouseEnter={(e) => {
if (!isClearing) {
(e.target as HTMLElement).style.border = '2px solid #6b7280';
(e.target as HTMLElement).style.color = '#e5e7eb';
}
}}
onMouseLeave={(e) => {
if (!isClearing) {
(e.target as HTMLElement).style.border = '2px solid #374151';
(e.target as HTMLElement).style.color = '#94a3b8';
}
}}
>
Cancel
</button>
<button
type="submit"
disabled={isClearing || !folderPath.trim() || confirmText !== 'CLEAR'}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #dc2626',
background: isClearing ? '#4b5563' : 'linear-gradient(135deg, #ef4444, #dc2626)',
color: '#ffffff',
fontSize: '14px',
fontWeight: '600',
cursor: (isClearing || !folderPath.trim() || confirmText !== 'CLEAR') ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: (isClearing || !folderPath.trim() || confirmText !== 'CLEAR') ? 0.6 : 1
}}
onMouseEnter={(e) => {
if (!isClearing && folderPath.trim() && confirmText === 'CLEAR') {
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(239, 68, 68, 0.4)';
}
}}
onMouseLeave={(e) => {
if (!isClearing && folderPath.trim() && confirmText === 'CLEAR') {
(e.target as HTMLElement).style.transform = 'translateY(0)';
(e.target as HTMLElement).style.boxShadow = 'none';
}
}}
>
{isClearing ? 'Clearing...' : 'Clear Folder'}
</button>
</div>
</form>
</div>
</div>
);
};
export default ClearFolderModal;
@@ -1,346 +0,0 @@
import React, { useState } from 'react';
import { ROLE_OPTIONS } from '../config/constants.ts';
import { CreateUserFormData, ApiFunction } from '../types';
interface CreateUserModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (formData: CreateUserFormData) => void;
onError: (error: string) => void;
api: ApiFunction;
setStatusMessage: (message: string) => void;
}
const CreateUserModal: React.FC<CreateUserModalProps> = ({
isOpen,
onClose,
onSubmit,
onError,
api,
setStatusMessage
}) => {
const [formData, setFormData] = useState<CreateUserFormData>({
email: '',
password: '',
role: 0 // Default to 0 (None role)
});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.email || !formData.password) {
setStatusMessage('Please fill in all required fields');
return;
}
setIsSubmitting(true);
setStatusMessage('Creating user...');
try {
// Convert role to number before sending
const userData = {
...formData,
role: typeof formData.role === 'string' ? parseInt(formData.role, 10) : formData.role
};
// Make the API call directly
const result = await api('/users', { method: 'POST', json: userData });
console.log('User creation result:', result);
setStatusMessage('User created successfully!');
// Call the parent's onSubmit for any additional handling
await onSubmit(formData);
// Close modal immediately
handleClose();
} catch (e: any) {
console.error('User creation error:', e);
const errorMessage = `Failed to create user: ${e.message}`;
setStatusMessage(errorMessage);
onError(errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setFormData({ email: '', password: '', role: 0 });
onClose();
};
const handleInputChange = (field: keyof CreateUserFormData, value: string) => {
if (field === 'role') {
setFormData(prev => ({ ...prev, [field]: parseInt(value, 10) || 0 }));
} else {
setFormData(prev => ({ ...prev, [field]: value }));
}
};
if (!isOpen) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: '#1f2937',
border: '1px solid #374151',
borderRadius: '12px',
padding: '24px',
width: '90%',
maxWidth: '500px',
maxHeight: '90vh',
overflow: 'auto'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px'
}}>
<h3 style={{
margin: 0,
fontSize: '20px',
fontWeight: '600',
color: '#e5e7eb'
}}>
Create New User
</h3>
<button
onClick={handleClose}
style={{
background: 'none',
border: 'none',
color: '#94a3b8',
fontSize: '24px',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.color = '#e5e7eb';
(e.target as HTMLElement).style.backgroundColor = '#374151';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.color = '#94a3b8';
(e.target as HTMLElement).style.backgroundColor = 'transparent';
}}
>
×
</button>
</div>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Email Address *
</label>
<input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="user@azaion.com"
required
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #6366f1';
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Password *
</label>
<input
type="password"
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
placeholder="••••••••"
required
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #6366f1';
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
/>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Role
</label>
<select
value={formData.role}
onChange={(e) => handleInputChange('role', e.target.value)}
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box',
cursor: 'pointer'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #6366f1';
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
>
{ROLE_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.text}
</option>
))}
</select>
</div>
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'flex-end'
}}>
<button
type="button"
onClick={handleClose}
disabled={isSubmitting}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #374151',
background: 'transparent',
color: '#94a3b8',
fontSize: '14px',
fontWeight: '500',
cursor: isSubmitting ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: isSubmitting ? 0.6 : 1
}}
onMouseEnter={(e) => {
if (!isSubmitting) {
(e.target as HTMLElement).style.border = '2px solid #6b7280';
(e.target as HTMLElement).style.color = '#e5e7eb';
}
}}
onMouseLeave={(e) => {
if (!isSubmitting) {
(e.target as HTMLElement).style.border = '2px solid #374151';
(e.target as HTMLElement).style.color = '#94a3b8';
}
}}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !formData.email || !formData.password}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #5154e6',
background: isSubmitting ? '#4b5563' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#ffffff',
fontSize: '14px',
fontWeight: '600',
cursor: (isSubmitting || !formData.email || !formData.password) ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: (isSubmitting || !formData.email || !formData.password) ? 0.6 : 1
}}
onMouseEnter={(e) => {
if (!isSubmitting && formData.email && formData.password) {
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(99, 102, 241, 0.4)';
}
}}
onMouseLeave={(e) => {
if (!isSubmitting && formData.email && formData.password) {
(e.target as HTMLElement).style.transform = 'translateY(0)';
(e.target as HTMLElement).style.boxShadow = 'none';
}
}}
>
{isSubmitting ? 'Creating...' : 'Create User'}
</button>
</div>
</form>
</div>
</div>
);
};
export default CreateUserModal;
@@ -1,137 +0,0 @@
import React from 'react';
import { parseHardware } from '../utils/parsers.ts';
import { memoryToGBNumber } from '../utils/formatters.ts';
import { CHART_COLORS } from '../config/constants.ts';
import { User, ChartData, ChartSegment } from '../types';
interface HardwareChartsProps {
users: User[] | null;
}
interface PieChartProps {
counts: ChartData[];
title: string;
}
const HardwareCharts: React.FC<HardwareChartsProps> = ({ users }) => {
const arr = Array.isArray(users) ? users : (users ? [users] : []);
if (!arr.length) {
return <div style={{ color: '#94a3b8', fontSize: '12px' }}>No users to chart.</div>;
}
// Aggregate hardware data
const cpuMap = new Map<string, number>();
const gpuMap = new Map<string, number>();
const memMap = new Map<string, number>();
for (const u of arr) {
const hw = parseHardware(u.hardware);
const cpu = (hw && hw.cpu ? String(hw.cpu).trim() : 'Unknown');
const gpu = (hw && hw.gpu ? String(hw.gpu).trim() : 'Unknown');
const memGb = hw && hw.memory ? memoryToGBNumber(hw.memory) : 0;
const memLabel = memGb ? `${Math.ceil(memGb)} GB` : 'Unknown';
cpuMap.set(cpu, (cpuMap.get(cpu) || 0) + 1);
gpuMap.set(gpu, (gpuMap.get(gpu) || 0) + 1);
memMap.set(memLabel, (memMap.get(memLabel) || 0) + 1);
}
const getTopCounts = (map: Map<string, number>): ChartData[] => {
const arr = Array.from(map.entries())
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value);
const maxItems = 7;
if (arr.length <= maxItems) return arr;
const head = arr.slice(0, maxItems);
const rest = arr.slice(maxItems);
const restSum = rest.reduce((s, x) => s + x.value, 0);
return [...head, { label: 'Other', value: restSum }];
};
const cpuCounts = getTopCounts(cpuMap);
const gpuCounts = getTopCounts(gpuMap);
const memCounts = getTopCounts(memMap);
const PieChart: React.FC<PieChartProps> = ({ counts, title }) => {
const total = counts.reduce((s, c) => s + c.value, 0) || 1;
let acc = 0;
const segments: ChartSegment[] = counts.map((c, i) => {
const frac = c.value / total;
const start = acc;
acc += frac;
const color = CHART_COLORS[i % CHART_COLORS.length];
const startDeg = Math.round(start * 360);
const endDeg = Math.round(acc * 360);
return {
color,
startDeg,
endDeg,
label: c.label,
value: c.value,
percent: Math.round(frac * 1000) / 10
};
});
const gradient = segments.map(s => `${s.color} ${s.startDeg}deg ${s.endDeg}deg`).join(', ');
return (
<div style={{
border: '1px solid #1b2536',
background: '#0b1223',
borderRadius: '10px',
padding: '12px'
}}>
<h4 style={{ margin: '0 0 10px', color: '#e5e7eb' }}>{title}</h4>
<div style={{
width: '200px',
height: '200px',
borderRadius: '50%',
margin: '10px auto',
background: `conic-gradient(${gradient})`
}}></div>
<ul style={{ listStyle: 'none', padding: 0, margin: '8px 0 0', fontSize: '12px' }}>
{segments.map((s, i) => (
<li key={i} style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '6px'
}}>
<span style={{
background: s.color,
width: '10px',
height: '10px',
borderRadius: '2px',
display: 'inline-block',
border: '1px solid #1f2937'
}}></span>
<span style={{ color: '#e5e7eb' }}>{s.label}</span>
<span style={{ color: '#94a3b8', fontSize: '12px' }}>
&nbsp; {s.value} ({s.percent}%)
</span>
</li>
))}
</ul>
</div>
);
};
return (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '12px',
gridColumn: '1 / -1'
}}>
<PieChart counts={cpuCounts} title="CPU Models" />
<PieChart counts={gpuCounts} title="GPU Models" />
<PieChart counts={memCounts} title="Memory (GB)" />
</div>
);
};
export default HardwareCharts;
@@ -1,327 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { html, formatJSON } from '../utils/formatters.ts';
import { ApiFunction } from '../types';
interface ListResourcesProps {
api: ApiFunction;
setStatusMessage: (message: string) => void;
setOutputMeta: (meta: string) => void;
updateOutputTitle: (opKey: string, extra?: string) => void;
}
interface ResourceData {
error?: string;
data?: any;
}
const ListResources: React.FC<ListResourcesProps> = ({
api,
setStatusMessage,
setOutputMeta,
updateOutputTitle
}) => {
const [prodResources, setProdResources] = useState<any[] | ResourceData | null>(null);
const [stageResources, setStageResources] = useState<any[] | ResourceData | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const handleDownload = async (endpoint: string, envType: string) => {
try {
setStatusMessage(`Downloading installer for ${envType}...`);
setOutputMeta(`${new Date().toLocaleString()} — Downloading installer from ${endpoint}`);
// Get the API base URL to construct the full download URL
let API_BASE = 'https://api.azaion.com';
try {
const res = await fetch('/__server-info', { method: 'GET' });
if (res.ok) {
const info = await res.json();
if (info && info.proxyEnabled) {
API_BASE = '/proxy';
}
}
} catch (_) {
// ignore; fall back to direct API_BASE
}
// Get auth token
const AUTH_TOKEN = localStorage.getItem('authToken') || '';
const headers: Record<string, string> = {};
if (AUTH_TOKEN) {
headers['Authorization'] = AUTH_TOKEN.startsWith('Bearer ') ? AUTH_TOKEN : `Bearer ${AUTH_TOKEN}`;
}
// Add auth header via fetch and create blob URL for secure download
const downloadUrl = `${API_BASE}${endpoint}`;
const response = await fetch(downloadUrl, { headers });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Extract filename from Content-Disposition header if available
let filename = '';
const contentDisposition = response.headers.get('content-disposition');
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1].replace(/['"]/g, '');
}
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
// Create a download link
const link = document.createElement('a');
link.href = blobUrl;
link.style.display = 'none';
// Only set download attribute if we have a filename, otherwise let browser handle it
if (filename) {
link.download = filename;
}
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up blob URL
setTimeout(() => window.URL.revokeObjectURL(blobUrl), 100);
setStatusMessage(`Installer download started for ${envType}`);
setOutputMeta(`${new Date().toLocaleString()} — Installer download initiated from ${endpoint}`);
} catch (error: any) {
console.error('Download error:', error);
setStatusMessage(`Download failed: ${error.message}`);
setOutputMeta(`${new Date().toLocaleString()} — Download error: ${error.message}`);
}
};
const handleUpdateBoth = useCallback(async () => {
setIsLoading(true);
updateOutputTitle('list-resources');
setStatusMessage('Loading...');
setOutputMeta(`${new Date().toLocaleString()} — Loading resources from both environments...`);
try {
// Load both environments in parallel
const [prodData, stageData] = await Promise.all([
api('/resources/list/suite', { method: 'GET' }).catch((e: any) => ({ error: e.message, data: e.data })),
api('/resources/list/suite-stage', { method: 'GET' }).catch((e: any) => ({ error: e.message, data: e.data }))
]);
setProdResources(prodData);
setStageResources(stageData);
setStatusMessage('OK');
setOutputMeta(`${new Date().toLocaleString()} — Resources loaded from both environments`);
} catch (e: any) {
setStatusMessage(e.message);
setOutputMeta(`${new Date().toLocaleString()} — Error loading resources`);
setProdResources({ error: e.message, data: e.data });
setStageResources({ error: e.message, data: e.data });
} finally {
setIsLoading(false);
}
}, [api, updateOutputTitle, setStatusMessage, setOutputMeta, setProdResources, setStageResources]);
// Load resources on component mount
useEffect(() => {
handleUpdateBoth();
}, [handleUpdateBoth]);
const renderResourceSection = (resources: any[] | ResourceData | null, title: string, envType: string) => {
return (
<div style={{
border: '1px solid #1b2536',
background: '#0b1223',
borderRadius: '8px',
padding: '16px',
flex: '1'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px'
}}>
<h4 style={{
margin: '0',
fontSize: '18px',
fontWeight: '600',
color: '#e5e7eb'
}}>
{title}
</h4>
<button
onClick={() => handleDownload(
envType === 'stage' ? '/resources/get-installer/stage' : '/resources/get-installer',
envType
)}
style={{
padding: '8px 16px',
borderRadius: '6px',
border: '1px solid #4f46e5',
background: 'linear-gradient(135deg, #4f46e5, #7c3aed)',
color: '#ffffff',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(79, 70, 229, 0.3)',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(79, 70, 229, 0.4)';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.transform = 'translateY(0)';
(e.target as HTMLElement).style.boxShadow = '0 2px 4px rgba(79, 70, 229, 0.3)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 15V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M7 10L12 15L17 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 15V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Download Installer
</button>
</div>
{resources ? (
(resources as ResourceData).error ? (
<pre style={{
background: '#0b1223',
border: '1px solid #0f1a33',
borderRadius: '8px',
padding: '10px',
maxHeight: '260px',
overflow: 'auto',
color: '#e5e7eb',
fontSize: '12px'
}}>
{html(formatJSON((resources as ResourceData).data || (resources as ResourceData).error))}
</pre>
) : (
<div>
{Array.isArray(resources) ? (
resources.length > 0 ? (
<ul style={{
listStyle: 'none',
padding: 0,
margin: 0,
maxWidth: '100%',
overflowWrap: 'anywhere' as const,
wordBreak: 'break-word' as const
}}>
{resources.map((item, index) => (
<li key={index} style={{
padding: '12px 16px',
border: '1px solid #1b2536',
background: '#111827',
borderRadius: '6px',
marginBottom: '8px',
maxWidth: '100%',
whiteSpace: 'normal' as const,
overflowWrap: 'anywhere' as const,
wordBreak: 'break-word' as const,
color: '#e5e7eb',
fontSize: '16px',
fontWeight: '500',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
}}>
{typeof item === 'string' ? item : JSON.stringify(item)}
</li>
))}
</ul>
) : (
<div style={{
color: '#94a3b8',
fontSize: '16px',
textAlign: 'center' as const,
padding: '20px'
}}>
No resources found.
</div>
)
) : (
<div style={{
color: '#94a3b8',
fontSize: '14px',
textAlign: 'center' as const,
padding: '20px'
}}>
No resources found.
</div>
)}
</div>
)
) : (
<div style={{
color: '#94a3b8',
fontSize: '16px',
textAlign: 'center' as const,
padding: '20px'
}}>
{isLoading ? 'Loading...' : 'Click Reload to load resources'}
</div>
)}
</div>
);
};
return (
<div style={{ gridColumn: '1 / -1' }}>
{/* Two-column layout for environments */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '20px',
marginBottom: '24px'
}}>
{renderResourceSection(prodResources, 'Prod Env', 'prod')}
{renderResourceSection(stageResources, 'Stage Env', 'stage')}
</div>
{/* Reload Button underneath */}
<div style={{
display: 'flex',
justifyContent: 'center'
}}>
<button
onClick={handleUpdateBoth}
disabled={isLoading}
style={{
height: '48px',
padding: '0 32px',
borderRadius: '8px',
border: '2px solid #5154e6',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#ffffff',
cursor: isLoading ? 'not-allowed' : 'pointer',
fontSize: '16px',
fontWeight: '600',
opacity: isLoading ? 0.6 : 1,
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(99, 102, 241, 0.3)'
}}
onMouseEnter={(e) => {
if (!isLoading) {
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(99, 102, 241, 0.4)';
}
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.transform = 'translateY(0)';
(e.target as HTMLElement).style.boxShadow = '0 2px 4px rgba(99, 102, 241, 0.3)';
}}
>
{isLoading ? 'Reloading...' : 'Reload'}
</button>
</div>
</div>
);
};
export default ListResources;
@@ -1,113 +0,0 @@
import React, { useEffect, useState } from 'react';
interface NotificationBadgeProps {
message: string;
type: 'success' | 'error';
isVisible: boolean;
onClose: () => void;
duration?: number;
}
const NotificationBadge: React.FC<NotificationBadgeProps> = ({
message,
type,
isVisible,
onClose,
duration = 5000
}) => {
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
if (isVisible) {
setIsAnimating(true);
const timer = setTimeout(() => {
setIsAnimating(false);
setTimeout(onClose, 300); // Wait for animation to complete
}, duration);
return () => clearTimeout(timer);
}
}, [isVisible, duration, onClose]);
if (!isVisible) return null;
const isSuccess = type === 'success';
const bgColor = isSuccess ? '#065f46' : '#7f1d1d';
const borderColor = isSuccess ? '#10b981' : '#ef4444';
const textColor = isSuccess ? '#10b981' : '#ef4444';
const icon = isSuccess ? '✅' : '❌';
return (
<div
style={{
position: 'fixed',
top: '20px',
right: '20px',
backgroundColor: bgColor,
border: `2px solid ${borderColor}`,
borderRadius: '12px',
padding: '16px 20px',
minWidth: '300px',
maxWidth: '500px',
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.3)',
zIndex: 10000,
transform: isAnimating ? 'translateX(0)' : 'translateX(100%)',
opacity: isAnimating ? 1 : 0,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
display: 'flex',
alignItems: 'center',
gap: '12px',
cursor: 'pointer'
}}
onClick={onClose}
>
<span style={{ fontSize: '24px', flexShrink: 0 }}>{icon}</span>
<div style={{ flex: 1 }}>
<div style={{
color: textColor,
fontWeight: '600',
fontSize: '16px',
marginBottom: '4px'
}}>
{isSuccess ? 'Success!' : 'Error!'}
</div>
<div style={{
color: isSuccess ? '#a7f3d0' : '#fca5a5',
fontSize: '14px',
lineHeight: '1.4'
}}>
{message}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
style={{
background: 'none',
border: 'none',
color: isSuccess ? '#a7f3d0' : '#fca5a5',
fontSize: '20px',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
transition: 'all 0.2s ease',
flexShrink: 0
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.backgroundColor = 'transparent';
}}
>
×
</button>
</div>
);
};
export default NotificationBadge;
@@ -1,319 +0,0 @@
import React, { useState, useRef } from 'react';
import { ApiFunction } from '../types';
interface UploadFileModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (message: string) => void;
onError: (error: string) => void;
api: ApiFunction;
setStatusMessage: (message: string) => void;
}
const UploadFileModal: React.FC<UploadFileModalProps> = ({
isOpen,
onClose,
onSuccess,
onError,
api,
setStatusMessage
}) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [folderPath, setFolderPath] = useState<string>('');
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedFile) {
setStatusMessage('Please select a file to upload');
return;
}
if (!folderPath.trim()) {
setStatusMessage('Please enter a folder path');
return;
}
setIsUploading(true);
setStatusMessage('Uploading file...');
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('folderPath', folderPath);
const result = await api('/upload', {
method: 'POST',
formData: formData
});
console.log('Upload result:', result);
setStatusMessage('File uploaded successfully!');
onSuccess(`File "${selectedFile.name}" uploaded to "${folderPath}" successfully!`);
handleClose();
} catch (e: any) {
console.error('Upload error:', e);
const errorMessage = `Failed to upload file: ${e.message}`;
setStatusMessage(errorMessage);
onError(errorMessage);
} finally {
setIsUploading(false);
}
};
const handleClose = () => {
setSelectedFile(null);
setFolderPath('');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onClose();
};
if (!isOpen) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: '#1f2937',
border: '1px solid #374151',
borderRadius: '12px',
padding: '24px',
width: '90%',
maxWidth: '500px',
maxHeight: '90vh',
overflow: 'auto'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px'
}}>
<h3 style={{
margin: 0,
fontSize: '20px',
fontWeight: '600',
color: '#e5e7eb'
}}>
Upload File
</h3>
<button
onClick={handleClose}
style={{
background: 'none',
border: 'none',
color: '#94a3b8',
fontSize: '24px',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.color = '#e5e7eb';
(e.target as HTMLElement).style.backgroundColor = '#374151';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.color = '#94a3b8';
(e.target as HTMLElement).style.backgroundColor = 'transparent';
}}
>
×
</button>
</div>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Select File *
</label>
<input
ref={fileInputRef}
type="file"
onChange={handleFileSelect}
required
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box',
cursor: 'pointer'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #6366f1';
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
/>
{selectedFile && (
<div style={{
marginTop: '8px',
padding: '8px',
backgroundColor: '#065f46',
border: '1px solid #10b981',
borderRadius: '6px',
color: '#a7f3d0',
fontSize: '12px'
}}>
Selected: {selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
</div>
)}
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Folder Path *
</label>
<input
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="/uploads/documents"
required
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #6366f1';
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
/>
</div>
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'flex-end'
}}>
<button
type="button"
onClick={handleClose}
disabled={isUploading}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #374151',
background: 'transparent',
color: '#94a3b8',
fontSize: '14px',
fontWeight: '500',
cursor: isUploading ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: isUploading ? 0.6 : 1
}}
onMouseEnter={(e) => {
if (!isUploading) {
(e.target as HTMLElement).style.border = '2px solid #6b7280';
(e.target as HTMLElement).style.color = '#e5e7eb';
}
}}
onMouseLeave={(e) => {
if (!isUploading) {
(e.target as HTMLElement).style.border = '2px solid #374151';
(e.target as HTMLElement).style.color = '#94a3b8';
}
}}
>
Cancel
</button>
<button
type="submit"
disabled={isUploading || !selectedFile || !folderPath.trim()}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #5154e6',
background: isUploading ? '#4b5563' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#ffffff',
fontSize: '14px',
fontWeight: '600',
cursor: (isUploading || !selectedFile || !folderPath.trim()) ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: (isUploading || !selectedFile || !folderPath.trim()) ? 0.6 : 1
}}
onMouseEnter={(e) => {
if (!isUploading && selectedFile && folderPath.trim()) {
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(99, 102, 241, 0.4)';
}
}}
onMouseLeave={(e) => {
if (!isUploading && selectedFile && folderPath.trim()) {
(e.target as HTMLElement).style.transform = 'translateY(0)';
(e.target as HTMLElement).style.boxShadow = 'none';
}
}}
>
{isUploading ? 'Uploading...' : 'Upload File'}
</button>
</div>
</form>
</div>
</div>
);
};
export default UploadFileModal;
@@ -1,294 +0,0 @@
import React from 'react';
import { parseHardware, roleCode, roleLabel, getLastLoginValue } from '../utils/parsers.ts';
import { formatMemoryGB, formatUTCDate } from '../utils/formatters.ts';
import { User, ApiFunction } from '../types';
interface UsersListProps {
users: User[] | null;
onSearch: (searchEmail: string) => void;
searchValue: string;
api: ApiFunction;
setStatusMessage: (message: string) => void;
onUserUpdate?: () => void;
}
const UsersList: React.FC<UsersListProps> = ({
users,
onSearch,
searchValue,
api,
setStatusMessage,
onUserUpdate
}) => {
const handleToggleUser = async (email: string, isEnabled: boolean) => {
const action = isEnabled ? 'disable' : 'enable';
setStatusMessage(`${action === 'enable' ? 'Enabling' : 'Disabling'} user...`);
try {
await api(`/users/${encodeURIComponent(email)}/${action}`, { method: 'PUT' });
setStatusMessage(`User ${action}d successfully`);
if (onUserUpdate) onUserUpdate();
} catch (e: any) {
setStatusMessage(`Failed to ${action} user: ${e.message}`);
}
};
const handleDeleteUser = async (email: string) => {
if (!window.confirm(`Are you sure you want to delete user "${email}"? This action cannot be undone.`)) {
return;
}
setStatusMessage('Deleting user...');
try {
await api(`/users/${encodeURIComponent(email)}`, { method: 'DELETE' });
setStatusMessage('User deleted successfully');
if (onUserUpdate) onUserUpdate();
} catch (e: any) {
setStatusMessage(`Failed to delete user: ${e.message}`);
}
};
const arr = Array.isArray(users) ? users : (users ? [users] : []);
if (!arr.length) {
return <div style={{ color: '#94a3b8', fontSize: '12px' }}>No users found.</div>;
}
// Sort so role 10 users go first
if (arr.length > 1) {
arr.sort((a, b) => {
const a10 = roleCode(a && a.role) === 10;
const b10 = roleCode(b && b.role) === 10;
if (a10 && !b10) return -1;
if (b10 && !a10) return 1;
return 0;
});
}
return (
<>
{arr.map(u => {
const { text: rText, cls: rCls } = roleLabel(u.role);
const hw = parseHardware(u.hardware);
const lastLoginRaw = getLastLoginValue(u);
const lastLoginDisplay = (lastLoginRaw != null && String(lastLoginRaw).trim() !== '')
? formatUTCDate(lastLoginRaw)
: 'Unknown';
// Get queue offset (only the first one)
const qo = (u.userConfig && u.userConfig.queueOffsets) || {};
const queueOffset = qo.annotationsOffset ?? '';
const isEnabled = u.isEnabled !== false; // Default to enabled if property is missing
return (
<div key={u.id} className="card" style={{
padding: '16px',
minHeight: '200px',
opacity: isEnabled ? 1 : 0.6,
filter: isEnabled ? 'none' : 'grayscale(0.3)',
border: isEnabled ? '1px solid #2a3b5f' : '1px solid #374151'
}}>
{/* Header with email and badges */}
<div style={{ marginBottom: '12px' }}>
<h4 style={{ margin: '0 0 8px', fontSize: '20px', fontWeight: 700, color: '#93c5fd' }}>
{u.email || 'User'}
</h4>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap' as const
}}>
<div>
{rText && (
<span className={`badge ${rCls}`} style={{
display: 'inline-block',
padding: '6px 14px',
borderRadius: '999px',
fontSize: '14px',
border: '1px solid #2b3650',
background: 'rgba(99, 102, 241, 0.18)',
color: '#c7d2fe',
fontWeight: '500'
}}>
{rText}
</span>
)}
</div>
<span style={{
display: 'inline-block',
padding: '6px 10px',
borderRadius: '4px',
fontSize: '11px',
border: '1px solid #4b5563',
background: '#374151',
color: '#e5e7eb',
fontFamily: 'monospace',
whiteSpace: 'nowrap' as const,
fontWeight: '500'
}}>
Last Login: {lastLoginDisplay}
</span>
</div>
</div>
{/* Queue Panel */}
{queueOffset && (
<div style={{
background: '#0b1223',
border: '1px solid #1b2536',
borderRadius: '6px',
padding: '12px',
marginBottom: '8px',
minHeight: '60px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-end'
}}>
<div>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '4px' }}>Queue Offset</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#e5e7eb' }}>{queueOffset}</div>
</div>
<button
className="btn-small"
style={{
height: '48px',
fontSize: '14px',
padding: '10px 18px',
background: '#6366f1',
border: '1px solid #5154e6',
color: '#e5e7eb',
borderRadius: '6px',
cursor: 'pointer',
lineHeight: '1.3',
whiteSpace: 'normal' as const,
textAlign: 'center' as const,
width: '80px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
Set Offset
</button>
</div>
)}
{/* Hardware Panel */}
{hw && (
<div style={{
background: '#0b1223',
border: '1px solid #1b2536',
borderRadius: '6px',
padding: '12px',
minHeight: '80px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-end'
}}>
<div style={{ flex: '1' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>Hardware</div>
<div style={{ fontSize: '13px', lineHeight: '1.4' }}>
{hw.cpu && <div style={{ marginBottom: '3px' }}>{hw.cpu}</div>}
{hw.gpu && (
<div style={{
color: hw.gpu.toLowerCase().includes('nvidia') ? '#86efac' :
hw.gpu.toLowerCase().includes('amd') || hw.gpu.toLowerCase().includes('radeon') ? '#fca5a5' : '#c7d2fe',
fontWeight: hw.gpu.toLowerCase().includes('nvidia') || hw.gpu.toLowerCase().includes('amd') ? 600 : 'normal',
marginBottom: '3px'
}}>
{hw.gpu}
</div>
)}
{hw.memory && <div>{formatMemoryGB(hw.memory)}</div>}
</div>
</div>
<button
onClick={() => {/* Reset Hardware functionality */}}
className="btn-small"
style={{
height: '48px',
fontSize: '14px',
padding: '10px 18px',
background: '#991b1b',
border: '1px solid #b91c1c',
color: '#e5e7eb',
borderRadius: '6px',
cursor: 'pointer',
marginLeft: '12px',
lineHeight: '1.3',
whiteSpace: 'normal' as const,
textAlign: 'center' as const,
width: '80px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
Reset Hardware
</button>
</div>
)}
{/* User Action Buttons */}
<div style={{
display: 'flex',
gap: '10px',
marginTop: '4px',
paddingTop: '4px',
borderTop: '1px solid #1b2536',
justifyContent: 'flex-end'
}}>
<button
onClick={() => handleToggleUser(u.email, isEnabled)}
style={{
height: '30px',
fontSize: '12px',
padding: '6px 12px',
background: isEnabled ? '#92400e' : '#166534',
border: isEnabled ? '1px solid #a16207' : '1px solid #15803d',
color: '#e5e7eb',
borderRadius: '4px',
cursor: 'pointer',
lineHeight: '1.3',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{isEnabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => handleDeleteUser(u.email)}
style={{
height: '30px',
fontSize: '12px',
padding: '6px 12px',
background: '#7f1d1d',
border: '1px solid #991b1b',
color: '#e5e7eb',
borderRadius: '4px',
cursor: 'pointer',
lineHeight: '1.3',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
Delete
</button>
</div>
</div>
);
})}
</>
);
};
export default UsersList;
-81
View File
@@ -1,81 +0,0 @@
// Static configurations for the admin dashboard
import { RoleInfo, OperationConfig, RoleOption } from '../types';
export const ROLES: Record<number, RoleInfo> = {
1000: { text: 'ApiAdmin', cls: 'apiadmin' },
40: { text: 'Admin', cls: 'admin' },
50: { text: 'ResourceUploader', cls: 'uploader' },
30: { text: 'CompanionPC', cls: 'companion' },
20: { text: 'Validator', cls: 'validator' },
10: { text: 'Operator', cls: 'operator' },
0: { text: 'None', cls: 'none' }
};
export const ROLE_OPTIONS: RoleOption[] = [
{ value: '', text: 'Choose Role' },
{ value: '10', text: '10 (Operator)' },
{ value: '20', text: '20 (Validator)' },
{ value: '30', text: '30 (CompanionPC)' },
{ value: '40', text: '40 (Admin)' },
{ value: '50', text: '50 (ResourceUploader)' },
{ value: '1000', text: '1000 (ApiAdmin)' }
];
export const OPERATIONS_CONFIG: Record<string, OperationConfig> = {
'list-users': {
title: 'List Users',
description: 'Filter by email (optional)',
hasForm: false
},
'show-chart': {
title: 'Show Chart',
description: 'Pie charts by CPU / GPU / Memory',
hasForm: false
},
'current-user': {
title: 'Current User',
description: 'Get info about current user',
hasForm: false
},
'list-resources': {
title: 'List Resources',
description: 'List files in folder',
hasForm: false
},
'upload-file': {
title: 'Upload File',
description: 'To specific folder',
hasForm: true
},
'clear-folder': {
title: 'Clear Folder',
description: 'Remove all files',
hasForm: true
}
};
export const OUTPUT_TITLES: Record<string, string> = {
'list-users': 'Users',
'current-user': 'Current User',
'list-resources': 'Resources',
'show-chart': 'Users Hardware charts',
'upload-file': 'Upload File',
'clear-folder': 'Clear Folder',
};
export const CHART_COLORS: string[] = [
'#60a5fa', '#34d399', '#f472b6', '#fbbf24', '#a78bfa',
'#f87171', '#22d3ee', '#86efac', '#fca5a5', '#c084fc'
];
export const MEMORY_THRESHOLDS = {
VERY_LARGE: 1e7, // Likely KB
LARGE: 1e5, // Likely MB
MEDIUM: 256 // Likely already GB
} as const;
export const LOGIN_FIELDS: string[] = [
'lastLogin', 'last_login', 'lastLoginAt', 'last_login_at',
'lastSeen', 'last_seen', 'lastSeenAt', 'last_seen_at',
'last_activity', 'lastActivity'
];
@@ -1,235 +0,0 @@
import { useState, useCallback, useMemo } from 'react';
import { OUTPUT_TITLES, OPERATIONS_CONFIG } from '../config/constants.ts';
import {
User,
UseAdminOperationsReturn,
ApiFunction,
ServerInfo
} from '../types';
const useAdminOperations = (): UseAdminOperationsReturn => {
const [currentOpKey, setCurrentOpKey] = useState<string>('list-users');
const [outputTitle, setOutputTitle] = useState<string>('Users');
const [outputMeta, setOutputMeta] = useState<string>('');
const [output, setOutput] = useState<string>('');
const [status, setStatus] = useState<string>('');
const [users, setUsers] = useState<User[] | null>(null);
const [searchValue, setSearchValue] = useState<string>('');
let API_BASE = 'https://api.azaion.com';
let apiResolved = false;
let AUTH_TOKEN = localStorage.getItem('authToken') || '';
const resolveApiBase = async (): Promise<string> => {
if (apiResolved) return API_BASE;
try {
const res = await fetch('/__server-info', { method: 'GET' });
if (res.ok) {
const info: ServerInfo = await res.json();
if (info && info.proxyEnabled) API_BASE = '/proxy';
}
} catch {
// ignore; use default API_BASE
}
apiResolved = true;
return API_BASE;
};
const api: ApiFunction = async (path, { method = 'GET', json, formData, headers = {} } = {}) => {
await resolveApiBase();
const h: Record<string, string> = { ...headers };
if (AUTH_TOKEN) h['Authorization'] = AUTH_TOKEN.startsWith('Bearer ') ? AUTH_TOKEN : `Bearer ${AUTH_TOKEN}`;
let body: string | FormData | undefined;
if (json !== undefined) {
h['Content-Type'] = 'application/json';
body = JSON.stringify(json);
} else if (formData) {
body = formData;
}
const res = await fetch(`${API_BASE}${path}`, { method, headers: h, body });
const text = await res.text();
let data: any;
try {
data = JSON.parse(text);
} catch {
data = text;
}
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`) as any;
err.data = data;
throw err;
}
return data;
};
const updateOutputTitle = useCallback((opKey: string, extra?: string) => {
let title = OUTPUT_TITLES[opKey] || 'Output';
if (extra) title = `${title}${extra}`;
setOutputTitle(title);
}, []);
const setStatusMessage = useCallback((text: string, type: string = '') => {
setStatus(text || '');
}, []);
// Operations definition
const operations = useMemo(() => ({
'list-users': {
...OPERATIONS_CONFIG['list-users'],
run: async (searchEmail: string = '') => {
setCurrentOpKey('list-users');
updateOutputTitle('list-users');
const qs = searchEmail ? `?searchEmail=${encodeURIComponent(searchEmail)}` : '';
setStatusMessage('Loading...');
try {
const data: User[] = await api(`/users${qs}`, { method: 'GET' });
setStatusMessage('OK');
setUsers(data);
setOutput('');
} catch (e: any) {
setStatusMessage(e.message);
setUsers([]);
setOutput(`<div class="small-muted">Error: ${e.message}</div>`);
}
}
},
'show-chart': {
...OPERATIONS_CONFIG['show-chart'],
run: async () => {
setCurrentOpKey('show-chart');
updateOutputTitle('show-chart');
setStatusMessage('Loading users...');
try {
const data: User[] = await api(`/users`, { method: 'GET' });
setStatusMessage('OK');
setUsers(data);
setOutput('');
} catch (e: any) {
setStatusMessage(e.message);
setUsers([]);
setOutput(`<div class="small-muted">Error: ${e.message}</div>`);
}
}
},
'current-user': {
...OPERATIONS_CONFIG['current-user'],
run: async () => {
setCurrentOpKey('current-user');
updateOutputTitle('current-user');
setStatusMessage('Loading...');
try {
const data: User = await api('/currentuser', { method: 'GET' });
setStatusMessage('OK');
setUsers([data]);
setOutput('');
} catch (e: any) {
setStatusMessage(e.message);
setUsers([]);
setOutput(`<div class="small-muted">Error: ${e.message}</div>`);
}
}
},
'list-resources': {
...OPERATIONS_CONFIG['list-resources'],
run: async () => {
setCurrentOpKey('list-resources');
updateOutputTitle('list-resources');
setUsers(null);
setOutput('');
setOutputMeta(`${new Date().toLocaleString()}`);
}
},
'upload-file': {
...OPERATIONS_CONFIG['upload-file'],
run: async () => {
setCurrentOpKey('upload-file');
updateOutputTitle('upload-file');
setUsers(null);
setOutput(`
<div class="card full-span">
<h4>Upload File</h4>
<div style="margin-top: 16px; text-align: center;">
<p style="color: #94a3b8; margin-bottom: 20px;">
Click the "Upload File" button to open the upload dialog.
</p>
<button onclick="window.adminDashboard.openUploadModal()" style="
height: 40px;
padding: 0 20px;
border-radius: 8px;
border: 2px solid #5154e6;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
">
📁 Upload File
</button>
</div>
</div>
`);
}
},
'clear-folder': {
...OPERATIONS_CONFIG['clear-folder'],
run: async () => {
setCurrentOpKey('clear-folder');
updateOutputTitle('clear-folder');
setUsers(null);
setOutput(`
<div class="card full-span">
<h4 style="color: #ef4444;">⚠️ Clear Folder</h4>
<div style="margin-top: 16px; text-align: center;">
<p style="color: #94a3b8; margin-bottom: 20px;">
Click the "Clear Folder" button to open the clear folder dialog.
<br><strong style="color: #ef4444;">Warning: This action cannot be undone!</strong>
</p>
<button onclick="window.adminDashboard.openClearFolderModal()" style="
height: 40px;
padding: 0 20px;
border-radius: 8px;
border: 2px solid #dc2626;
background: linear-gradient(135deg, #ef4444, #dc2626);
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
">
🗑️ Clear Folder
</button>
</div>
</div>
`);
}
}
}), [api, setCurrentOpKey, updateOutputTitle, setStatusMessage, setOutputMeta, setUsers, setSearchValue]);
const handleUserSearch = useCallback(async (searchEmail: string) => {
setSearchValue(searchEmail);
await operations['list-users'].run(searchEmail);
}, [operations, setSearchValue]);
return {
currentOpKey,
outputTitle,
outputMeta,
output,
status,
users,
searchValue,
operations,
api,
setStatusMessage,
setOutputMeta,
updateOutputTitle,
handleUserSearch
};
};
export default useAdminOperations;
-138
View File
@@ -1,138 +0,0 @@
// Type definitions for the Admin dashboard
export interface User {
id: string;
email: string;
role: number;
hardware?: string | HardwareParsed;
lastLogin?: string | number;
last_login?: string | number;
lastLoginAt?: string | number;
last_login_at?: string | number;
lastSeen?: string | number;
last_seen?: string | number;
lastSeenAt?: string | number;
last_seen_at?: string | number;
last_activity?: string | number;
lastActivity?: string | number;
userConfig?: {
queueOffsets?: {
annotationsOffset?: number;
annotationsConfirmOffset?: number;
annotationsCommandsOffset?: number;
};
};
isEnabled?: boolean;
}
export interface HardwareParsed {
cpu: string;
gpu: string;
memory: string;
drive: string;
}
export interface RoleInfo {
text: string;
cls: string;
code?: number;
}
export interface OperationConfig {
title: string;
description: string;
hasForm: boolean;
}
export interface ChartData {
label: string;
value: number;
}
export interface ChartSegment extends ChartData {
color: string;
startDeg: number;
endDeg: number;
percent: number;
}
export interface RoleOption {
value: string;
text: string;
}
export interface ApiResponse<T = any> {
data?: T;
error?: string;
message?: string;
id?: string;
userId?: string;
user?: {
id: string;
};
}
export interface LoginResponse {
token?: string;
accessToken?: string;
access_token?: string;
jwt?: string;
Authorization?: string;
authorization?: string;
authToken?: string;
data?: {
token?: string;
};
}
export interface ServerInfo {
proxyEnabled?: boolean;
}
export interface CreateUserFormData {
email: string;
password: string;
role: number;
}
export interface QueueOffsets {
annotationsOffset: number;
annotationsConfirmOffset: number;
annotationsCommandsOffset: number;
}
export interface Operation {
title: string;
hasForm: boolean;
run: (formData?: any) => Promise<void>;
}
export interface UseAdminOperationsReturn {
currentOpKey: string;
outputTitle: string;
outputMeta: string;
output: string;
status: string;
users: User[] | null;
searchValue: string;
operations: Record<string, Operation>;
api: ApiFunction;
setStatusMessage: (text: string, type?: string) => void;
setOutputMeta: (meta: string) => void;
updateOutputTitle: (opKey: string, extra?: string) => void;
handleUserSearch: (searchEmail: string) => Promise<void>;
}
export interface ApiFunction {
(path: string, options?: {
method?: string;
json?: any;
formData?: FormData;
headers?: Record<string, string>;
}): Promise<any>;
}
export type MemoryThreshold = 'VERY_LARGE' | 'LARGE' | 'MEDIUM';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
-89
View File
@@ -1,89 +0,0 @@
// Formatting utilities for the admin dashboard
import { MEMORY_THRESHOLDS } from '../config/constants.ts';
export function html(str: string | number): string {
return String(str).replace(/[&<>]/g, s => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;'
}[s] || s));
}
export function formatJSON(obj: any): string {
try {
if (typeof obj === 'string') obj = JSON.parse(obj);
return JSON.stringify(obj, null, 2);
} catch {
return String(obj);
}
}
export function formatMemoryGB(mem: string | number | null | undefined): string {
if (mem == null) return '';
let raw = String(mem).trim();
// Extract digits if memory was part of a sentence
const digits = raw.match(/\d+(?:[.,]\d+)?/g);
if (digits && digits.length) raw = digits[0].replace(',', '.');
const n = Number(raw);
if (!isFinite(n) || n <= 0) return String(mem);
// Heuristics: typical API returns KB (e.g., 67037080 -> ~64 GB)
let gb: number;
if (n > MEMORY_THRESHOLDS.VERY_LARGE) {
gb = n / 1048576; // KB -> GiB
} else if (n > MEMORY_THRESHOLDS.LARGE) {
gb = n / 1024; // MB -> GiB
} else if (n > MEMORY_THRESHOLDS.MEDIUM) {
gb = n; // GB
} else {
// small numbers treat as GB already
gb = n;
}
const roundedUp = Math.ceil(gb); // round up to the next whole GB
return `${roundedUp} GB`;
}
export function formatUTCDate(val: string | number | null | undefined): string {
if (val === null || val === undefined) return '';
// try parse ISO or epoch
let d: Date;
if (typeof val === 'number') {
d = new Date(val > 1e12 ? val : val * 1000);
} else {
const s = String(val).trim();
// if numeric string
if (/^\d+$/.test(s)) {
const num = Number(s);
d = new Date(num > 1e12 ? num : num * 1000);
} else {
d = new Date(s);
}
}
if (isNaN(d.getTime())) return String(val);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
const HH = String(d.getUTCHours()).padStart(2, '0');
const MM = String(d.getUTCMinutes()).padStart(2, '0');
const SS = String(d.getUTCSeconds()).padStart(2, '0');
return `${yyyy}-${mm}-${dd} ${HH}:${MM}:${SS} UTC`;
}
export function memoryToGBNumber(mem: string | number | null | undefined): number {
if (mem == null) return 0;
let raw = String(mem).trim();
const digits = raw.match(/\d+(?:[.,]\d+)?/g);
if (digits && digits.length) raw = digits[0].replace(',', '.');
const n = Number(raw);
if (!isFinite(n) || n <= 0) return 0;
if (n > MEMORY_THRESHOLDS.VERY_LARGE) return n / 1048576; // KB -> GiB
if (n > MEMORY_THRESHOLDS.LARGE) return n / 1024; // MB -> GiB
return n; // assume already GB otherwise
}
-66
View File
@@ -1,66 +0,0 @@
export const extractToken = (data: any): string | null => {
if (!data) return null;
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data);
return parsed.token || parsed.data?.token || null;
} catch {
return data;
}
}
if (data.token) return data.token;
if (data.data?.token) return data.data.token;
return null;
};
export const parseHardware = (hardware: any): any => {
if (!hardware) return null;
try {
if (typeof hardware === 'string') {
return JSON.parse(hardware);
}
return hardware;
} catch {
return null;
}
};
export const roleCode = (role: any): number => {
if (typeof role === 'number') return role;
if (typeof role === 'string') {
const match = role.match(/-?\d+/);
if (match) return Number(match[0]);
}
return Number(role) || 0;
};
export const roleLabel = (role: any): string => {
const code = roleCode(role);
switch (code) {
case 0: return 'User';
case 10: return 'Moderator';
case 20: return 'Admin';
case 30: return 'Super Admin';
case 40: return 'System Admin';
case 1000: return 'Root';
default: return `Role ${code}`;
}
};
export const getLastLoginValue = (lastLogin: any): string => {
if (!lastLogin) return 'Never';
try {
const date = new Date(lastLogin);
if (isNaN(date.getTime())) return 'Invalid Date';
return date.toLocaleString();
} catch {
return 'Invalid Date';
}
};
@@ -1,47 +0,0 @@
.controls {
margin-top: 4px;
}
.input-group {
display: flex;
flex-direction: row;
background: #222531;
padding: 0 20px;
border-radius: 4px;
}
.time {
color: #fff;
}
.video-slider {
margin: 12px 26px;
}
.MuiSlider-root {
color: #fff !important;
}
.buttons-group {
display: flex;
flex-direction: row;
gap: 10px;
margin-top: 6px;
}
.control-btn {
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
background: #222531;
padding: 4px;
border: 0;
border-radius: 4px;
cursor: pointer;
}
.control-btn:hover {
background: #535b77;
}
@@ -1,112 +0,0 @@
import { Slider } from '@mui/material';
import './AnnotationControls.css';
import PreviousIcon from '../../icons/PreviousIcon';
import PlayIcon from '../../icons/PlayIcon';
import PauseIcon from '../../icons/PauseIcon';
import NextIcon from '../../icons/NextIcon';
import StopIcon from '../../icons/StopIcon';
import SaveIcon from '../../icons/SaveIcon';
import CleanIcon from '../../icons/CleanIcon';
import DeleteIcon from '../../icons/DeleteIcon';
function AnnotationControls({
videoRef,
currentTime,
setCurrentTime,
onFrameBackward,
onPlayPause, isPlaying,
onFrameForward,
onSaveAnnotation,
onStop,
onDelete,
onDeleteAll
}) {
function formatDuration(value) {
if (Number.isNaN(value)) {
return '0:00'
}
const minute = Math.floor(value / 60);
const secondLeft = Math.floor(value - minute * 60);
return `${minute}:${secondLeft < 10 ? `0${secondLeft}` : secondLeft}`;
}
const handleSliderChange = (e) => {
setCurrentTime(e.target.value);
}
return (
<div className='controls'>
<div className='input-group'>
<p className='time'>{formatDuration(currentTime)}</p>
<Slider
aria-label='time-indicator'
value={currentTime}
onChange={handleSliderChange}
min={0}
max={videoRef.current === null ? 1 : videoRef.current.duration}
step={0.1}
className='video-slider'
/>
{videoRef.current !== null
? <p className='time'>{formatDuration(videoRef.current.duration - currentTime)}</p>
: <p className='time'>{formatDuration(0)}</p>
}
</div>
<div className='buttons-group' >
<button
className='control-btn arrow-btn'
onClick={onFrameBackward}
title="Previous Frame"
>
<PreviousIcon />
</button>
<button
className={isPlaying ? 'control-btn pause-btn' : 'control-btn play-btn'}
onClick={onPlayPause}
>
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</button>
<button
className='control-btn arrow-btn'
onClick={onFrameForward}
title="Next Frame"
>
<NextIcon />
</button>
<button
className='control-btn stop-btn'
onClick={onStop}
title='Stop'
>
<StopIcon />
</button>
<button
className='control-btn save-btn'
onClick={onSaveAnnotation}
title='Save'
>
<SaveIcon />
</button>
<button
className='control-btn delete-btn'
onClick={onDelete}
title='Delete'
>
<DeleteIcon />
</button>
<button
className='control-btn clean-btn'
onClick={onDeleteAll}
title='DeleteAll'
>
<CleanIcon />
</button>
</div>
</div>
);
}
export default AnnotationControls;
@@ -1,28 +0,0 @@
.annotation-section {
background: #222531;
border-radius: 4px;
padding: 8px;
height: 80%;
}
.annotation-list {
display: flex;
flex-direction: column;
gap: 4px;
list-style-type: none;
padding: 0;
}
.annotation-list-item {
box-sizing: border-box;
display: flex;
align-items: center;
height: 22px;
background: #858CA2;
padding: 4px;
color: #fff;
font-size: 14px;
line-height: 1;
font-weight: 600;
border-radius: 4px ;
}
@@ -1,18 +0,0 @@
import './AnnotationList.css'
function AnnotationList({ annotations, onAnnotationClick }) {
return (
<div className='annotation-section'>
<h3 className='menu-title'>Annotations</h3>
<ul className='annotation-list'>
{annotations.map((annotation, index) => (
<li className='annotation-list-item' key={index} onClick={() => onAnnotationClick(index)}>
Frame {index + 1} - {annotation.detections.length} objects
</li>
))}
</ul>
</div>
);
}
export default AnnotationList;
@@ -1,55 +0,0 @@
.content-wrapper {
display: flex;
gap: 4px;
height: 100vh;
overflow: hidden;
background: #0D1421;
padding: 4px;
}
.side-menu {
display: flex;
flex-direction: column;
width: 228px;
height: 100vh;
}
.right-menu{
overflow-y: auto;
}
.player-wrapper {
width: calc(100% - 464px);
height: 100%;
display: flex;
flex-direction: column;
}
.error-message {
position: absolute;
background: #ffdddd;
color: #d8000c;
padding: 6px;
margin: 6px;
border-radius: 4px;
}
.player-container {
display: flex;
flex: 1;
flex-direction: column;
position: relative;
min-height: 0;
}
.player-block {
display: flex;
flex: 1;
position: relative;
background: #000;
justify-content: center;
align-items: center;
overflow: hidden;
border-radius: 8px;
border: 1px solid #222531;
}
@@ -1,260 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import VideoPlayer from '../VideoPlayer/VideoPlayer';
import AnnotationList from '../AnnotationList/AnnotationList';
import MediaList from '../MediaList/MediaList';
import DetectionClassList from '../DetectionClassList/DetectionClassList';
import CanvasEditor from '../CanvasEditor/CanvasEditor';
import * as AnnotationService from '../../services/AnnotationService';
import AnnotationControls from '../AnnotationControls/AnnotationControls';
import saveAnnotation from '../../services/DataHandler';
import './AnnotationMain.css';
import { detectionTypes } from '../../constants/detectionTypes';
function AnnotationMain() {
const [files, setFiles] = useState([]);
const [selectedFile, setSelectedFile] = useState(null);
const [annotations, setAnnotations] = useState([]);
const [currentTime, setCurrentTime] = useState(0);
const [selectedClass, setSelectedClass] = useState(null);
const [detections, setDetections] = useState([]);
const [selectedDetectionIndices, setSelectedDetectionIndices] = useState([]);
const [isPlaying, setIsPlaying] = useState(false);
const [videoWidth, setVideoWidth] = useState(640);
const [videoHeight, setVideoHeight] = useState(480);
const [errorMessage, setErrorMessage] = useState("");
const [detectionType, setDetectionType] = useState(detectionTypes.day)
const videoRef = useRef(null);
const containerRef = useRef(null);
useEffect(() => {
const initialFiles = [];
setFiles(initialFiles);
}, []);
const handleFileSelect = (file) => {
if (!file) return;
setSelectedFile(file);
setAnnotations([]);
setDetections([]);
setSelectedDetectionIndices([]);
setCurrentTime(0);
setIsPlaying(false);
setErrorMessage("");
};
const handleDropNewFiles = (newFiles) => {
if (!newFiles || newFiles.length === 0) return;
const validFiles = [...newFiles];
setFiles(prevFiles => [...prevFiles, ...validFiles]);
if (!selectedFile && validFiles.length > 0) {
setSelectedFile(validFiles[0]);
}
};
const handleAnnotationSave = () => {
if (!videoRef.current) return;
if (!detections || detections.length === 0) {
setErrorMessage("Please create at least one detection before saving");
return;
}
const safeContainerRef = {
current: {
offsetWidth: videoWidth,
offsetHeight: videoHeight
}
};
const imageData = AnnotationService.createAnnotationImage(
videoRef,
detections,
safeContainerRef,
detectionType
);
if (imageData) {
const newAnnotations = {
time: currentTime,
detections: detections,
imageData: imageData
};
setAnnotations(prevAnnotation => [...prevAnnotation, newAnnotations]);
saveAnnotation(currentTime, detections, imageData);
setErrorMessage("");
}
};
const handleDelete = () => {
if (selectedDetectionIndices.length === 0) {
setErrorMessage("Please select a detection to delete");
return;
}
const newDetections = detections.filter((_, index) => !selectedDetectionIndices.includes(index));
setDetections(newDetections);
setSelectedDetectionIndices([]);
setErrorMessage("");
};
const handleDeleteAll = () => {
setDetections([]);
}
const handleAnnotationClick = (index) => {
const annotation = annotations[index];
if (annotation) {
setCurrentTime(annotation.time);
setDetections(annotation.detections || []);
setSelectedDetectionIndices([]);
}
if (videoRef.current) {
videoRef.current.currentTime = annotation.time;
}
setIsPlaying(false);
};
const handleClassSelect = (cls) => {
setSelectedClass(cls);
};
const handleDetectionsChange = (newDetections) => {
setDetections(newDetections);
};
const handleSelectionChange = (newSelection) => {
setSelectedDetectionIndices(newSelection);
};
const handlePlayPause = () => {
setIsPlaying(prev => !prev);
};
const handleStop = () => {
setIsPlaying(false);
setCurrentTime(0);
};
const handleFrameForward = () => {
if (videoRef.current) {
videoRef.current.currentTime += 1 / 30;
setCurrentTime(videoRef.current.currentTime);
}
};
const handleFrameBackward = () => {
if (videoRef.current) {
videoRef.current.currentTime -= 1 / 30;
setCurrentTime(videoRef.current.currentTime);
}
};
const handleSizeChanged = (width, height) => {
setVideoWidth(width);
setVideoHeight(height);
};
const handleSetCurrentTime = (time) => {
setCurrentTime(time);
};
// Toggle debug mode with Ctrl+D
useEffect(() => {
const handleKeyDown = (e) => {
switch (e.key) {
case 'Space':
// Handle space key if needed
break;
default:
// Handle other keys if needed
break;
}
if (e.ctrlKey && e.key === 'd') {
e.preventDefault();
}
};
window.addEventListener('keydown', handleKeyDown);
}, []);
return (
<div className='content-wrapper' >
<div className='side-menu left-menu' >
<MediaList
files={files}
selectedFile={selectedFile}
onFileSelect={handleFileSelect}
onDropNewFiles={handleDropNewFiles}
/>
<DetectionClassList
onClassSelect={handleClassSelect}
detectionType={detectionType}
setDetectionType={setDetectionType}
/>
</div>
<div className='player-wrapper' >
{errorMessage && (
<div className='error-message' >
{errorMessage}
</div>
)}
<div className='player-container' ref={containerRef}>
<div className='player-block' >
<VideoPlayer
videoFile={selectedFile}
currentTime={currentTime}
videoRef={videoRef}
isPlaying={isPlaying}
onSizeChanged={handleSizeChanged}
onSetCurrentTime={handleSetCurrentTime}
>
<CanvasEditor
width={videoWidth}
height={videoHeight}
detections={detections}
selectedDetectionIndices={selectedDetectionIndices}
onDetectionsChange={handleDetectionsChange}
onSelectionChange={handleSelectionChange}
detectionClass={selectedClass}
detectionType={detectionType}
/>
</VideoPlayer>
</div>
<AnnotationControls
videoRef={videoRef}
currentTime={currentTime}
setCurrentTime={setCurrentTime}
onFrameBackward={handleFrameBackward}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
onFrameForward={handleFrameForward}
onSaveAnnotation={handleAnnotationSave}
onStop={handleStop}
onDelete={handleDelete}
onDeleteAll={handleDeleteAll}
/>
</div>
</div>
<div className='side-menu right-menu'>
<AnnotationList
annotations={annotations}
onAnnotationClick={handleAnnotationClick}
/>
</div>
</div>
);
}
export default AnnotationMain;
@@ -1,15 +0,0 @@
.editor-container {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
pointer-events: auto;
}
.canvas-editor {
position: absolute;
width: 100%;
height: 100%;
pointer-events: auto;
}
-269
View File
@@ -1,269 +0,0 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import * as AnnotationService from '../../services/AnnotationService';
import DetectionContainer from '../DetectionContainer';
import './CanvasEditor.css';
function CanvasEditor({
width,
height,
detections,
initialCurrentDetection = null,
selectedDetectionIndices,
onDetectionsChange,
onSelectionChange,
children,
detectionClass,
detectionType
}) {
const containerRef = useRef(null);
const [currentDetection, setCurrentDetection] = useState(initialCurrentDetection);
const [mouseDownPos, setMouseDownPos] = useState(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [resizeData, setResizeData] = useState(null);
const [localDetections, setLocalDetections] = useState(detections || []);
const [localSelectedIndices, setLocalSelectedIndices] = useState(selectedDetectionIndices || []);
const [, setDimensions] = useState({ width: width || 640, height: height || 480 });
// Track if we're in a dragging operation
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (width && height) {
setDimensions({ width, height });
}
}, [width, height]);
useEffect(() => {
setLocalDetections(detections || []);
}, [detections]);
useEffect(() => {
setLocalSelectedIndices(selectedDetectionIndices || []);
}, [selectedDetectionIndices]);
const handleMouseDown = (e) => {
e.preventDefault();
if (!containerRef.current) return;
const { x: mouseX, y: mouseY } = AnnotationService.calculateRelativeCoordinates(e, containerRef);
setMouseDownPos({ mouseX, mouseY });
let detectionFound = false;
for (let i = localDetections.length - 1; i >= 0; i--) {
if (AnnotationService.isMouseOverDetection(e.clientX, e.clientY, localDetections[i], containerRef)) {
if (e.ctrlKey) {
const newSelectedIndices = localSelectedIndices.includes(i)
? localSelectedIndices.filter(index => index !== i)
: [...localSelectedIndices, i];
setLocalSelectedIndices(newSelectedIndices);
if (onSelectionChange) {
onSelectionChange(newSelectedIndices);
}
} else {
const newSelectedIndices = [i];
setLocalSelectedIndices(newSelectedIndices);
if (onSelectionChange) {
onSelectionChange(newSelectedIndices);
}
}
setDragOffset({
x: mouseX - localDetections[i].x1,
y: mouseY - localDetections[i].y1,
});
setIsDragging(true);
detectionFound = true;
break;
}
}
if (!detectionFound) {
if (!e.ctrlKey) {
setLocalSelectedIndices([]);
if (onSelectionChange) {
onSelectionChange([]);
}
}
if (detectionClass) {
setCurrentDetection({ x1: mouseX, y1: mouseY, x2: mouseX, y2: mouseY, class: detectionClass });
}
}
};
const handleMouseMove = useCallback((e) => {
if (!containerRef.current) return;
const { x: mouseX, y: mouseY } = AnnotationService.calculateRelativeCoordinates(e, containerRef);
if (localSelectedIndices.length > 0 && mouseDownPos && !resizeData) {
// Dragging logic
setIsDragging(true);
const newDetections = [...localDetections];
const firstSelectedIndex = localSelectedIndices[0];
if (firstSelectedIndex === undefined || !newDetections[firstSelectedIndex]) return;
const firstSelectedDetection = newDetections[firstSelectedIndex];
const { newX1, newY1 } = AnnotationService.calculateNewPosition(mouseX, mouseY, dragOffset, firstSelectedDetection, containerRef);
const deltaX = newX1 - firstSelectedDetection.x1;
const deltaY = newY1 - firstSelectedDetection.y1;
localSelectedIndices.forEach(index => {
if (newDetections[index] === undefined) return;
const detection = newDetections[index];
let updatedX1 = detection.x1 + deltaX;
let updatedY1 = detection.y1 + deltaY;
let updatedX2 = detection.x2 + deltaX;
let updatedY2 = detection.y2 + deltaY;
const bounds = AnnotationService.calculateNewPosition(updatedX1 + dragOffset.x, updatedY1 + dragOffset.y, dragOffset, { ...detection, x1: updatedX1, y1: updatedY1, x2: updatedX2, y2: updatedY2 }, containerRef);
detection.x1 = bounds.newX1;
detection.y1 = bounds.newY1;
detection.x2 = bounds.newX2;
detection.y2 = bounds.newY2;
});
setLocalDetections(newDetections);
if (onDetectionsChange) {
onDetectionsChange(newDetections);
}
} else if (currentDetection && !resizeData) {
setCurrentDetection(prev => ({ ...prev, x2: mouseX, y2: mouseY }));
} else if (resizeData) {
setIsDragging(true);
const { index, position } = resizeData;
if (localDetections[index] === undefined) return;
const newDetections = [...localDetections];
const detection = { ...newDetections[index] };
const updatedDetection = AnnotationService.calculateResizedPosition(mouseX, mouseY, position, detection, containerRef);
newDetections[index] = updatedDetection;
setLocalDetections(newDetections);
if (onDetectionsChange) {
onDetectionsChange(newDetections);
}
}
}, [localSelectedIndices, mouseDownPos, resizeData, localDetections, containerRef, onDetectionsChange]);
const handleMouseUp = useCallback((e) => {
// If we're dragging (or resizing), stop propagation to prevent other elements from reacting
if (isDragging || resizeData) {
e.stopPropagation();
}
if (currentDetection && mouseDownPos) {
const dx = Math.abs(currentDetection.x2 - currentDetection.x1);
const dy = Math.abs(currentDetection.y2 - currentDetection.y1);
if (dx > 5 && dy > 5) {
// Normalize coordinates so x1,y1 is always top-left and x2,y2 is bottom-right
const normalizedDetection = {
...currentDetection,
x1: Math.min(currentDetection.x1, currentDetection.x2),
y1: Math.min(currentDetection.y1, currentDetection.y2),
x2: Math.max(currentDetection.x1, currentDetection.x2),
y2: Math.max(currentDetection.y1, currentDetection.y2),
kw: width / containerRef.current.offsetWidth,
kh: height / containerRef.current.offsetHeight
};
const newDetections = [...localDetections, normalizedDetection];
setLocalDetections(newDetections);
if (onDetectionsChange) {
onDetectionsChange(newDetections);
}
}
}
setCurrentDetection(null);
setMouseDownPos(null);
setDragOffset({ x: 0, y: 0 });
setResizeData(null);
setIsDragging(false);
}, [isDragging, resizeData, localSelectedIndices, localDetections, onDetectionsChange, onSelectionChange]);
const handleDetectionMouseDown = (e, index) => {
e.stopPropagation();
if (!localSelectedIndices.includes(index)) {
if (!e.ctrlKey) {
const newSelectedIndices = [index];
setLocalSelectedIndices(newSelectedIndices);
onSelectionChange && onSelectionChange(newSelectedIndices);
} else {
const newSelectedIndices = [...localSelectedIndices, index];
setLocalSelectedIndices(newSelectedIndices);
onSelectionChange && onSelectionChange(newSelectedIndices);
}
}
const { x: mouseX, y: mouseY } = AnnotationService.calculateRelativeCoordinates(e, containerRef);
setDragOffset({
x: mouseX - localDetections[index].x1,
y: mouseY - localDetections[index].y1,
});
setMouseDownPos({ x: mouseX, y: mouseY });
setIsDragging(true);
};
const handleResize = (e, index, position) => {
e.stopPropagation();
setResizeData({ index, position });
if (!localSelectedIndices.includes(index)) {
if (!e.ctrlKey) {
setLocalSelectedIndices([index]);
onSelectionChange && onSelectionChange([index]);
}
else {
const newSelectedIndices = [...localSelectedIndices, index];
setLocalSelectedIndices(newSelectedIndices);
onSelectionChange && onSelectionChange(newSelectedIndices);
}
}
setIsDragging(true);
};
// Add a document-level mouse move and up handler for dragging outside container
useEffect(() => {
if (isDragging || resizeData) {
const handleDocumentMouseMove = (e) => {
handleMouseMove(e);
};
const handleDocumentMouseUp = (e) => {
handleMouseUp(e);
};
document.addEventListener('mousemove', handleDocumentMouseMove);
document.addEventListener('mouseup', handleDocumentMouseUp);
return () => {
document.removeEventListener('mousemove', handleDocumentMouseMove);
document.removeEventListener('mouseup', handleDocumentMouseUp);
};
}
}, [isDragging, resizeData, mouseDownPos, handleMouseMove, handleMouseUp]);
return (
<div className='editor-container' >
<div className='canvas-editor'
ref={containerRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{children}
<DetectionContainer
detections={localDetections}
selectedDetectionIndices={localSelectedIndices}
onDetectionMouseDown={handleDetectionMouseDown}
currentDetection={currentDetection}
onResize={handleResize}
detectionType={detectionType}
/>
</div>
</div>
);
}
export default CanvasEditor;
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
title: string
message?: string
onConfirm: () => void
onCancel: () => void
}
export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }: Props) {
const { t } = useTranslation()
const cancelRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (open) cancelRef.current?.focus()
}, [open])
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [open, onCancel])
if (!open) return null
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]">
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-80 shadow-xl">
<h3 className="text-white font-semibold mb-2">{title}</h3>
{message && <p className="text-az-text text-sm mb-4">{message}</p>}
<div className="flex justify-end gap-2">
<button ref={cancelRef} onClick={onCancel} className="px-3 py-1 text-sm border border-az-border rounded hover:bg-az-bg text-az-text">
{t('common.cancel')}
</button>
<button onClick={onConfirm} className="px-3 py-1 text-sm bg-az-red rounded hover:bg-red-600 text-white">
{t('common.confirm')}
</button>
</div>
</div>
</div>
)
}
-97
View File
@@ -1,97 +0,0 @@
import React from 'react';
import { detectionTypes } from '../constants/detectionTypes';
function Detection({ detection, isSelected, onDetectionMouseDown, onResize, detectionType }) {
if (!detection || !detection.class) {
return null;
}
const { Color: color } = detection.class;
if (!color) {
console.error("Color is undefined for detection class:", detection.class);
return null;
}
// Use startsWith to correctly handle RGBA and hex colors
const borderColor = color.startsWith('rgba')
? color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)/, 'rgba($1, $2, $3, 1)')
: color;
const resizeHandleSize = 8;
const resizeHandles = [
{ position: 'top-left', cursor: 'nwse-resize', x: -resizeHandleSize, y: -resizeHandleSize, },
{ position: 'top-right', cursor: 'nesw-resize', x: detection.x2 - detection.x1 , y: -resizeHandleSize,},
{ position: 'bottom-left', cursor: 'nesw-resize', x: -resizeHandleSize, y: detection.y2 - detection.y1, },
{ position: 'bottom-right', cursor: 'nwse-resize', x: detection.x2 - detection.x1, y: detection.y2 - detection.y1 , },
{ position: 'top-middle', cursor: 'ns-resize', x: (detection.x2 - detection.x1) / 2 - resizeHandleSize / 2, y: -resizeHandleSize },
{ position: 'bottom-middle', cursor: 'ns-resize', x: (detection.x2 - detection.x1) / 2 - resizeHandleSize / 2, y: detection.y2 - detection.y1 },
{ position: 'left-middle', cursor: 'ew-resize', x: -resizeHandleSize, y: (detection.y2 - detection.y1) / 2 - resizeHandleSize / 2 },
{ position: 'right-middle', cursor: 'ew-resize', x: detection.x2 - detection.x1, y: (detection.y2 - detection.y1) / 2 - resizeHandleSize / 2 },
];
const style = {
position: 'absolute',
left: `${detection.x1}px`,
top: `${detection.y1}px`,
width: `${detection.x2 - detection.x1}px`,
height: `${detection.y2 - detection.y1}px`,
border: `2px solid ${borderColor}`,
boxSizing: 'border-box',
cursor: isSelected ? 'move' : 'default',
pointerEvents: 'auto',
zIndex: isSelected ? 2 : 1,
};
if (isSelected) {
style.border = `3px solid black`;
style.boxShadow = `0 0 4px 4px ${borderColor}`;
}
const handleMouseDown = (e) => {
e.stopPropagation();
onDetectionMouseDown(e);
};
const handleResizeMouseDown = (e, position) => {
e.stopPropagation();
e.preventDefault();
onResize(e, position);
};
return (
<div style={style} onMouseDown={handleMouseDown}>
{isSelected && resizeHandles.map((handle) => (
<div
key={handle.position}
style={{
position: 'absolute',
left: `${handle.x}px`,
top: `${handle.y}px`,
width: `${resizeHandleSize}px`,
height: `${resizeHandleSize}px`,
backgroundColor: 'black',
cursor: handle.cursor,
pointerEvents: 'auto',
zIndex: 3,
}}
onMouseDown={(e) => handleResizeMouseDown(e, handle.position)}
/>
))}
<span style={{
color: 'white',
fontSize: '12px',
position: "absolute",
top: "-18px",
left: "0px",
textShadow: '1px 1px 2px black',
pointerEvents: 'none'
}}>
{detection.class.Name} {detectionType !== detectionTypes.day && '(' + detectionType + ')'}
</span>
</div>
);
}
export default Detection;
@@ -1,80 +0,0 @@
.detection {
margin-top: 4px;
}
.class-list {
display: flex;
flex-direction: column;
background: #858CA2;
border-radius: 4px;
padding: 4px;
height: 48vh;
}
.menu-title {
margin-bottom: 6px;
}
.class-list-group {
display: flex;
flex-direction: column;
gap: 3px;
padding: 0;
margin: 0;
overflow: auto;
scrollbar-width: none;
list-style-type: none;
}
.class-list-group::-webkit-scrollbar {
display: none;
}
.class-list-item {
display: flex;
align-items: center;
height: 30px;
cursor: pointer;
padding: 8px;
font-size: 14px;
font-weight: 500;
border-radius: 4px;
}
.detection-type-group {
background: #222531;
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 16px 9px;
margin-top: 4px;
border-radius: 4px;
}
.detection-type-btn {
width: 66px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
background: #3862fb41;
color: #3861FB;
font-size: 30px;
padding: 5px 17px;
border-radius: 4px;
border: 0;
}
.detection-type-btn:hover {
background: #0e2060;
}
.active-type {
color: white;
background: #3861FB;
}
.active-type:hover {
cursor: default;
background: #3861FB;
}
@@ -1,147 +0,0 @@
import React, { useEffect, useState } from 'react';
import DetectionClass from '../../models/DetectionClass';
import './DetectionClassList.css';
import { MdOutlineNightlightRound, MdOutlineWbSunny } from "react-icons/md";
import { FaRegSnowflake } from 'react-icons/fa';
import { detectionTypes } from '../../constants/detectionTypes';
function DetectionClassList({ onClassSelect, detectionType, setDetectionType }) {
const [detectionClasses, setDetectionClasses] = useState([]);
const [selectedClass, setSelectedClass] = useState(null);
const colors = [
"#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#000000",
"#800000", "#008000", "#000080", "#808000", "#800080", "#008080", "#808080",
"#C00000", "#00C000", "#0000C0", "#C0C000", "#C000C0", "#00C0C0", "#C0C0C0",
"#400000", "#004000", "#000040", "#404000", "#400040", "#004040", "#404040",
"#200000", "#002000", "#000020", "#202000", "#200020", "#002020", "#202020",
"#600000", "#006000", "#000060", "#606000", "#600060", "#006060", "#606060",
"#A00000", "#00A000", "#0000A0", "#A0A000", "#A000A0", "#00A0A0", "#A0A0A0",
"#E00000", "#00E000", "#0000E0", "#E0E000", "#E000E0", "#00E0E0", "#E0E0E0"
];
const calculateColor = (id, opacity = '0.2') => {
const hexColor = colors[id % colors.length];
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
useEffect(() => {
const defaultClasses = [
{ Id: 0, Name: "Car" },
{ Id: 1, Name: "Person" },
{ Id: 2, Name: "Truck" },
{ Id: 3, Name: "Bicycle" },
{ Id: 4, Name: "Motorcycle" },
{ Id: 5, Name: "Bus" }
];
try {
fetch('config.json')
.then(response => response.json())
.then(data => {
const classes = data.classes.map(cls => {
const color = calculateColor(cls.Id, '1');
return new DetectionClass(cls.Id, cls.Name, color);
});
setDetectionClasses(classes);
if (classes.length > 0 && onClassSelect && !selectedClass) {
setSelectedClass(classes[0]);
onClassSelect(classes[0]);
}
})
.catch(error => {
console.warn("Using default classes");
const classes = defaultClasses.map(cls => {
const color = calculateColor(cls.Id, '1');
return new DetectionClass(cls.Id, cls.Name, color);
});
setDetectionClasses(classes);
if (classes.length > 0 && onClassSelect && !selectedClass) {
setSelectedClass(classes[0]);
onClassSelect(classes[0]);
}
});
} catch (error) {
console.warn("Using default classes");
const classes = defaultClasses.map(cls => {
const color = calculateColor(cls.Id, '1');
return new DetectionClass(cls.Id, cls.Name, color);
});
setDetectionClasses(classes);
if (classes.length > 0 && onClassSelect && !selectedClass) {
setSelectedClass(classes[0]);
onClassSelect(classes[0]);
}
}
}, []);
const handleClassClick = (cls) => {
setSelectedClass(cls);
onClassSelect && onClassSelect(cls);
};
const handleTypeClick = (type) => {
setDetectionType(type);
}
return (
<div className='detection'>
<div className='class-list'>
<h3 className='menu-title'>Classes</h3>
<ul className='class-list-group' >
{detectionClasses.map((cls) => {
const backgroundColor = calculateColor(cls.Id);
const darkBg = calculateColor(cls.Id, '0.8');
const isSelected = selectedClass && selectedClass.Id === cls.Id;
return (
<li
key={cls.Id}
className='class-list-item'
style={{
border: `1px solid ${isSelected ? '#000' : '#eee0'}`,
backgroundColor: isSelected ? darkBg : backgroundColor,
}}
onClick={() => handleClassClick(cls)}
>
{cls.Name}
</li>
);
})}
</ul>
</div>
<div className='detection-type-group'>
<button className={detectionType == detectionTypes.day
? 'detection-type-btn active-type'
: 'detection-type-btn'} title='День'
onClick={() => handleTypeClick(detectionTypes.day)}>
<MdOutlineWbSunny />
</button>
<button className={detectionType == detectionTypes.night
? 'detection-type-btn active-type'
: 'detection-type-btn'} title='Ніч'
onClick={() => handleTypeClick(detectionTypes.night)}>
<MdOutlineNightlightRound />
</button>
<button className={detectionType == detectionTypes.winter
? 'detection-type-btn active-type'
: 'detection-type-btn'} title='Зима'
onClick={() => handleTypeClick(detectionTypes.winter)}>
<FaRegSnowflake />
</button>
</div>
</div>
);
}
export default DetectionClassList;
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../api/client'
import type { DetectionClass } from '../types'
interface Props {
selectedClassNum: number
onSelect: (classNum: number) => void
photoMode: number
onPhotoModeChange: (mode: number) => void
}
export default function DetectionClasses({ selectedClassNum, onSelect, photoMode, onPhotoModeChange }: Props) {
const { t } = useTranslation()
const [classes, setClasses] = useState<DetectionClass[]>([])
useEffect(() => {
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
}, [])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const num = parseInt(e.key)
if (num >= 1 && num <= 9) {
const idx = num - 1
const cls = classes[idx + photoMode]
if (cls) onSelect(cls.id)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [classes, photoMode, onSelect])
const modes = [
{ value: 0, label: t('annotations.regular') },
{ value: 20, label: t('annotations.winter') },
{ value: 40, label: t('annotations.night') },
]
return (
<div className="border-t border-az-border p-2">
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.classes')}</div>
<div className="flex gap-1 mb-2">
{modes.map(m => (
<button
key={m.value}
onClick={() => onPhotoModeChange(m.value)}
className={`text-xs px-2 py-0.5 rounded ${photoMode === m.value ? 'bg-az-orange text-white' : 'bg-az-bg text-az-muted'}`}
>
{m.label}
</button>
))}
</div>
<div className="space-y-0.5 max-h-48 overflow-y-auto">
{classes.filter(c => c.photoMode === photoMode).map((c, i) => (
<button
key={c.id}
onClick={() => onSelect(c.id)}
className={`w-full flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs text-left ${
selectedClassNum === c.id ? 'bg-az-border text-white' : 'text-az-text hover:bg-az-bg'
}`}
>
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: c.color }} />
<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>
)
}
-32
View File
@@ -1,32 +0,0 @@
// src/components/DetectionContainer.js
import React from 'react';
import Detection from './Detection';
function DetectionContainer({ detections, selectedDetectionIndices, onDetectionMouseDown, currentDetection, onResize, detectionType }) {
return (
<>
{detections.map((detection, index) => (
<Detection
key={index}
detection={detection}
isSelected={selectedDetectionIndices.includes(index)}
onDetectionMouseDown={(e) => onDetectionMouseDown(e, index)}
onResize={(e, position) => onResize(e, index, position)}
detectionType={detectionType}
/>
))}
{currentDetection && (
<Detection
detection={currentDetection}
isSelected={false}
onDetectionMouseDown={() => {}} // No-op handler for the current detection
onResize={() => {}} // No-op handler for the current detection
detectionType={detectionType}
/>
)}
</>
);
}
export default DetectionContainer;
+52
View File
@@ -0,0 +1,52 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
import { api } from '../api/client'
import type { Flight, UserSettings } from '../types'
interface FlightState {
flights: Flight[]
selectedFlight: Flight | null
selectFlight: (f: Flight | null) => void
refreshFlights: () => Promise<void>
}
const FlightContext = createContext<FlightState>(null!)
export function useFlight() {
return useContext(FlightContext)
}
export function FlightProvider({ children }: { children: ReactNode }) {
const [flights, setFlights] = useState<Flight[]>([])
const [selectedFlight, setSelectedFlight] = useState<Flight | null>(null)
const refreshFlights = useCallback(async () => {
try {
const data = await api.get<{ items: Flight[] }>('/api/flights?pageSize=1000')
setFlights(data.items ?? [])
} catch {}
}, [])
useEffect(() => {
refreshFlights()
api.get<UserSettings>('/api/annotations/settings/user')
.then(settings => {
if (settings?.selectedFlightId) {
api.get<Flight>(`/api/flights/${settings.selectedFlightId}`)
.then(f => setSelectedFlight(f))
.catch(() => {})
}
})
.catch(() => {})
}, [refreshFlights])
const selectFlight = useCallback((f: Flight | null) => {
setSelectedFlight(f)
api.put('/api/annotations/settings/user', { selectedFlightId: f?.id ?? null }).catch(() => {})
}, [])
return (
<FlightContext.Provider value={{ flights, selectedFlight, selectFlight, refreshFlights }}>
{children}
</FlightContext.Provider>
)
}
+133
View File
@@ -0,0 +1,133 @@
import { NavLink, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../auth/AuthContext'
import { useFlight } from './FlightContext'
import { useState, useRef, useEffect } from 'react'
import HelpModal from './HelpModal'
import type { Flight } from '../types'
export default function Header() {
const { t, i18n } = useTranslation()
const { user, logout, hasPermission } = useAuth()
const { flights, selectedFlight, selectFlight } = useFlight()
const navigate = useNavigate()
const [showDropdown, setShowDropdown] = useState(false)
const [filter, setFilter] = useState('')
const [showHelp, setShowHelp] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node))
setShowDropdown(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const filtered = flights.filter(f => f.name.toLowerCase().includes(filter.toLowerCase()))
const handleLogout = async () => {
await logout()
navigate('/login')
}
const navItems = [
{ to: '/flights', label: t('nav.flights'), perm: 'FL' },
{ to: '/annotations', label: t('nav.annotations'), perm: 'ANN' },
{ to: '/dataset', label: t('nav.dataset'), perm: 'DATASET' },
{ to: '/admin', label: t('nav.admin'), perm: 'ADM' },
]
const toggleLang = () => {
i18n.changeLanguage(i18n.language === 'en' ? 'ua' : 'en')
}
return (
<header className="flex items-center h-10 bg-az-header border-b border-az-border px-3 gap-3 text-sm shrink-0">
<span className="font-bold text-az-orange tracking-wider">AZAION</span>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowDropdown(!showDropdown)}
className="bg-az-panel border border-az-border rounded px-2 py-0.5 text-az-text hover:border-az-muted min-w-[160px] text-left truncate"
>
{selectedFlight?.name || '— Select Flight —'}
</button>
{showDropdown && (
<div className="absolute top-full left-0 mt-1 bg-az-panel border border-az-border rounded shadow-lg z-50 w-64">
<input
className="w-full bg-az-bg border-b border-az-border px-2 py-1 text-az-text text-sm outline-none"
placeholder="Filter..."
value={filter}
onChange={e => setFilter(e.target.value)}
autoFocus
/>
<div className="max-h-60 overflow-y-auto">
{filtered.map((f: Flight) => (
<button
key={f.id}
onClick={() => { selectFlight(f); setShowDropdown(false); setFilter('') }}
className={`w-full text-left px-2 py-1 hover:bg-az-bg text-az-text text-sm ${
selectedFlight?.id === f.id ? 'bg-az-bg font-semibold' : ''
}`}
>
<div>{f.name}</div>
<div className="text-xs text-az-muted">{new Date(f.createdDate).toLocaleDateString()}</div>
</button>
))}
{filtered.length === 0 && (
<div className="px-2 py-2 text-az-muted text-xs">No flights</div>
)}
</div>
</div>
)}
</div>
<nav className="hidden sm:flex items-center gap-1 ml-2">
{navItems.filter(n => hasPermission(n.perm)).map(n => (
<NavLink
key={n.to}
to={n.to}
className={({ isActive }) =>
`px-2 py-1 rounded text-sm ${isActive ? 'bg-az-bg font-semibold text-white' : 'text-az-text hover:text-white'}`
}
>
{n.label}
</NavLink>
))}
</nav>
<div className="flex-1" />
<span className="text-xs text-az-muted hidden sm:block">{user?.email}</span>
<button onClick={toggleLang} className="text-xs text-az-muted hover:text-white px-1">
{i18n.language === 'en' ? 'UA' : 'EN'}
</button>
<button onClick={() => setShowHelp(true)} className="text-az-muted hover:text-white text-xs">?</button>
<NavLink to="/settings" className="text-az-muted hover:text-white"></NavLink>
<button onClick={handleLogout} className="text-az-muted hover:text-az-red text-xs">
{t('nav.logout')}
</button>
{/* 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">
{navItems.filter(n => hasPermission(n.perm)).map(n => (
<NavLink
key={n.to}
to={n.to}
className={({ isActive }) =>
`text-xs px-2 py-1 ${isActive ? 'text-az-orange font-semibold' : 'text-az-muted'}`
}
>
{n.label}
</NavLink>
))}
<NavLink to="/settings" className={({ isActive }) => `text-xs px-2 py-1 ${isActive ? 'text-az-orange' : 'text-az-muted'}`}>
</NavLink>
</nav>
<HelpModal open={showHelp} onClose={() => setShowHelp(false)} />
</header>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
onClose: () => void
}
const GUIDELINES = [
{ en: 'Draw bounding boxes tightly around the target', ua: 'Малюйте рамки щільно навколо цілі' },
{ en: 'Do not include shadow in the box unless the target is the shadow itself', ua: 'Не включайте тінь у рамку, якщо ціль не є тінню' },
{ en: 'If the target is partially occluded, annotate the visible part', ua: 'Якщо ціль частково перекрита, анотуйте видиму частину' },
{ en: 'Choose the correct class for each detection', ua: 'Обирайте правильний клас для кожної детекції' },
{ en: 'Set the affiliation (Friendly/Hostile/Unknown) for military targets', ua: 'Встановіть приналежність (Свій/Ворожий/Невідомий) для військових цілей' },
{ en: 'Validate annotations before they are used for training', ua: 'Валідуйте анотації перед використанням для навчання' },
]
export default function HelpModal({ open, onClose }: Props) {
const { i18n } = useTranslation()
if (!open) return null
const lang = i18n.language === 'ua' ? 'ua' : 'en'
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]" onClick={onClose}>
<div className="bg-az-panel border border-az-border rounded-lg p-5 w-[500px] max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h2 className="text-white font-semibold text-lg mb-4">How to Annotate</h2>
<ol className="space-y-2">
{GUIDELINES.map((g, i) => (
<li key={i} className="flex gap-2 text-sm text-az-text">
<span className="text-az-orange font-semibold shrink-0">{i + 1}.</span>
<span>{g[lang]}</span>
</li>
))}
</ol>
<h3 className="text-white font-semibold mt-5 mb-2">Keyboard Shortcuts</h3>
<div className="grid grid-cols-2 gap-1 text-xs text-az-text">
<span className="text-az-muted">Space</span><span>Play / Pause</span>
<span className="text-az-muted"> </span><span>Frame step</span>
<span className="text-az-muted">Ctrl + </span><span>5 second skip</span>
<span className="text-az-muted">Enter</span><span>Save annotation</span>
<span className="text-az-muted">Delete</span><span>Delete selected</span>
<span className="text-az-muted">X</span><span>Delete all detections</span>
<span className="text-az-muted">1-9</span><span>Select detection class</span>
<span className="text-az-muted">M</span><span>Mute / Unmute</span>
<span className="text-az-muted">Ctrl + Scroll</span><span>Zoom canvas</span>
<span className="text-az-muted">Esc</span><span>Close dialog / editor</span>
<span className="text-az-muted">V</span><span>Validate (Dataset)</span>
<span className="text-az-muted">PageUp/Down</span><span>Navigate media / pages</span>
</div>
<div className="mt-4 flex justify-end">
<button onClick={onClose} className="bg-az-border text-az-text text-xs px-3 py-1 rounded hover:bg-az-muted">
Close
</button>
</div>
</div>
</div>
)
}
-88
View File
@@ -1,88 +0,0 @@
.explorer{
height: 40vh ;
background: #222531;
padding: 8px;
border-radius: 4px;
min-height: 180px;
}
.explorer-head{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 6px;
}
.menu-title {
font-size: 18px;
line-height: 20px;
color: white;
margin: 0;
margin-right: 10px;
}
.open-btn{
width: 80px;
height: 20px;
background: #6188FF;
color: white;
border: 0;
border-radius: 4px;
padding: 0;
}
.open-btn:hover{
background: #295cf7;
}
.file-filter{
box-sizing: border-box;
width: 100%;
height: 26px;
background: white;
padding: 6px 12px;
border: 0;
border-radius: 2px;
font-size: 14px;
}
.file-list-group {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0;
margin: 12px 0;
list-style-type: none;
overflow: auto;
scrollbar-width: none;
max-height: 36%;
}
.file-list-group::-webkit-scrollbar {
display: none;
}
.file-list-item {
padding: 7px 6px;
font-size: 12px;
color: white;
cursor: pointer;
border-radius: 2px;
}
.label {
font-size: 12px;
}
.file-input-block {
display: flex;
justify-content: center;
align-items: center;
height: 12%;
color: white;
border: 2px dashed #ccc;
border-radius: 4px;
padding: 8px;
text-align: center;
cursor: pointer;
}
-70
View File
@@ -1,70 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import './MediaList.css'
function MediaList({ files, selectedFile, onFileSelect, onDropNewFiles }) {
const { getRootProps, getInputProps, isDragActive, open: openFileDialog } = useDropzone({
onDrop: onDropNewFiles,
multiple: true,
});
const { getRootProps: getFolderRootProps, getInputProps: getFolderInputProps, open: openFolderDialog } = useDropzone({
onDrop: onDropNewFiles,
multiple: true
});
const [filteredFiles, setFilteredFiles] = useState(files);
useEffect(() => {
setFilteredFiles(files);
}, [files])
const handleInputChange = (e) => {
const value = e.target.value;
const filtered = files.filter((file) => file.name.toLowerCase().includes(value.toLowerCase()));
setFilteredFiles(filtered);
}
return (
<div className='explorer'>
<div className='explorer-head'>
<h3 className='menu-title' >Files</h3>
<button className='open-btn' type="button" onClick={openFileDialog}>
Open File
</button>
<button className='open-btn' type="button" onClick={openFolderDialog}>
Open Folder
</button>
</div>
<input className='file-filter' type='text' placeholder='Filename' onChange={handleInputChange} />
<ul className='file-list-group' >
{filteredFiles.map((file) => (
<li
className='file-list-item'
key={file.name}
style={{
backgroundColor: selectedFile === file ? '#474A52' : '#858CA2'
}}
onClick={() => onFileSelect(file)}
>
{file.name}
</li>
))}
</ul>
<div className='file-input-block' {...getRootProps()} >
<input {...getInputProps()} />
<div style={{ display: 'none' }}>
<input {...getFolderInputProps()} webkitdirectory="true" mozdirectory="true" />
</div>
{isDragActive ? (
<p className='label' >Drop here</p>
) : (
<p className='label' >Drag new files</p>
)}
</div>
</div>
);
}
export default MediaList;
@@ -1,40 +0,0 @@
.player {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #000;
overflow: hidden;
}
.video {
width: 100%;
height: auto;
max-height: 100%;
display: block;
object-fit: contain;
pointer-events: none;
}
.player-error {
position: absolute;
top: 10px;
left: 10px;
background: rgba(255, 0, 0, 0.7);
color: white;
padding: 5px;
border-radius: 3px;
font-size: 12px;
z-index: 10;
}
.player-item{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: auto;
}
-150
View File
@@ -1,150 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import './VideoPlayer.css';
function VideoPlayer({ children, videoFile, currentTime, videoRef, isPlaying, onSizeChanged, onSetCurrentTime }) {
const containerRef = useRef(null);
const [playbackError, setPlaybackError] = useState(null);
const objectUrlRef = useRef(null);
// Flag to track if time update is coming from natural playback
const isPlaybackUpdateRef = useRef(false);
// Set up the video file when it changes
useEffect(() => {
if (!videoFile || !videoRef.current) return;
try {
// Clean up previous object URL
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
}
// Create new object URL and set it directly
const objectUrl = URL.createObjectURL(videoFile);
objectUrlRef.current = objectUrl;
// Reset video and set new source
videoRef.current.pause();
videoRef.current.src = objectUrl;
videoRef.current.load();
setPlaybackError(null);
return () => {
URL.revokeObjectURL(objectUrl);
objectUrlRef.current = null;
};
} catch (err) {
console.error("Error loading video:", err);
setPlaybackError(`Error loading video: ${err.message}`);
}
}, [videoFile]);
// Handle metadata loading and size changes
useEffect(() => {
if (!videoRef.current) return;
const handleMetadata = () => {
if (!videoRef.current) return;
const width = videoRef.current.videoWidth || 640;
const height = videoRef.current.videoHeight || 480;
if (onSizeChanged) {
onSizeChanged(width, height);
}
};
videoRef.current.addEventListener('loadedmetadata', handleMetadata);
return () => {
if (videoRef.current) {
videoRef.current.removeEventListener('loadedmetadata', handleMetadata);
}
};
}, [onSizeChanged]);
// Handle play/pause state
useEffect(() => {
if (!videoRef.current) return;
const attemptPlay = async () => {
try {
if (isPlaying) {
isPlaybackUpdateRef.current = true;
await videoRef.current.play();
setPlaybackError(null);
} else {
videoRef.current.pause();
}
} catch (err) {
console.error("Playback error:", err);
setPlaybackError(`Playback error: ${err.message}`);
}
};
attemptPlay();
}, [isPlaying]);
// Handle current time changes
useEffect(() => {
if (!videoRef.current) return;
// Only update the video's time if it's not coming from natural playback
if (!isPlaybackUpdateRef.current) {
try {
if (videoRef.current.readyState > 0) {
videoRef.current.currentTime = currentTime;
}
} catch (err) {
console.warn("Error setting time:", err);
}
} else {
// Reset the flag after receiving the update
isPlaybackUpdateRef.current = false;
}
}, [currentTime]);
// Set up time update events
useEffect(() => {
if (!videoRef.current) return;
const handleTimeUpdate = () => {
if (videoRef.current && onSetCurrentTime && isPlaying) {
isPlaybackUpdateRef.current = true;
onSetCurrentTime(videoRef.current.currentTime);
}
};
const handleSeeked = () => {
if (videoRef.current && onSetCurrentTime) {
onSetCurrentTime(videoRef.current.currentTime);
}
};
videoRef.current.addEventListener('timeupdate', handleTimeUpdate);
videoRef.current.addEventListener('seeked', handleSeeked);
return () => {
if (videoRef.current) {
videoRef.current.removeEventListener('timeupdate', handleTimeUpdate);
videoRef.current.removeEventListener('seeked', handleSeeked);
}
};
}, [onSetCurrentTime, isPlaying]);
return (
<div className='player' ref={containerRef} >
<video className='video' ref={videoRef} preload="auto" playsInline muted />
{playbackError && (
<div className='player-error' >
{playbackError}
</div>
)}
<div className='player-item'>
{children}
</div>
</div>
);
}
export default VideoPlayer;
-5
View File
@@ -1,5 +0,0 @@
export const detectionTypes = {
day: 'day',
night: 'night',
winter: 'winter'
}
+208
View File
@@ -0,0 +1,208 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../../api/client'
import ConfirmDialog from '../../components/ConfirmDialog'
import type { DetectionClass, Aircraft, User } from '../../types'
export default function AdminPage() {
const { t } = useTranslation()
const [classes, setClasses] = useState<DetectionClass[]>([])
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
const [users, setUsers] = useState<User[]>([])
const [newClass, setNewClass] = useState({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'Annotator' })
const [deactivateId, setDeactivateId] = useState<string | null>(null)
useEffect(() => {
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
api.get<User[]>('/api/admin/users').then(setUsers).catch(() => {})
}, [])
const handleAddClass = async () => {
if (!newClass.name) return
await api.post('/api/admin/classes', newClass)
const updated = await api.get<DetectionClass[]>('/api/annotations/classes')
setClasses(updated)
setNewClass({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
}
const handleDeleteClass = async (id: number) => {
await api.delete(`/api/admin/classes/${id}`)
setClasses(prev => prev.filter(c => c.id !== id))
}
const handleAddUser = async () => {
if (!newUser.email || !newUser.password) return
await api.post('/api/admin/users', newUser)
const updated = await api.get<User[]>('/api/admin/users')
setUsers(updated)
setNewUser({ name: '', email: '', password: '', role: 'Annotator' })
}
const handleDeactivate = async () => {
if (!deactivateId) return
await api.patch(`/api/admin/users/${deactivateId}`, { isActive: false })
setUsers(prev => prev.map(u => u.id === deactivateId ? { ...u, isActive: false } : u))
setDeactivateId(null)
}
const handleToggleDefault = async (a: Aircraft) => {
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
}
return (
<div className="flex h-full overflow-y-auto p-4 gap-4">
{/* Detection classes */}
<div className="w-[340px] shrink-0">
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes')}</h2>
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-az-border text-az-muted">
<th className="px-2 py-1 text-left">#</th>
<th className="px-2 py-1 text-left">Name</th>
<th className="px-2 py-1">Color</th>
<th className="px-2 py-1"></th>
</tr>
</thead>
<tbody>
{classes.map(c => (
<tr key={c.id} className="border-b border-az-border text-az-text">
<td className="px-2 py-1">{c.id}</td>
<td className="px-2 py-1">{c.name}</td>
<td className="px-2 py-1 text-center"><span className="inline-block w-3 h-3 rounded-full" style={{ backgroundColor: c.color }} /></td>
<td className="px-2 py-1"><button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button></td>
</tr>
))}
</tbody>
</table>
<div className="p-2 flex gap-1 border-t border-az-border">
<input value={newClass.name} onChange={e => setNewClass(p => ({ ...p, name: e.target.value }))} placeholder="Name" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
<input type="color" value={newClass.color} onChange={e => setNewClass(p => ({ ...p, color: e.target.value }))} className="w-8 h-7 border-0 bg-transparent cursor-pointer" />
<button onClick={handleAddClass} className="bg-az-orange text-white text-xs px-2 py-1 rounded">+</button>
</div>
</div>
</div>
{/* Center: AI + GPS settings */}
<div className="flex-1 space-y-4 max-w-md">
<div>
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.aiSettings')}</h2>
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2 text-xs">
<div>
<label className="text-az-muted">Frame Period Recognition</label>
<input type="number" defaultValue={5} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
</div>
<div>
<label className="text-az-muted">Frame Recognition Seconds</label>
<input type="number" defaultValue={1} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
</div>
<div>
<label className="text-az-muted">Probability Threshold</label>
<input type="number" defaultValue={0.5} step={0.05} min={0} max={1} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
</div>
<button className="bg-az-orange text-white text-xs px-3 py-1 rounded">{t('common.save')}</button>
</div>
</div>
<div>
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.gpsSettings')}</h2>
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2 text-xs">
<div>
<label className="text-az-muted">Device Address</label>
<input defaultValue="192.168.1.100" className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
</div>
<div>
<label className="text-az-muted">Port</label>
<input type="number" defaultValue={5535} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
</div>
<div>
<label className="text-az-muted">Protocol</label>
<select className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text">
<option>TCP</option>
<option>UDP</option>
</select>
</div>
<button className="bg-az-orange text-white text-xs px-3 py-1 rounded">{t('common.save')}</button>
</div>
</div>
{/* Users */}
<div>
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.users')}</h2>
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-az-border text-az-muted">
<th className="px-2 py-1 text-left">Name</th>
<th className="px-2 py-1 text-left">Email</th>
<th className="px-2 py-1">Role</th>
<th className="px-2 py-1">Status</th>
<th className="px-2 py-1"></th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} className="border-b border-az-border text-az-text">
<td className="px-2 py-1">{u.name}</td>
<td className="px-2 py-1">{u.email}</td>
<td className="px-2 py-1 text-center">{u.role}</td>
<td className="px-2 py-1 text-center">
<span className={`px-1 rounded ${u.isActive ? 'text-az-green' : 'text-az-red'}`}>
{u.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-2 py-1">
{u.isActive && (
<button onClick={() => setDeactivateId(u.id)} className="text-az-muted hover:text-az-red text-xs">
{t('admin.deactivate')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="p-2 flex gap-1 border-t border-az-border">
<input value={newUser.name} onChange={e => setNewUser(p => ({ ...p, name: e.target.value }))} placeholder="Name" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
<input value={newUser.email} onChange={e => setNewUser(p => ({ ...p, email: e.target.value }))} placeholder="Email" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
<input value={newUser.password} onChange={e => setNewUser(p => ({ ...p, password: e.target.value }))} placeholder="Password" type="password" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
<select value={newUser.role} onChange={e => setNewUser(p => ({ ...p, role: e.target.value }))} className="bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text">
<option>Annotator</option>
<option>Admin</option>
<option>Viewer</option>
</select>
<button onClick={handleAddUser} className="bg-az-orange text-white text-xs px-2 py-1 rounded">+</button>
</div>
</div>
</div>
</div>
{/* Aircrafts sidebar */}
<div className="w-[280px] shrink-0">
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.aircrafts')}</h2>
<div className="bg-az-panel border border-az-border rounded p-2 space-y-1">
{aircrafts.map(a => (
<div key={a.id} onClick={() => handleToggleDefault(a)} className="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-az-bg text-xs text-az-text">
<span className={`px-1 rounded text-[10px] ${a.type === 'Plane' ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
{a.type === 'Plane' ? 'P' : 'C'}
</span>
<span className="flex-1">{a.model}</span>
<span className={`text-sm ${a.isDefault ? 'text-az-orange' : 'text-az-muted'}`}></span>
</div>
))}
</div>
</div>
<ConfirmDialog
open={!!deactivateId}
title={t('admin.deactivate')}
message="Deactivate this user?"
onConfirm={handleDeactivate}
onCancel={() => setDeactivateId(null)}
/>
</div>
)
}
@@ -0,0 +1,90 @@
import { useState, useCallback } from 'react'
import { useResizablePanel } from '../../hooks/useResizablePanel'
import MediaList from './MediaList'
import VideoPlayer from './VideoPlayer'
import CanvasEditor from './CanvasEditor'
import AnnotationsSidebar from './AnnotationsSidebar'
import DetectionClasses from '../../components/DetectionClasses'
import type { Media, AnnotationListItem, Detection } from '../../types'
export default function AnnotationsPage() {
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null)
const [currentTime, setCurrentTime] = useState(0)
const [annotations, setAnnotations] = useState<AnnotationListItem[]>([])
const [selectedAnnotation, setSelectedAnnotation] = useState<AnnotationListItem | null>(null)
const [selectedClassNum, setSelectedClassNum] = useState(0)
const [photoMode, setPhotoMode] = useState(0)
const [detections, setDetections] = useState<Detection[]>([])
const leftPanel = useResizablePanel(250, 200, 400)
const rightPanel = useResizablePanel(200, 150, 350)
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
setSelectedAnnotation(ann)
setDetections(ann.detections)
}, [])
const handleDetectionsChange = useCallback((dets: Detection[]) => {
setDetections(dets)
}, [])
const isVideo = selectedMedia?.mediaType === 2
return (
<div className="flex h-full">
{/* Left panel */}
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
<MediaList
selectedMedia={selectedMedia}
onSelect={setSelectedMedia}
onAnnotationsLoaded={setAnnotations}
/>
<DetectionClasses
selectedClassNum={selectedClassNum}
onSelect={setSelectedClassNum}
photoMode={photoMode}
onPhotoModeChange={setPhotoMode}
/>
</div>
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
{/* Center - video/canvas */}
<div className="flex-1 flex flex-col overflow-hidden">
{selectedMedia && isVideo && (
<VideoPlayer
media={selectedMedia}
onTimeUpdate={setCurrentTime}
selectedClassNum={selectedClassNum}
/>
)}
{selectedMedia && (
<CanvasEditor
media={selectedMedia}
annotation={selectedAnnotation}
detections={detections}
onDetectionsChange={handleDetectionsChange}
selectedClassNum={selectedClassNum}
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>
{/* Right panel */}
<div onMouseDown={rightPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
<div style={{ width: rightPanel.width }} className="bg-az-panel border-l border-az-border flex flex-col shrink-0">
<AnnotationsSidebar
media={selectedMedia}
annotations={annotations}
selectedAnnotation={selectedAnnotation}
onSelect={handleAnnotationSelect}
onAnnotationsUpdate={setAnnotations}
/>
</div>
</div>
)
}
@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../../api/client'
import { createSSE } from '../../api/sse'
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
interface Props {
media: Media | null
annotations: AnnotationListItem[]
selectedAnnotation: AnnotationListItem | null
onSelect: (ann: AnnotationListItem) => void
onAnnotationsUpdate: (anns: AnnotationListItem[]) => void
}
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate }: Props) {
const { t } = useTranslation()
const [detecting, setDetecting] = useState(false)
const [detectLog, setDetectLog] = useState<string[]>([])
useEffect(() => {
if (!media) return
return createSSE<{ annotationId: string; mediaId: string; status: number }>('/api/annotations/annotations/events', (event) => {
if (event.mediaId === media.id) {
api.get<PaginatedResponse<AnnotationListItem>>(
`/api/annotations/annotations?mediaId=${media.id}&pageSize=1000`
).then(res => onAnnotationsUpdate(res.items)).catch(() => {})
}
})
}, [media, onAnnotationsUpdate])
const handleDetect = async () => {
if (!media) return
setDetecting(true)
setDetectLog(['Starting AI detection...'])
try {
await api.post(`/api/detect/${media.id}`)
setDetectLog(prev => [...prev, 'Detection complete.'])
} catch (e: any) {
setDetectLog(prev => [...prev, `Error: ${e.message}`])
}
}
const getRowGradient = (ann: AnnotationListItem) => {
if (ann.detections.length === 0) return 'rgba(221,221,221,0.25)'
const stops = ann.detections.map((d, i) => {
const pct = (i / Math.max(ann.detections.length - 1, 1)) * 100
const alpha = Math.min(1, d.confidence)
return `${d.label ? getClassColor(d.classNum) : '#888'}${Math.round(alpha * 40).toString(16).padStart(2, '0')} ${pct}%`
})
return `linear-gradient(to right, ${stops.join(', ')})`
}
const classColors: Record<number, string> = {}
const getClassColor = (classNum: number) => {
if (classColors[classNum]) return classColors[classNum]
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF', '#188021', '#800000', '#008000', '#000080']
return colors[classNum % colors.length]
}
return (
<div className="flex flex-col h-full">
<div className="p-2 border-b border-az-border flex items-center justify-between">
<span className="text-xs font-semibold text-az-muted">{t('annotations.title')}</span>
<button
onClick={handleDetect}
disabled={!media}
className="text-xs bg-az-blue text-white px-2 py-0.5 rounded disabled:opacity-50"
>
{t('annotations.detect')}
</button>
</div>
<div className="flex-1 overflow-y-auto">
{annotations.map(ann => (
<div
key={ann.id}
onClick={() => onSelect(ann)}
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs ${
selectedAnnotation?.id === ann.id ? 'ring-1 ring-az-orange ring-inset' : ''
}`}
style={{ background: getRowGradient(ann) }}
>
<div className="flex items-center justify-between">
<span className="text-az-text font-mono">{ann.time || '—'}</span>
<span className="text-az-muted">{ann.detections.length > 0 ? ann.detections[0].label : '—'}</span>
</div>
</div>
))}
{annotations.length === 0 && (
<div className="p-2 text-az-muted text-xs text-center">{t('common.noData')}</div>
)}
</div>
{detecting && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]">
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-96 max-h-80 flex flex-col">
<h3 className="text-white font-semibold mb-2">{t('annotations.detect')}</h3>
<div className="flex-1 overflow-y-auto bg-az-bg rounded p-2 text-xs text-az-text font-mono space-y-0.5 mb-2">
{detectLog.map((line, i) => <div key={i}>{line}</div>)}
</div>
<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>
)
}
+347
View File
@@ -0,0 +1,347 @@
import { useRef, useEffect, useState, useCallback } from 'react'
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
interface Props {
media: Media
annotation: AnnotationListItem | null
detections: Detection[]
onDetectionsChange: (dets: Detection[]) => void
selectedClassNum: number
currentTime: number
annotations: AnnotationListItem[]
}
interface DragState {
type: 'draw' | 'move' | 'resize'
startX: number
startY: number
detectionIndex?: number
handle?: string
}
const HANDLE_SIZE = 6
const MIN_BOX_SIZE = 12
const AFFILIATION_COLORS: Record<number, string> = {
0: '#FFD700',
1: '#228be6',
2: '#fa5252',
}
export default function CanvasEditor({ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement | null>(null)
const [zoom, setZoom] = useState(1)
const [pan, setPan] = useState({ x: 0, y: 0 })
const [selected, setSelected] = useState<Set<number>>(new Set())
const [dragState, setDragState] = useState<DragState | null>(null)
const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null)
const [imgSize, setImgSize] = useState({ w: 0, h: 0 })
const loadImage = useCallback(() => {
const img = new Image()
img.crossOrigin = 'anonymous'
if (annotation) {
img.src = `/api/annotations/annotations/${annotation.id}/image`
} else {
img.src = `/api/annotations/media/${media.id}/file`
}
img.onload = () => {
imgRef.current = img
setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
}
}, [media, annotation])
useEffect(() => { loadImage() }, [loadImage])
const toCanvas = useCallback((nx: number, ny: number) => ({
x: nx * imgSize.w * zoom + pan.x,
y: ny * imgSize.h * zoom + pan.y,
}), [imgSize, zoom, pan])
const fromCanvas = useCallback((cx: number, cy: number) => ({
x: Math.max(0, Math.min(1, (cx - pan.x) / (imgSize.w * zoom))),
y: Math.max(0, Math.min(1, (cy - pan.y) / (imgSize.h * zoom))),
}), [imgSize, zoom, pan])
const draw = useCallback(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx || !imgRef.current) return
const container = containerRef.current
if (container) {
canvas.width = container.clientWidth
canvas.height = container.clientHeight
}
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.save()
ctx.drawImage(imgRef.current, pan.x, pan.y, imgSize.w * zoom, imgSize.h * zoom)
const timeWindowDets = getTimeWindowDetections()
const allDets = [...detections, ...timeWindowDets]
allDets.forEach((det, i) => {
const isSelected = selected.has(i) && i < detections.length
const cx = (det.centerX - det.width / 2) * imgSize.w * zoom + pan.x
const cy = (det.centerY - det.height / 2) * imgSize.h * zoom + pan.y
const w = det.width * imgSize.w * zoom
const h = det.height * imgSize.h * zoom
const color = AFFILIATION_COLORS[det.affiliation] || '#FFD700'
ctx.strokeStyle = color
ctx.lineWidth = isSelected ? 2 : 1
ctx.strokeRect(cx, cy, w, h)
ctx.fillStyle = color
ctx.globalAlpha = 0.1
ctx.fillRect(cx, cy, w, h)
ctx.globalAlpha = 1
const label = det.confidence < 0.995
? `${det.label} ${(det.confidence * 100).toFixed(0)}%`
: det.label
ctx.fillStyle = color
ctx.font = '11px sans-serif'
ctx.fillText(label, cx + 2, cy - 3)
if (det.combatReadiness === 1) {
ctx.fillStyle = '#40c057'
ctx.beginPath()
ctx.arc(cx + w - 6, cy + 6, 3, 0, Math.PI * 2)
ctx.fill()
}
if (isSelected) {
const handles = getHandles(cx, cy, w, h)
handles.forEach(hp => {
ctx.fillStyle = '#fff'
ctx.fillRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
ctx.strokeStyle = color
ctx.strokeRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
})
}
})
if (drawRect) {
ctx.strokeStyle = '#fd7e14'
ctx.lineWidth = 1
ctx.setLineDash([4, 4])
ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h)
ctx.setLineDash([])
}
ctx.restore()
}, [detections, selected, zoom, pan, imgSize, drawRect, currentTime, annotations])
useEffect(() => {
const id = requestAnimationFrame(draw)
return () => cancelAnimationFrame(id)
}, [draw])
useEffect(() => {
const container = containerRef.current
if (!container) return
const obs = new ResizeObserver(() => draw())
obs.observe(container)
return () => obs.disconnect()
}, [draw])
const getTimeWindowDetections = (): Detection[] => {
if (media.mediaType !== 2) return []
const timeTicks = currentTime * 10_000_000
return annotations
.filter(a => {
if (!a.time) return false
const parts = a.time.split(':').map(Number)
const annTime = (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 10_000_000
return Math.abs(annTime - timeTicks) < 2_000_000
})
.flatMap(a => a.detections)
}
const getHandles = (x: number, y: number, w: number, h: number) => [
{ x, y, cursor: 'nw-resize', name: 'tl' },
{ x: x + w / 2, y, cursor: 'n-resize', name: 'tc' },
{ x: x + w, y, cursor: 'ne-resize', name: 'tr' },
{ x: x + w, y: y + h / 2, cursor: 'e-resize', name: 'mr' },
{ x: x + w, y: y + h, cursor: 'se-resize', name: 'br' },
{ x: x + w / 2, y: y + h, cursor: 's-resize', name: 'bc' },
{ x, y: y + h, cursor: 'sw-resize', name: 'bl' },
{ x, y: y + h / 2, cursor: 'w-resize', name: 'ml' },
]
const hitTest = (cx: number, cy: number) => {
for (let i = detections.length - 1; i >= 0; i--) {
const d = detections[i]
const bx = (d.centerX - d.width / 2) * imgSize.w * zoom + pan.x
const by = (d.centerY - d.height / 2) * imgSize.h * zoom + pan.y
const bw = d.width * imgSize.w * zoom
const bh = d.height * imgSize.h * zoom
if (selected.has(i)) {
const handles = getHandles(bx, by, bw, bh)
for (const h of handles) {
if (Math.abs(cx - h.x) < HANDLE_SIZE && Math.abs(cy - h.y) < HANDLE_SIZE) {
return { type: 'handle' as const, index: i, handle: h.name }
}
}
}
if (cx >= bx && cx <= bx + bw && cy >= by && cy <= by + bh) {
return { type: 'box' as const, index: i }
}
}
return null
}
const handleMouseDown = (e: React.MouseEvent) => {
const rect = canvasRef.current?.getBoundingClientRect()
if (!rect) return
const mx = e.clientX - rect.left
const my = e.clientY - rect.top
if (e.ctrlKey && e.button === 0) {
setDragState({ type: 'draw', startX: mx, startY: my })
return
}
const hit = hitTest(mx, my)
if (hit?.type === 'handle') {
setDragState({ type: 'resize', startX: mx, startY: my, detectionIndex: hit.index, handle: hit.handle })
} else if (hit?.type === 'box') {
if (e.ctrlKey) {
setSelected(prev => { const n = new Set(prev); n.has(hit.index) ? n.delete(hit.index) : n.add(hit.index); return n })
} else {
setSelected(new Set([hit.index]))
}
setDragState({ type: 'move', startX: mx, startY: my, detectionIndex: hit.index })
} else {
setSelected(new Set())
setDragState({ type: 'draw', startX: mx, startY: my })
}
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!dragState) return
const rect = canvasRef.current?.getBoundingClientRect()
if (!rect) return
const mx = e.clientX - rect.left
const my = e.clientY - rect.top
if (dragState.type === 'draw') {
setDrawRect({
x: Math.min(dragState.startX, mx),
y: Math.min(dragState.startY, my),
w: Math.abs(mx - dragState.startX),
h: Math.abs(my - dragState.startY),
})
} else if (dragState.type === 'move' && dragState.detectionIndex !== undefined) {
const dx = (mx - dragState.startX) / (imgSize.w * zoom)
const dy = (my - dragState.startY) / (imgSize.h * zoom)
const newDets = [...detections]
const indices = selected.size > 0 ? Array.from(selected) : [dragState.detectionIndex]
indices.forEach(i => {
if (newDets[i]) {
newDets[i] = {
...newDets[i],
centerX: Math.max(newDets[i].width / 2, Math.min(1 - newDets[i].width / 2, newDets[i].centerX + dx)),
centerY: Math.max(newDets[i].height / 2, Math.min(1 - newDets[i].height / 2, newDets[i].centerY + dy)),
}
}
})
onDetectionsChange(newDets)
setDragState({ ...dragState, startX: mx, startY: my })
} else if (dragState.type === 'resize' && dragState.detectionIndex !== undefined && dragState.handle) {
const idx = dragState.detectionIndex
const d = detections[idx]
const norm = fromCanvas(mx, my)
const newDets = [...detections]
let x1 = d.centerX - d.width / 2, y1 = d.centerY - d.height / 2
let x2 = d.centerX + d.width / 2, y2 = d.centerY + d.height / 2
if (dragState.handle.includes('l')) x1 = norm.x
if (dragState.handle.includes('r')) x2 = norm.x
if (dragState.handle.includes('t')) y1 = norm.y
if (dragState.handle.includes('b')) y2 = norm.y
const w = Math.abs(x2 - x1), h = Math.abs(y2 - y1)
if (w * imgSize.w * zoom >= MIN_BOX_SIZE && h * imgSize.h * zoom >= MIN_BOX_SIZE) {
newDets[idx] = {
...d,
centerX: Math.min(x1, x2) + w / 2,
centerY: Math.min(y1, y2) + h / 2,
width: w,
height: h,
}
onDetectionsChange(newDets)
}
}
}
const handleMouseUp = () => {
if (dragState?.type === 'draw' && drawRect) {
const w = drawRect.w / (imgSize.w * zoom)
const h = drawRect.h / (imgSize.h * zoom)
if (w * imgSize.w >= MIN_BOX_SIZE && h * imgSize.h >= MIN_BOX_SIZE) {
const center = fromCanvas(drawRect.x + drawRect.w / 2, drawRect.y + drawRect.h / 2)
const newDet: Detection = {
id: crypto.randomUUID(),
classNum: selectedClassNum,
label: '',
confidence: 1,
affiliation: 0 as Affiliation,
combatReadiness: 0 as CombatReadiness,
centerX: center.x,
centerY: center.y,
width: w,
height: h,
}
onDetectionsChange([...detections, newDet])
setSelected(new Set([detections.length]))
}
setDrawRect(null)
}
setDragState(null)
}
const handleWheel = (e: React.WheelEvent) => {
if (!e.ctrlKey) return
e.preventDefault()
const delta = e.deltaY > 0 ? 0.9 : 1.1
setZoom(z => Math.max(0.1, Math.min(10, z * delta)))
}
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement) return
if (e.key === 'Delete' && selected.size > 0) {
onDetectionsChange(detections.filter((_, i) => !selected.has(i)))
setSelected(new Set())
}
if (e.key === 'x' || e.key === 'X') {
if (e.target instanceof HTMLInputElement) return
onDetectionsChange([])
setSelected(new Set())
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [detections, selected, onDetectionsChange])
return (
<div ref={containerRef} className="flex-1 relative overflow-hidden cursor-crosshair">
<canvas
ref={canvasRef}
className="absolute inset-0"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
/>
</div>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useFlight } from '../../components/FlightContext'
import { api } from '../../api/client'
import { useDebounce } from '../../hooks/useDebounce'
import ConfirmDialog from '../../components/ConfirmDialog'
import type { Media, PaginatedResponse, AnnotationListItem } from '../../types'
interface Props {
selectedMedia: Media | null
onSelect: (m: Media) => void
onAnnotationsLoaded: (anns: AnnotationListItem[]) => void
}
export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded }: Props) {
const { t } = useTranslation()
const { selectedFlight } = useFlight()
const [media, setMedia] = useState<Media[]>([])
const [filter, setFilter] = useState('')
const debouncedFilter = useDebounce(filter, 300)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [dragging, setDragging] = useState(false)
const fetchMedia = useCallback(async () => {
const params = new URLSearchParams({ pageSize: '1000' })
if (selectedFlight) params.set('flightId', selectedFlight.id)
if (debouncedFilter) params.set('name', debouncedFilter)
try {
const res = await api.get<PaginatedResponse<Media>>(`/api/annotations/media?${params}`)
setMedia(res.items)
} catch {}
}, [selectedFlight, debouncedFilter])
useEffect(() => { fetchMedia() }, [fetchMedia])
const handleSelect = async (m: Media) => {
onSelect(m)
try {
const res = await api.get<PaginatedResponse<AnnotationListItem>>(
`/api/annotations/annotations?mediaId=${m.id}&pageSize=1000`
)
onAnnotationsLoaded(res.items)
} catch {}
}
const handleDelete = async () => {
if (!deleteId) return
await api.delete(`/api/annotations/media/${deleteId}`)
setDeleteId(null)
fetchMedia()
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
if (!selectedFlight || !e.dataTransfer.files.length) return
const form = new FormData()
form.append('waypointId', '')
for (const file of e.dataTransfer.files) form.append('files', file)
await api.upload('/api/annotations/media/batch', form)
fetchMedia()
}
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) return
const form = new FormData()
form.append('waypointId', '')
for (const file of e.target.files) form.append('files', file)
await api.upload('/api/annotations/media/batch', form)
fetchMedia()
e.target.value = ''
}
return (
<div
className={`flex-1 flex flex-col overflow-hidden ${dragging ? 'ring-2 ring-az-orange ring-inset' : ''}`}
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
>
<div className="p-2 border-b border-az-border flex gap-1">
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder={t('annotations.mediaList')}
className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
/>
<label className="bg-az-orange text-white text-xs px-2 py-1 rounded cursor-pointer">
<input type="file" multiple className="hidden" onChange={handleFileUpload} />
</label>
</div>
<div className="flex-1 overflow-y-auto">
{media.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 === 2 ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
{m.mediaType === 2 ? 'V' : 'P'}
</span>
<span className="truncate flex-1">{m.name}</span>
{m.duration && <span className="text-az-muted">{m.duration}</span>}
</div>
))}
</div>
<ConfirmDialog
open={!!deleteId}
title={t('annotations.deleteMedia')}
onConfirm={handleDelete}
onCancel={() => setDeleteId(null)}
/>
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
import { useRef, useState, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../../api/client'
import { getToken } from '../../api/client'
import type { Media } from '../../types'
interface Props {
media: Media
onTimeUpdate: (time: number) => void
selectedClassNum: number
}
export default function VideoPlayer({ media, onTimeUpdate, selectedClassNum }: Props) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement>(null)
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [muted, setMuted] = useState(false)
const token = getToken()
const videoUrl = `/api/annotations/media/${media.id}/file`
const stepFrames = useCallback((count: number) => {
const video = videoRef.current
if (!video) return
const fps = 30
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + count / fps))
}, [])
const togglePlay = useCallback(() => {
const v = videoRef.current
if (!v) return
if (v.paused) { v.play(); setPlaying(true) }
else { v.pause(); setPlaying(false) }
}, [])
const stop = useCallback(() => {
const v = videoRef.current
if (!v) return
v.pause()
v.currentTime = 0
setPlaying(false)
}, [])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
switch (e.key) {
case ' ': e.preventDefault(); togglePlay(); break
case 'ArrowLeft': e.preventDefault(); stepFrames(e.ctrlKey ? -150 : -1); break
case 'ArrowRight': e.preventDefault(); stepFrames(e.ctrlKey ? 150 : 1); break
case 'm': case 'M': setMuted(m => !m); break
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [togglePlay, stepFrames])
const formatTime = (s: number) => {
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
}
return (
<div className="bg-black flex flex-col">
<video
ref={videoRef}
src={videoUrl}
muted={muted}
className="w-full max-h-[50vh] object-contain"
onTimeUpdate={e => {
const t = (e.target as HTMLVideoElement).currentTime
setCurrentTime(t)
onTimeUpdate(t)
}}
onLoadedMetadata={e => setDuration((e.target as HTMLVideoElement).duration)}
onClick={togglePlay}
/>
{/* Progress bar */}
<div
className="h-1 bg-az-border cursor-pointer"
onClick={e => {
const rect = e.currentTarget.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
if (videoRef.current) videoRef.current.currentTime = pct * duration
}}
>
<div className="h-full bg-az-orange" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} />
</div>
{/* Controls */}
<div className="flex items-center gap-1 px-2 py-1 bg-az-header text-xs">
<button onClick={togglePlay} className="text-az-text hover:text-white px-1">{playing ? '⏸' : '▶'}</button>
<button onClick={stop} className="text-az-text hover:text-white px-1"></button>
{[1, 5, 10, 30, 60].map(n => (
<button key={`prev-${n}`} onClick={() => stepFrames(-n)} className="text-az-muted hover:text-white px-0.5">-{n}</button>
))}
<span className="text-az-muted mx-1">|</span>
{[1, 5, 10, 30, 60].map(n => (
<button key={`next-${n}`} onClick={() => stepFrames(n)} className="text-az-muted hover:text-white px-0.5">+{n}</button>
))}
<div className="flex-1" />
<button onClick={() => setMuted(m => !m)} className="text-az-text hover:text-white px-1">
{muted ? '🔇' : '🔊'}
</button>
<span className="text-az-muted">{formatTime(currentTime)} / {formatTime(duration)}</span>
</div>
</div>
)
}
+253
View File
@@ -0,0 +1,253 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../../api/client'
import { useDebounce } from '../../hooks/useDebounce'
import { useResizablePanel } from '../../hooks/useResizablePanel'
import { useFlight } from '../../components/FlightContext'
import DetectionClasses from '../../components/DetectionClasses'
import ConfirmDialog from '../../components/ConfirmDialog'
import CanvasEditor from '../annotations/CanvasEditor'
import type { DatasetItem, PaginatedResponse, ClassDistributionItem, AnnotationListItem, Detection, Media } from '../../types'
import { AnnotationStatus } from '../../types'
type Tab = 'annotations' | 'editor' | 'distribution'
export default function DatasetPage() {
const { t } = useTranslation()
const { selectedFlight } = useFlight()
const leftPanel = useResizablePanel(250, 200, 400)
const [items, setItems] = useState<DatasetItem[]>([])
const [totalCount, setTotalCount] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [fromDate, setFromDate] = useState('')
const [toDate, setToDate] = useState('')
const [statusFilter, setStatusFilter] = useState<AnnotationStatus | null>(null)
const [objectsOnly, setObjectsOnly] = useState(false)
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 400)
const [selectedClassNum, setSelectedClassNum] = useState(0)
const [photoMode, setPhotoMode] = useState(0)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [tab, setTab] = useState<Tab>('annotations')
const [editorAnnotation, setEditorAnnotation] = useState<AnnotationListItem | null>(null)
const [editorDetections, setEditorDetections] = useState<Detection[]>([])
const [distribution, setDistribution] = useState<ClassDistributionItem[]>([])
const fetchItems = useCallback(async () => {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (fromDate) params.set('fromDate', fromDate)
if (toDate) params.set('toDate', toDate)
if (selectedFlight) params.set('flightId', selectedFlight.id)
if (statusFilter !== null) params.set('status', String(statusFilter))
if (selectedClassNum) params.set('classNum', String(selectedClassNum))
if (objectsOnly) params.set('hasDetections', 'true')
if (debouncedSearch) params.set('name', debouncedSearch)
try {
const res = await api.get<PaginatedResponse<DatasetItem>>(`/api/annotations/dataset?${params}`)
setItems(res.items)
setTotalCount(res.totalCount)
} catch {}
}, [page, pageSize, fromDate, toDate, selectedFlight, statusFilter, selectedClassNum, objectsOnly, debouncedSearch])
useEffect(() => { fetchItems() }, [fetchItems])
const handleDoubleClick = async (item: DatasetItem) => {
try {
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${item.annotationId}`)
setEditorAnnotation(ann)
setEditorDetections(ann.detections)
setTab('editor')
} catch {}
}
const handleValidate = async () => {
if (selectedIds.size === 0) return
await api.post('/api/annotations/dataset/bulk-status', {
annotationIds: Array.from(selectedIds),
status: AnnotationStatus.Validated,
})
setSelectedIds(new Set())
fetchItems()
}
const loadDistribution = useCallback(async () => {
try {
const data = await api.get<ClassDistributionItem[]>('/api/annotations/dataset/class-distribution')
setDistribution(data)
} catch {}
}, [])
useEffect(() => { if (tab === 'distribution') loadDistribution() }, [tab, loadDistribution])
const maxDistCount = Math.max(...distribution.map(d => d.count), 1)
const totalPages = Math.ceil(totalCount / pageSize)
const editorMedia: Media | null = editorAnnotation ? {
id: editorAnnotation.mediaId, name: '', path: '', mediaType: 1, mediaStatus: 0,
duration: null, annotationCount: 0, waypointId: null, userId: '',
} : 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 (
<div className="flex h-full">
{/* Left panel */}
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
<DetectionClasses
selectedClassNum={selectedClassNum}
onSelect={setSelectedClassNum}
photoMode={photoMode}
onPhotoModeChange={setPhotoMode}
/>
<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="flex-1 flex flex-col overflow-hidden">
{/* Filter bar */}
<div className="flex items-center gap-2 p-2 border-b border-az-border bg-az-panel text-xs flex-wrap">
<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" />
<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
key={String(sb.value)}
onClick={() => { setStatusFilter(sb.value); setPage(1) }}
className={`px-2 py-0.5 rounded ${statusFilter === sb.value ? 'bg-az-orange text-white' : 'bg-az-bg text-az-muted'}`}
>
{sb.label}
</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
key={tb}
onClick={() => setTab(tb)}
className={`px-3 py-1.5 text-xs ${tab === tb ? 'bg-az-bg text-white border-b-2 border-az-orange' : 'text-az-muted'}`}
>
{t(`dataset.${tb === 'distribution' ? 'classDistribution' : tb}`)}
</button>
))}
</div>
{/* Content */}
{tab === 'annotations' && (
<div className="flex-1 overflow-y-auto p-2">
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}>
{items.map(item => (
<div
key={item.annotationId}
onClick={e => {
if (e.ctrlKey) {
setSelectedIds(prev => {
const n = new Set(prev)
n.has(item.annotationId) ? n.delete(item.annotationId) : n.add(item.annotationId)
return n
})
} else {
setSelectedIds(new Set([item.annotationId]))
}
}}
onDoubleClick={() => handleDoubleClick(item)}
className={`bg-az-panel border rounded overflow-hidden cursor-pointer ${
selectedIds.has(item.annotationId) ? 'border-az-orange' : 'border-az-border'
} ${item.isSeed ? 'ring-2 ring-az-red' : ''}`}
>
<img
src={`/api/annotations/annotations/${item.annotationId}/thumbnail`}
alt={item.imageName}
className="w-full h-32 object-cover bg-az-bg"
loading="lazy"
/>
<div className="p-1.5 text-xs">
<div className="truncate text-az-text">{item.imageName}</div>
<div className="flex justify-between">
<span className="text-az-muted">{new Date(item.createdDate).toLocaleDateString()}</span>
<span className={`px-1 rounded ${
item.status === AnnotationStatus.Validated ? 'bg-az-green/20 text-az-green' :
item.status === AnnotationStatus.Edited ? 'bg-az-blue/20 text-az-blue' :
'bg-az-muted/20 text-az-muted'
}`}>
{item.status === AnnotationStatus.Validated ? t('dataset.status.validated') :
item.status === AnnotationStatus.Edited ? t('dataset.status.edited') :
t('dataset.status.created')}
</span>
</div>
</div>
</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 && (
<div className="flex-1 overflow-hidden">
<CanvasEditor
media={editorMedia}
annotation={editorAnnotation}
detections={editorDetections}
onDetectionsChange={setEditorDetections}
selectedClassNum={selectedClassNum}
currentTime={0}
annotations={[]}
/>
</div>
)}
{tab === 'distribution' && (
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-1.5 max-w-2xl">
{distribution.map(d => (
<div key={d.classNum} className="flex items-center gap-2 text-xs">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: d.color }} />
<span className="w-40 truncate text-az-text">{d.label}</span>
<div className="flex-1 bg-az-bg rounded h-4 overflow-hidden">
<div className="h-full rounded" style={{ width: `${(d.count / maxDistCount) * 100}%`, backgroundColor: d.color, opacity: 0.7 }} />
</div>
<span className="text-az-muted w-12 text-right">{d.count}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { MapContainer, TileLayer, Marker, Polyline, Popup } from 'react-leaflet'
import type { Waypoint } from '../../types'
import 'leaflet/dist/leaflet.css'
import L from 'leaflet'
const icon = L.divIcon({ className: 'bg-az-orange rounded-full w-3 h-3 border border-white', iconSize: [12, 12] })
interface Props {
waypoints: Waypoint[]
}
export default function FlightMap({ waypoints }: Props) {
const center: [number, number] = waypoints.length > 0
? [waypoints[0].latitude, waypoints[0].longitude]
: [50.45, 30.52]
const positions = waypoints
.sort((a, b) => a.order - b.order)
.map(w => [w.latitude, w.longitude] as [number, number])
return (
<MapContainer center={center} zoom={13} className="h-full w-full" key={center.join(',')}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{waypoints.map(wp => (
<Marker key={wp.id} position={[wp.latitude, wp.longitude]} icon={icon}>
<Popup>{wp.name}</Popup>
</Marker>
))}
{positions.length > 1 && <Polyline positions={positions} color="#fd7e14" weight={2} />}
</MapContainer>
)
}
+189
View File
@@ -0,0 +1,189 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useFlight } from '../../components/FlightContext'
import { api } from '../../api/client'
import { createSSE } from '../../api/sse'
import { useResizablePanel } from '../../hooks/useResizablePanel'
import ConfirmDialog from '../../components/ConfirmDialog'
import type { Flight, Waypoint, Aircraft } from '../../types'
import FlightMap from './FlightMap'
export default function FlightsPage() {
const { t } = useTranslation()
const { flights, selectedFlight, selectFlight, refreshFlights } = useFlight()
const [mode, setMode] = useState<'params' | 'gps'>('params')
const [waypoints, setWaypoints] = useState<Waypoint[]>([])
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
const [liveGps, setLiveGps] = useState<{ lat: number; lon: number; satellites: number; status: string } | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [newName, setNewName] = useState('')
const leftPanel = useResizablePanel(200, 150, 350)
useEffect(() => {
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
}, [])
useEffect(() => {
if (!selectedFlight) { setWaypoints([]); return }
api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`).then(setWaypoints).catch(() => {})
}, [selectedFlight])
useEffect(() => {
if (!selectedFlight || mode !== 'gps') return
return createSSE(`/api/flights/${selectedFlight.id}/live-gps`, (data: any) => setLiveGps(data))
}, [selectedFlight, mode])
const handleCreate = async () => {
if (!newName.trim()) return
await api.post('/api/flights', { name: newName.trim() })
setNewName('')
refreshFlights()
}
const handleDelete = async () => {
if (!deleteId) return
await api.delete(`/api/flights/${deleteId}`)
if (selectedFlight?.id === deleteId) selectFlight(null)
setDeleteId(null)
refreshFlights()
}
const handleAddWaypoint = async () => {
if (!selectedFlight) return
await api.post(`/api/flights/${selectedFlight.id}/waypoints`, {
name: `Point ${waypoints.length}`,
latitude: 50.45, longitude: 30.52, order: waypoints.length,
})
const wps = await api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`)
setWaypoints(wps)
}
const handleDeleteWaypoint = async (wpId: string) => {
if (!selectedFlight) return
await api.delete(`/api/flights/${selectedFlight.id}/waypoints/${wpId}`)
setWaypoints(prev => prev.filter(w => w.id !== wpId))
}
return (
<div className="flex h-full">
{/* Flight list sidebar */}
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
<div className="p-2 border-b border-az-border">
<div className="flex gap-1">
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreate()}
placeholder={t('flights.create')}
className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
/>
<button onClick={handleCreate} className="bg-az-orange text-white text-xs px-2 py-1 rounded">+</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{flights.map(f => (
<div
key={f.id}
onClick={() => selectFlight(f)}
className={`px-2 py-1.5 cursor-pointer border-b border-az-border text-sm ${
selectedFlight?.id === f.id ? 'bg-az-bg text-white' : 'text-az-text hover:bg-az-bg'
}`}
>
<div className="flex items-center justify-between">
<span className="truncate">{f.name}</span>
<button onClick={e => { e.stopPropagation(); setDeleteId(f.id) }} className="text-az-muted hover:text-az-red text-xs">×</button>
</div>
<div className="text-xs text-az-muted">{new Date(f.createdDate).toLocaleDateString()}</div>
</div>
))}
</div>
</div>
{/* Resize handle */}
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
{/* Left params panel */}
{selectedFlight && (
<div className="w-64 bg-az-panel border-r border-az-border flex flex-col shrink-0 overflow-y-auto">
<div className="flex border-b border-az-border">
<button
onClick={() => setMode('params')}
className={`flex-1 py-1.5 text-xs ${mode === 'params' ? 'bg-az-bg text-white' : 'text-az-muted'}`}
>
{t('flights.params')}
</button>
<button
onClick={() => setMode('gps')}
className={`flex-1 py-1.5 text-xs ${mode === 'gps' ? 'bg-az-bg text-white' : 'text-az-muted'}`}
>
{t('flights.gpsDenied')}
</button>
</div>
{mode === 'params' && (
<div className="p-2 space-y-2 text-xs">
<div>
<label className="text-az-muted block mb-0.5">{t('flights.aircraft')}</label>
<select className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text">
{aircrafts.map(a => <option key={a.id} value={a.id}>{a.model}</option>)}
</select>
</div>
<div>
<label className="text-az-muted block mb-0.5">{t('flights.height')}</label>
<input type="number" className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" defaultValue={100} />
</div>
<div>
<div className="flex justify-between items-center mb-1">
<label className="text-az-muted">{t('flights.waypoints')}</label>
<button onClick={handleAddWaypoint} className="text-az-orange text-xs">+ Add</button>
</div>
<div className="space-y-0.5">
{waypoints.map(wp => (
<div key={wp.id} className="flex items-center justify-between bg-az-bg rounded px-1.5 py-0.5">
<span className="text-az-text">{wp.name}</span>
<button onClick={() => handleDeleteWaypoint(wp.id)} className="text-az-muted hover:text-az-red">×</button>
</div>
))}
</div>
</div>
</div>
)}
{mode === 'gps' && (
<div className="p-2 space-y-2 text-xs">
<div>
<label className="text-az-muted block mb-1">{t('flights.liveGps')}</label>
{liveGps ? (
<div className="bg-az-bg rounded p-1.5 space-y-0.5">
<div className="text-az-text">Status: <span className="text-az-green">{liveGps.status}</span></div>
<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>
)}
{/* Map view */}
<div className="flex-1 relative">
<FlightMap waypoints={waypoints} />
</div>
<ConfirmDialog
open={!!deleteId}
title={t('common.delete')}
message="Delete this flight and all its data?"
onConfirm={handleDelete}
onCancel={() => setDeleteId(null)}
/>
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../../auth/AuthContext'
type UnlockStep = 'idle' | 'authenticating' | 'downloadingKey' | 'decrypting' | 'startingServices' | 'ready'
const STEP_KEYS: Record<UnlockStep, string> = {
idle: '',
authenticating: 'login.authenticating',
downloadingKey: 'login.downloadingKey',
decrypting: 'login.decrypting',
startingServices: 'login.startingServices',
ready: 'login.ready',
}
export default function LoginPage() {
const { t } = useTranslation()
const { login } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [step, setStep] = useState<UnlockStep>('idle')
const runUnlockSequence = async () => {
const steps: UnlockStep[] = ['downloadingKey', 'decrypting', 'startingServices', 'ready']
for (const s of steps) {
setStep(s)
await new Promise(r => setTimeout(r, 600))
}
navigate('/flights')
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError('')
setStep('authenticating')
try {
await login(email, password)
await runUnlockSequence()
} catch {
setStep('idle')
setError(t('login.error'))
}
}
return (
<div className="flex items-center justify-center h-screen bg-az-bg">
<form onSubmit={handleSubmit} className="bg-az-panel border border-az-border rounded-lg p-6 w-[400px] shadow-2xl">
<h1 className="text-2xl font-bold text-az-orange text-center mb-6 tracking-widest">{t('login.title')}</h1>
{step !== 'idle' && (
<div className="mb-4 text-center">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-2 border-az-orange border-t-transparent mb-2" />
<div className="text-sm text-az-text">{t(STEP_KEYS[step])}</div>
</div>
)}
{step === 'idle' && (
<>
<div className="mb-3">
<label className="block text-xs text-az-muted mb-1">{t('login.email')}</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full bg-az-bg border border-az-border rounded px-3 py-2 text-az-text outline-none focus:border-az-orange"
required
autoFocus
/>
</div>
<div className="mb-4">
<label className="block text-xs text-az-muted mb-1">{t('login.password')}</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-az-bg border border-az-border rounded px-3 py-2 text-az-text outline-none focus:border-az-orange"
required
/>
</div>
{error && <div className="text-az-red text-sm mb-3">{error}</div>}
<button
type="submit"
className="w-full bg-az-orange text-white font-semibold py-2 rounded hover:bg-orange-600 transition"
>
{t('login.submit')}
</button>
</>
)}
</form>
</div>
)
}
+106
View File
@@ -0,0 +1,106 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../../api/client'
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
export default function SettingsPage() {
const { t } = useTranslation()
const [system, setSystem] = useState<SystemSettings | null>(null)
const [dirs, setDirs] = useState<DirectorySettings | null>(null)
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
const [saving, setSaving] = useState(false)
useEffect(() => {
api.get<SystemSettings>('/api/annotations/settings/system').then(setSystem).catch(() => {})
api.get<DirectorySettings>('/api/annotations/settings/directories').then(setDirs).catch(() => {})
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
}, [])
const saveSystem = async () => {
if (!system) return
setSaving(true)
await api.put('/api/annotations/settings/system', system)
setSaving(false)
}
const saveDirs = async () => {
if (!dirs) return
setSaving(true)
await api.put('/api/annotations/settings/directories', dirs)
setSaving(false)
}
const handleToggleDefault = async (a: Aircraft) => {
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
}
const field = (label: string, value: string | number | null | undefined, onChange: (v: string) => void, type = 'text') => (
<div>
<label className="text-az-muted text-xs block mb-0.5">{label}</label>
<input
type={type}
value={value ?? ''}
onChange={e => onChange(e.target.value)}
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none focus:border-az-orange"
/>
</div>
)
return (
<div className="flex h-full overflow-y-auto p-4 gap-6">
{/* Tenant config */}
<div className="w-[300px] shrink-0">
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.tenant')}</h2>
{system && (
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2">
{field('Military Unit', system.militaryUnit, v => setSystem(p => p ? { ...p, militaryUnit: v } : p))}
{field('Name', system.name, v => setSystem(p => p ? { ...p, name: v } : p))}
{field('Default Camera Width', system.defaultCameraWidth, v => setSystem(p => p ? { ...p, defaultCameraWidth: parseInt(v) || 0 } : p), 'number')}
{field('Default Camera FoV', system.defaultCameraFoV, v => setSystem(p => p ? { ...p, defaultCameraFoV: parseFloat(v) || 0 } : p), 'number')}
<button onClick={saveSystem} disabled={saving} className="bg-az-orange text-white text-xs px-3 py-1 rounded disabled:opacity-50">
{t('settings.save')}
</button>
</div>
)}
</div>
{/* Directories */}
<div className="w-[300px] shrink-0">
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.directories')}</h2>
{dirs && (
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2">
{field('Videos Dir', dirs.videosDir, v => setDirs(p => p ? { ...p, videosDir: v } : p))}
{field('Images Dir', dirs.imagesDir, v => setDirs(p => p ? { ...p, imagesDir: v } : p))}
{field('Labels Dir', dirs.labelsDir, v => setDirs(p => p ? { ...p, labelsDir: v } : p))}
{field('Results Dir', dirs.resultsDir, v => setDirs(p => p ? { ...p, resultsDir: v } : p))}
{field('Thumbnails Dir', dirs.thumbnailsDir, v => setDirs(p => p ? { ...p, thumbnailsDir: v } : p))}
{field('GPS Sat Dir', dirs.gpsSatDir, v => setDirs(p => p ? { ...p, gpsSatDir: v } : p))}
{field('GPS Route Dir', dirs.gpsRouteDir, v => setDirs(p => p ? { ...p, gpsRouteDir: v } : p))}
<button onClick={saveDirs} disabled={saving} className="bg-az-orange text-white text-xs px-3 py-1 rounded disabled:opacity-50">
{t('settings.save')}
</button>
</div>
)}
</div>
{/* Aircrafts */}
<div className="flex-1 max-w-sm">
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.aircrafts')}</h2>
<div className="bg-az-panel border border-az-border rounded p-2 space-y-1">
{aircrafts.map(a => (
<div key={a.id} className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-az-bg text-xs text-az-text">
<span className="flex-1">{a.model}</span>
<span className={`px-1 rounded text-[10px] ${a.type === 'Plane' ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
{a.type}
</span>
<button onClick={() => handleToggleDefault(a)} className={`text-sm ${a.isDefault ? 'text-az-orange' : 'text-az-muted hover:text-az-orange'}`}>
</button>
</div>
))}
</div>
</div>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { useState, useEffect } from 'react'
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}
+32
View File
@@ -0,0 +1,32 @@
import { useState, useCallback, useRef, useEffect } from 'react'
export function useResizablePanel(initialWidth: number, min = 100, max = 600) {
const [width, setWidth] = useState(initialWidth)
const dragging = useRef(false)
const startX = useRef(0)
const startWidth = useRef(0)
const onMouseDown = useCallback((e: React.MouseEvent) => {
dragging.current = true
startX.current = e.clientX
startWidth.current = width
e.preventDefault()
}, [width])
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!dragging.current) return
const delta = e.clientX - startX.current
setWidth(Math.min(max, Math.max(min, startWidth.current + delta)))
}
const onMouseUp = () => { dragging.current = false }
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
return () => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
}
}, [min, max])
return { width, onMouseDown, setWidth }
}
+93
View File
@@ -0,0 +1,93 @@
{
"nav": {
"flights": "Flights",
"annotations": "Annotations",
"dataset": "Dataset Explorer",
"admin": "Admin",
"settings": "Settings",
"logout": "Logout"
},
"login": {
"title": "AZAION",
"email": "Email",
"password": "Password",
"submit": "Sign In",
"authenticating": "Authenticating...",
"downloadingKey": "Downloading key...",
"decrypting": "Decrypting...",
"startingServices": "Starting services...",
"ready": "Ready",
"error": "Invalid credentials"
},
"flights": {
"title": "Flights",
"create": "Create New",
"params": "Flight Parameters",
"gpsDenied": "GPS-Denied",
"waypoints": "Waypoints",
"aircraft": "Aircraft",
"height": "Default Height",
"upload": "Upload",
"back": "Back to Flight",
"orthophoto": "Orthophoto",
"liveGps": "Live GPS",
"correction": "GPS Correction",
"apply": "Apply",
"telemetry": "Telemetry"
},
"annotations": {
"title": "Annotations",
"mediaList": "Media",
"upload": "Upload Files",
"deleteMedia": "Delete media?",
"detect": "AI Detect",
"save": "Save",
"delete": "Delete",
"deleteAll": "Delete All",
"classes": "Detection Classes",
"photoMode": "Photo Mode",
"regular": "Regular",
"winter": "Winter",
"night": "Night"
},
"dataset": {
"title": "Dataset Explorer",
"annotations": "Annotations",
"editor": "Editor",
"classDistribution": "Class Distribution",
"objectsOnly": "Show with objects only",
"search": "Search...",
"validate": "Validate",
"status": {
"created": "Created",
"edited": "Edited",
"validated": "Validated"
}
},
"admin": {
"title": "Admin",
"classes": "Detection Classes",
"aiSettings": "AI Recognition Settings",
"gpsSettings": "GPS Device Settings",
"aircrafts": "Default Aircrafts",
"users": "User Management",
"addUser": "Add User",
"deactivate": "Deactivate"
},
"settings": {
"title": "Settings",
"tenant": "Tenant Configuration",
"directories": "Directories",
"aircrafts": "Aircrafts",
"save": "Save"
},
"common": {
"confirm": "Confirm",
"cancel": "Cancel",
"delete": "Delete",
"save": "Save",
"loading": "Loading...",
"noData": "No data",
"help": "How to Annotate"
}
}
+13
View File
@@ -0,0 +1,13 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import en from './en.json'
import ua from './ua.json'
i18n.use(initReactI18next).init({
resources: { en: { translation: en }, ua: { translation: ua } },
lng: 'en',
fallbackLng: 'en',
interpolation: { escapeValue: false },
})
export default i18n
+93
View File
@@ -0,0 +1,93 @@
{
"nav": {
"flights": "Польоти",
"annotations": "Анотації",
"dataset": "Датасет",
"admin": "Адмін",
"settings": "Налаштування",
"logout": "Вийти"
},
"login": {
"title": "AZAION",
"email": "Email",
"password": "Пароль",
"submit": "Увійти",
"authenticating": "Автентифікація...",
"downloadingKey": "Завантаження ключа...",
"decrypting": "Розшифрування...",
"startingServices": "Запуск сервісів...",
"ready": "Готово",
"error": "Невірні облікові дані"
},
"flights": {
"title": "Польоти",
"create": "Створити новий",
"params": "Параметри польоту",
"gpsDenied": "GPS-Denied",
"waypoints": "Точки маршруту",
"aircraft": "Літальний апарат",
"height": "Висота за замовч.",
"upload": "Завантажити",
"back": "До польоту",
"orthophoto": "Ортофото",
"liveGps": "GPS Потік",
"correction": "Корекція GPS",
"apply": "Застосувати",
"telemetry": "Телеметрія"
},
"annotations": {
"title": "Анотації",
"mediaList": "Медіа",
"upload": "Завантажити файли",
"deleteMedia": "Видалити медіа?",
"detect": "AI Розпізнавання",
"save": "Зберегти",
"delete": "Видалити",
"deleteAll": "Видалити все",
"classes": "Класи детекцій",
"photoMode": "Режим фото",
"regular": "Звичайний",
"winter": "Зимовий",
"night": "Нічний"
},
"dataset": {
"title": "Датасет",
"annotations": "Анотації",
"editor": "Редактор",
"classDistribution": "Розподіл класів",
"objectsOnly": "Тільки з об'єктами",
"search": "Пошук...",
"validate": "Валідувати",
"status": {
"created": "Створено",
"edited": "Відредаговано",
"validated": "Валідовано"
}
},
"admin": {
"title": "Адмін",
"classes": "Класи детекцій",
"aiSettings": "AI Налаштування",
"gpsSettings": "GPS Пристрій",
"aircrafts": "Літальні апарати",
"users": "Користувачі",
"addUser": "Додати користувача",
"deactivate": "Деактивувати"
},
"settings": {
"title": "Налаштування",
"tenant": "Конфігурація",
"directories": "Директорії",
"aircrafts": "Літальні апарати",
"save": "Зберегти"
},
"common": {
"confirm": "Підтвердити",
"cancel": "Скасувати",
"delete": "Видалити",
"save": "Зберегти",
"loading": "Завантаження...",
"noData": "Немає даних",
"help": "Як анотувати"
}
}
-7
View File
@@ -1,7 +0,0 @@
const CleanIcon = ({ width = 32, height = 32 }) => (
<svg width={width} height={height} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.0339 1.78576C24.5443 1.79004 24.0845 1.98731 23.8248 2.38613L19.6867 10.2253C19.9933 10.3325 20.6829 10.6413 22.9734 11.6834L23.0074 11.7005C23.4033 11.8806 23.7056 12.0093 23.8248 12.0607C23.8652 12.08 23.9036 12.11 23.944 12.1293L26.9582 3.72411C27.1476 3.05941 26.675 2.29393 25.9194 1.9916C25.6384 1.85008 25.3276 1.78361 25.0339 1.78576ZM17.2516 10.8943C16.1979 10.9736 15.2805 11.5011 14.6291 12.421C15.0399 13.1178 15.7594 13.9691 16.7748 13.9476C17.043 13.9369 17.2686 14.132 17.3197 14.3936C17.5262 14.7174 18.7948 15.292 19.1588 15.1484C19.3121 15.0883 19.476 15.1012 19.6186 15.1827C19.7612 15.2642 19.8634 15.3971 19.8911 15.5601C19.9124 15.6501 20.1508 15.9653 20.6404 16.1947C21.0256 16.3748 21.4003 16.4199 21.611 16.3148C21.7473 16.2462 21.8963 16.2441 22.0368 16.2977C22.1794 16.3513 22.3028 16.4649 22.3603 16.6064C22.7286 17.5113 24.4102 17.5777 25.5618 17.4813C25.7299 16.7458 25.6852 15.9803 25.4255 15.2684C25.0658 14.2885 24.3527 13.5145 23.382 13.0728C23.2799 13.0278 22.9499 12.8691 22.5306 12.6783C20.5893 11.7949 19.493 11.3146 19.278 11.2545C18.5671 10.9715 17.8838 10.8471 17.2516 10.8943ZM14.0501 13.5188C13.8798 13.9262 13.6861 14.3078 13.4882 14.6681C13.7223 15.2427 14.4141 16.5743 15.6849 16.2805C15.8318 16.2462 15.9872 16.2655 16.1106 16.3491C16.2362 16.4327 16.3065 16.5614 16.332 16.7093C16.3682 16.9152 16.6087 17.1596 16.9451 17.344C17.4219 17.6035 17.937 17.6614 18.1541 17.567C18.2967 17.5048 18.4564 17.5006 18.5969 17.567C18.7374 17.6335 18.8438 17.76 18.8864 17.9101C18.963 18.1717 19.2312 18.4397 19.6016 18.6305C20.038 18.8535 20.5361 18.935 20.8617 18.8192C21.1129 18.7313 21.3811 18.8385 21.5088 19.0765C21.9899 19.975 23.5694 20.0714 24.7955 19.9857C24.9509 19.5397 25.0956 19.0808 25.2382 18.5962C25.0913 18.6048 24.9381 18.6134 24.7784 18.6134C23.6375 18.6134 22.273 18.3861 21.594 17.4469C21.1342 17.5091 20.6063 17.4083 20.0784 17.1382C19.6953 16.9409 19.2738 16.6386 19.0226 16.2634C18.3287 16.2634 17.5304 15.8753 17.1324 15.6287C16.8237 15.4378 16.5832 15.2491 16.4342 15.0454C15.3528 14.9447 14.5801 14.2349 14.0501 13.5188ZM12.807 15.7487C9.42885 20.4767 3.66025 20.3373 3.59426 20.3287C3.37927 20.3094 3.16853 20.4252 3.06636 20.6204C2.96418 20.8155 2.97908 21.0492 3.11744 21.2207C4.09023 22.4301 5.13539 23.4528 6.19971 24.3255C6.70206 24.4263 9.86309 24.8766 12.9943 21.2207C13.1901 20.9913 13.5329 20.972 13.7606 21.1693C13.9884 21.3665 14.0075 21.7117 13.8117 21.9412C11.6277 24.4906 9.32454 25.2883 7.71529 25.4577C9.06059 26.3711 10.4165 27.0615 11.6831 27.5847C12.4877 27.4153 15.1868 26.6027 17.5751 22.9361C17.7412 22.6831 18.0903 22.5973 18.3414 22.7645C18.5926 22.9318 18.6607 23.2834 18.4947 23.5365C16.6939 26.3003 14.7228 27.5976 13.3349 28.2022C16.1681 29.1264 18.2818 29.2078 18.4606 29.2143H18.4777C18.5798 29.2143 18.6799 29.1843 18.7672 29.1285C18.9204 29.032 22.0751 26.9564 24.3868 21.1178C24.2974 21.1199 24.2058 21.135 24.1143 21.135C22.9223 21.135 21.545 20.8927 20.7936 19.9514C20.2657 20.0157 19.6612 19.8935 19.1077 19.6083C18.6565 19.3746 18.2989 19.0615 18.069 18.6992C17.49 18.7485 16.8642 18.5383 16.4342 18.3046C15.9914 18.0623 15.6445 17.7579 15.4465 17.4126C14.1927 17.4791 13.3221 16.6193 12.807 15.7487Z" fill="#858CA2" />
</svg>
);
export default CleanIcon;
-8
View File
@@ -1,8 +0,0 @@
const DeleteIcon = ({ width = 32, height = 32 }) => (
<svg width={width} height={height} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.6667 5.33335V6.66669H20V5.33335H12V6.66669H9.33334V5.33335C9.33334 4.62611 9.61429 3.94783 10.1144 3.44774C10.6145 2.94764 11.2928 2.66669 12 2.66669H20C20.7073 2.66669 21.3855 2.94764 21.8856 3.44774C22.3857 3.94783 22.6667 4.62611 22.6667 5.33335Z" fill="#858CA2" />
<path d="M26.6667 8H5.33333C4.97971 8 4.64057 8.14048 4.39052 8.39052C4.14048 8.64057 4 8.97971 4 9.33333C4 9.68696 4.14048 10.0261 4.39052 10.2761C4.64057 10.5262 4.97971 10.6667 5.33333 10.6667H6.76L7.92 26.8533C7.96732 27.5277 8.26903 28.159 8.76409 28.6194C9.25915 29.0798 9.91061 29.335 10.5867 29.3333H21.44C22.1161 29.335 22.7675 29.0798 23.2626 28.6194C23.7576 28.159 24.0593 27.5277 24.1067 26.8533L25.24 10.6667H26.6667C27.0203 10.6667 27.3594 10.5262 27.6095 10.2761C27.8595 10.0261 28 9.68696 28 9.33333C28 8.97971 27.8595 8.64057 27.6095 8.39052C27.3594 8.14048 27.0203 8 26.6667 8ZM17.3333 22.6667C17.3333 23.0203 17.1929 23.3594 16.9428 23.6095C16.6928 23.8595 16.3536 24 16 24C15.6464 24 15.3072 23.8595 15.0572 23.6095C14.8071 23.3594 14.6667 23.0203 14.6667 22.6667V14.6667C14.6667 14.313 14.8071 13.9739 15.0572 13.7239C15.3072 13.4738 15.6464 13.3333 16 13.3333C16.3536 13.3333 16.6928 13.4738 16.9428 13.7239C17.1929 13.9739 17.3333 14.313 17.3333 14.6667V22.6667Z" fill="#858CA2" />
</svg>
);
export default DeleteIcon;
-8
View File
@@ -1,8 +0,0 @@
const NextIcon = ({ width = 32, height = 32 }) => (
<svg width={width} height={height} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.01213 22.3739V9.61386C5.01213 7.00053 7.85213 5.36053 10.1188 6.6672L15.6521 9.85387L21.1854 13.0539C23.4521 14.3605 23.4521 17.6272 21.1854 18.9339L15.6521 22.1339L10.1188 25.3206C7.85213 26.6272 5.01213 25.0006 5.01213 22.3739Z" fill="#858CA2" />
<path d="M26.9844 6.75958C27.531 6.75958 27.9844 7.21291 27.9844 7.75958V24.2396C27.9844 24.7862 27.531 25.2396 26.9844 25.2396C26.4377 25.2396 25.9844 24.7862 25.9844 24.2396V7.75958C25.9844 7.21291 26.4377 6.75958 26.9844 6.75958Z" fill="#858CA2" />
</svg>
);
export default NextIcon;
-8
View File
@@ -1,8 +0,0 @@
const PauseIcon = ({ width = 32, height = 32 }) => (
<svg width={width} height={height} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.66669 8.00002C2.66669 5.48586 2.66669 4.22878 3.44774 3.44774C4.22878 2.66669 5.48586 2.66669 8.00002 2.66669C10.5142 2.66669 11.7713 2.66669 12.5523 3.44774C13.3334 4.22878 13.3334 5.48586 13.3334 8.00002V24C13.3334 26.5142 13.3334 27.7712 12.5523 28.5523C11.7713 29.3334 10.5142 29.3334 8.00002 29.3334C5.48586 29.3334 4.22878 29.3334 3.44774 28.5523C2.66669 27.7712 2.66669 26.5142 2.66669 24V8.00002Z" fill="#858CA2" />
<path d="M18.6667 8.00002C18.6667 5.48586 18.6667 4.22878 19.4478 3.44774C20.2288 2.66669 21.4859 2.66669 24 2.66669C26.5142 2.66669 27.7712 2.66669 28.5523 3.44774C29.3334 4.22878 29.3334 5.48586 29.3334 8.00002V24C29.3334 26.5142 29.3334 27.7712 28.5523 28.5523C27.7712 29.3334 26.5142 29.3334 24 29.3334C21.4859 29.3334 20.2288 29.3334 19.4478 28.5523C18.6667 27.7712 18.6667 26.5142 18.6667 24V8.00002Z" fill="#858CA2" />
</svg>
);
export default PauseIcon;
-7
View File
@@ -1,7 +0,0 @@
const PlayIcon = ({ width = 32, height = 32 }) => (
<svg width={width} height={height} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28.5448 12.4701C31.374 14.0087 31.374 17.9914 28.5448 19.5299L11.4622 28.8194C8.71248 30.3147 5.33334 28.3684 5.33334 25.2895V6.71055C5.33334 3.63159 8.71248 1.68538 11.4622 3.18066L28.5448 12.4701Z" fill="#858CA2" />
</svg>
);
export default PlayIcon;
-8
View File
@@ -1,8 +0,0 @@
const PreviousIcon = ({ width = 32, height = 32 }) => (
<svg width={width} height={height} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M26.9879 9.62609V22.3861C26.9879 24.9995 24.1479 26.6395 21.8812 25.3328L16.3479 22.1461L10.8146 18.9461C8.54793 17.6395 8.54793 14.3728 10.8146 13.0661L16.3479 9.86609L21.8812 6.67943C24.1479 5.37276 26.9879 6.99943 26.9879 9.62609Z" fill="#858CA2" />
<path d="M5.01562 25.2404C4.46896 25.2404 4.01562 24.7871 4.01562 24.2404V7.76044C4.01562 7.21377 4.46896 6.76044 5.01562 6.76044C5.56229 6.76044 6.01563 7.21377 6.01563 7.76044V24.2404C6.01563 24.7871 5.56229 25.2404 5.01562 25.2404Z" fill="#858CA2" />
</svg>
);
export default PreviousIcon;
-7
View File
@@ -1,7 +0,0 @@
const SaveIcon = ({ width = 32, height = 32 }) => (
<svg width={width} height={height} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M30 28.4444V10.4218C29.9999 10.0093 29.836 9.61366 29.5442 9.322L22.678 2.45578C22.3863 2.16403 21.9907 2.00009 21.5782 2H3.55556C3.143 2 2.74733 2.16389 2.45561 2.45561C2.16389 2.74733 2 3.143 2 3.55556V28.4444C2 28.857 2.16389 29.2527 2.45561 29.5444C2.74733 29.8361 3.143 30 3.55556 30H28.4444C28.857 30 29.2527 29.8361 29.5444 29.5444C29.8361 29.2527 30 28.857 30 28.4444ZM11.3333 9.77778H17.5556C17.9681 9.77778 18.3638 9.94167 18.6555 10.2334C18.9472 10.5251 19.1111 10.9208 19.1111 11.3333C19.1111 11.7459 18.9472 12.1416 18.6555 12.4333C18.3638 12.725 17.9681 12.8889 17.5556 12.8889H11.3333C10.9208 12.8889 10.5251 12.725 10.2334 12.4333C9.94167 12.1416 9.77778 11.7459 9.77778 11.3333C9.77778 10.9208 9.94167 10.5251 10.2334 10.2334C10.5251 9.94167 10.9208 9.77778 11.3333 9.77778ZM22.2222 24.8889C22.2222 25.9935 21.3268 26.8889 20.2222 26.8889H11.7778C10.6732 26.8889 9.77778 25.9935 9.77778 24.8889V20.6667C9.77778 20.2541 9.94167 19.8584 10.2334 19.5667C10.5251 19.275 10.9208 19.1111 11.3333 19.1111H20.6667C21.0792 19.1111 21.4749 19.275 21.7666 19.5667C22.0583 19.8584 22.2222 20.2541 22.2222 20.6667V24.8889Z" fill="#858CA2" />
</svg>
);
export default SaveIcon;
-7
View File
@@ -1,7 +0,0 @@
const StopIcon = ({ width = 32, height = 32 }) => (
<svg width={width} height={height} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.66666 16C2.66666 9.71462 2.66666 6.57193 4.61928 4.61931C6.5719 2.66669 9.71459 2.66669 16 2.66669C22.2853 2.66669 25.4281 2.66669 27.3807 4.61931C29.3333 6.57193 29.3333 9.71462 29.3333 16C29.3333 22.2854 29.3333 25.4282 27.3807 27.3807C25.4281 29.3334 22.2853 29.3334 16 29.3334C9.71459 29.3334 6.5719 29.3334 4.61928 27.3807C2.66666 25.4282 2.66666 22.2854 2.66666 16Z" fill="#858CA2" />
</svg>
);
export default StopIcon;
+25 -11
View File
@@ -1,17 +1,31 @@
@import "tailwindcss";
@theme {
--color-az-bg: #1e1e1e;
--color-az-panel: #2b2b2b;
--color-az-header: #343a40;
--color-az-border: #495057;
--color-az-muted: #6c757d;
--color-az-text: #adb5bd;
--color-az-orange: #fd7e14;
--color-az-blue: #228be6;
--color-az-red: #fa5252;
--color-az-green: #40c057;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
* {
box-sizing: border-box;
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
::-webkit-scrollbar-track {
background: var(--color-az-bg);
}
::-webkit-scrollbar-thumb {
background: var(--color-az-border);
border-radius: 3px;
}
-17
View File
@@ -1,17 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './i18n/i18n'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
-9
View File
@@ -1,9 +0,0 @@
class DetectionClass {
constructor(id, name, color) {
this.Id = id;
this.Name = name;
this.Color = color;
}
}
export default DetectionClass;

Some files were not shown because too many files have changed in this diff Show More