mirror of
https://github.com/azaion/ui.git
synced 2026-04-22 07:06:35 +00:00
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:
+11
@@ -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
|
||||||
@@ -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.
@@ -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 `1–9` 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.0–1.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 (0–10000 m) + numeric input (50–5000, step 10)
|
||||||
|
- FocalLength: numeric input (0.1–100 mm, step 0.05)
|
||||||
|
- SensorWidth: numeric input (0.1–100 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: 1–9 (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 (0–16) 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 2–3 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 |
|
||||||
|
|-----|--------|
|
||||||
|
| `1–9` / `NumPad1–9` | 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 |
|
||||||
|
|-----|--------|
|
||||||
|
| `1–9` | 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)
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+63
File diff suppressed because one or more lines are too long
Vendored
+13
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
Generated
+2297
-1412
File diff suppressed because it is too large
Load Diff
+21
-45
@@ -1,54 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "azaion.webui",
|
"name": "azaion-ui",
|
||||||
"version": "0.1.1",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"i18next": "^24.2.2",
|
||||||
"@emotion/styled": "^11.14.0",
|
"leaflet": "^1.9.4",
|
||||||
"@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",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-i18next": "^15.4.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^7.4.0"
|
||||||
"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"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.55.1",
|
"@tailwindcss/vite": "^4.1.1",
|
||||||
"@types/node": "^24.5.2",
|
"@types/leaflet": "^1.9.17",
|
||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.0.4",
|
||||||
"typescript": "^4.9.5"
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"tailwindcss": "^4.1.1",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
// },
|
|
||||||
});
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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 |
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# https://www.robotstxt.org/robotstxt.html
|
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
-38
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
+42
-18
@@ -1,19 +1,43 @@
|
|||||||
import React from 'react';
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
import { AuthProvider } from './auth/AuthContext'
|
||||||
import AnnotationMain from './components/AnnotationMain/AnnotationMain';
|
import { FlightProvider } from './components/FlightContext'
|
||||||
import Admin from './components/Admin/Admin.tsx';
|
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 (
|
return (
|
||||||
<Router>
|
<AuthProvider>
|
||||||
<div style={{ width: '100%', height: '100vh' }}> {/* Use full viewport height */}
|
<Routes>
|
||||||
<Routes>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/" element={<AnnotationMain />} />
|
<Route
|
||||||
<Route path="/admin/*" element={<Admin />} />
|
path="/*"
|
||||||
</Routes>
|
element={
|
||||||
</div>
|
<ProtectedRoute>
|
||||||
</Router>
|
<FlightProvider>
|
||||||
);
|
<div className="flex flex-col h-screen">
|
||||||
};
|
<Header />
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
export default App;
|
<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>
|
||||||
|
</div>
|
||||||
|
</FlightProvider>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}</>
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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' }}>
|
|
||||||
— {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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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';
|
|
||||||
|
|
||||||
@@ -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 => ({
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>'
|
|
||||||
}[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
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export const detectionTypes = {
|
|
||||||
day: 'day',
|
|
||||||
night: 'night',
|
|
||||||
winter: 'winter'
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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='© <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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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": "Як анотувати"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
@@ -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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
::-webkit-scrollbar {
|
||||||
box-sizing: border-box;
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
code {
|
background: var(--color-az-bg);
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
}
|
||||||
monospace;
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-az-border);
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 +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 |
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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
Reference in New Issue
Block a user