diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..286d1e0 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md deleted file mode 100644 index c565e38..0000000 --- a/README.md +++ /dev/null @@ -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` diff --git a/_docs/ui_design.textClipping b/_docs/ui_design.textClipping new file mode 100644 index 0000000..8eceb84 Binary files /dev/null and b/_docs/ui_design.textClipping differ diff --git a/_docs/ui_design/README.md b/_docs/ui_design/README.md new file mode 100644 index 0000000..1d10ff1 --- /dev/null +++ b/_docs/ui_design/README.md @@ -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=` +- **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) diff --git a/_docs/ui_design/admin.html b/_docs/ui_design/admin.html new file mode 100644 index 0000000..76d21c3 --- /dev/null +++ b/_docs/ui_design/admin.html @@ -0,0 +1,236 @@ + + + + + +Azaion – Admin Tab Wireframe + + + + + +
+ AZAION + FL02 + +
+ user@azaion.com + + +
+
+ +
+ +
+

Detection Classes

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#NameIcon
0ArmorVehicle
1Truck
2Vehicle
3Artillery
4Shadow
5Trenches
6MilitaryMan
7TyreTracks
8AdditionArmoredTank
9Smoke
10Plane
11Moto
12CamouflageNet
13CamouflageBranches
14Roof
15Building
16Caponier
17Ammo
18Protect.Struct
+
+
+ +
+ +
+

AI Recognition Settings

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

GPS Device Settings

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+

Default Aircrafts

+
+
+ P + DJI Mavic 3 + +
+
+ C + Matrice 300 RTK +
+
+ P + Fixed Wing Scout +
+
+
+ +
+ + + diff --git a/_docs/ui_design/annotations.html b/_docs/ui_design/annotations.html new file mode 100644 index 0000000..7b35a59 --- /dev/null +++ b/_docs/ui_design/annotations.html @@ -0,0 +1,161 @@ + + + + + +Azaion – Annotations Tab Wireframe + + + + + +
+ AZAION + FL03 + +
+ user@azaion.com + + +
+
+ +
+ + + +
+
+
+
+
+ Mil. vehicle +
+
+ Mil. vehicle +
+
+
+
+
+
+
+
+ + + + + + + + + + + + +
+
+
+ + + +
+ + + diff --git a/_docs/ui_design/dataset_explorer.html b/_docs/ui_design/dataset_explorer.html new file mode 100644 index 0000000..e201a18 --- /dev/null +++ b/_docs/ui_design/dataset_explorer.html @@ -0,0 +1,166 @@ + + + + + +Azaion – Dataset Explorer Wireframe + + + + + +
+ AZAION + FL03 + +
+ user@azaion.com + + +
+
+ +
+ + + +
+
+
+ Date + + +
+
+
+ Flight + +
+
+ + + +
+ +
+
+
+
+ Validated +
+
+
+ Created +
+
+
+ Validated +
+
+
+ Edited +
+
+
+ None +
+
+
+ Validated +
+
+
+ Created +
+
+
+ Validated +
+
+
+ Edited +
+
+
+ None +
+
+
+ Validated +
+
+
+ Created +
+
+
+ Validated +
+
+
+ Edited +
+
+
+ None +
+
+
+ Validated +
+
+
+ Created +
+
+
+ Validated +
+
+
+ Edited +
+
+
+ None +
+
+
+
+ +
+ + + diff --git a/_docs/ui_design/flights.html b/_docs/ui_design/flights.html new file mode 100644 index 0000000..f92405a --- /dev/null +++ b/_docs/ui_design/flights.html @@ -0,0 +1,214 @@ + + + + + +Azaion – Flights Tab Wireframe + + + + + +
+ AZAION + FL02 + +
+ user@azaion.com + + +
+
+ +
+ + + +
+
+

Flight Parameters

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +

Waypoints

+
+
+ A1 Start +
+
+ A1 Point1 Track Conf +
+
+ A1 Point2 MilVeh +
+
+ A3 Point3 +
+
+ A3 Point4 Con +
+
+ Finish +
+
+
+ + +
+
+ + +
+ +
+
+
+ + + + +
+
+
+
+
+
+
+
Original path
+
Corrected path
+
+ ~800px +
+
+ +
+ + + + + diff --git a/_docs/ui_design/settings.html b/_docs/ui_design/settings.html new file mode 100644 index 0000000..7398fc7 --- /dev/null +++ b/_docs/ui_design/settings.html @@ -0,0 +1,117 @@ + + + + + +Azaion – Settings Tab Wireframe + + + + + +
+ AZAION + FL02 + +
+ user@azaion.com + + +
+
+ +
+ +
+
+

Tenant

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+

Directories

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Aircrafts

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ModelTypeDefault
DJI Mavic 3Plane
Matrice 300 RTKCopter
Fixed Wing ScoutPlane
+
+
+ +
+ + + diff --git a/dist/assets/index--amdfC0Y.css b/dist/assets/index--amdfC0Y.css new file mode 100644 index 0000000..c32a8c6 --- /dev/null +++ b/dist/assets/index--amdfC0Y.css @@ -0,0 +1 @@ +.leaflet-pane,.leaflet-tile,.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-tile-container,.leaflet-pane>svg,.leaflet-pane>canvas,.leaflet-zoom-box,.leaflet-image-layer,.leaflet-layer{position:absolute;left:0;top:0}.leaflet-container{overflow:hidden}.leaflet-tile,.leaflet-marker-icon,.leaflet-marker-shadow{-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-user-drag:none}.leaflet-tile::selection{background:transparent}.leaflet-safari .leaflet-tile{image-rendering:-webkit-optimize-contrast}.leaflet-safari .leaflet-tile-container{width:1600px;height:1600px;-webkit-transform-origin:0 0}.leaflet-marker-icon,.leaflet-marker-shadow{display:block}.leaflet-container .leaflet-overlay-pane svg{max-width:none!important;max-height:none!important}.leaflet-container .leaflet-marker-pane img,.leaflet-container .leaflet-shadow-pane img,.leaflet-container .leaflet-tile-pane img,.leaflet-container img.leaflet-image-layer,.leaflet-container .leaflet-tile{max-width:none!important;max-height:none!important;width:auto;padding:0}.leaflet-container img.leaflet-tile{mix-blend-mode:plus-lighter}.leaflet-container.leaflet-touch-zoom{-ms-touch-action:pan-x pan-y;touch-action:pan-x pan-y}.leaflet-container.leaflet-touch-drag{-ms-touch-action:pinch-zoom;touch-action:none;touch-action:pinch-zoom}.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom{-ms-touch-action:none;touch-action:none}.leaflet-container{-webkit-tap-highlight-color:transparent}.leaflet-container a{-webkit-tap-highlight-color:rgba(51,181,229,.4)}.leaflet-tile{filter:inherit;visibility:hidden}.leaflet-tile-loaded{visibility:inherit}.leaflet-zoom-box{width:0;height:0;-moz-box-sizing:border-box;box-sizing:border-box;z-index:800}.leaflet-overlay-pane svg{-moz-user-select:none}.leaflet-pane{z-index:400}.leaflet-tile-pane{z-index:200}.leaflet-overlay-pane{z-index:400}.leaflet-shadow-pane{z-index:500}.leaflet-marker-pane{z-index:600}.leaflet-tooltip-pane{z-index:650}.leaflet-popup-pane{z-index:700}.leaflet-map-pane canvas{z-index:100}.leaflet-map-pane svg{z-index:200}.leaflet-vml-shape{width:1px;height:1px}.lvml{behavior:url(#default#VML);display:inline-block;position:absolute}.leaflet-control{position:relative;z-index:800;pointer-events:visiblePainted;pointer-events:auto}.leaflet-top,.leaflet-bottom{position:absolute;z-index:1000;pointer-events:none}.leaflet-top{top:0}.leaflet-right{right:0}.leaflet-bottom{bottom:0}.leaflet-left{left:0}.leaflet-control{float:left;clear:both}.leaflet-right .leaflet-control{float:right}.leaflet-top .leaflet-control{margin-top:10px}.leaflet-bottom .leaflet-control{margin-bottom:10px}.leaflet-left .leaflet-control{margin-left:10px}.leaflet-right .leaflet-control{margin-right:10px}.leaflet-fade-anim .leaflet-popup{opacity:0;-webkit-transition:opacity .2s linear;-moz-transition:opacity .2s linear;transition:opacity .2s linear}.leaflet-fade-anim .leaflet-map-pane .leaflet-popup{opacity:1}.leaflet-zoom-animated{-webkit-transform-origin:0 0;-ms-transform-origin:0 0;transform-origin:0 0}svg.leaflet-zoom-animated{will-change:transform}.leaflet-zoom-anim .leaflet-zoom-animated{-webkit-transition:-webkit-transform .25s cubic-bezier(0,0,.25,1);-moz-transition:-moz-transform .25s cubic-bezier(0,0,.25,1);transition:transform .25s cubic-bezier(0,0,.25,1)}.leaflet-zoom-anim .leaflet-tile,.leaflet-pan-anim .leaflet-tile{-webkit-transition:none;-moz-transition:none;transition:none}.leaflet-zoom-anim .leaflet-zoom-hide{visibility:hidden}.leaflet-interactive{cursor:pointer}.leaflet-grab{cursor:-webkit-grab;cursor:-moz-grab;cursor:grab}.leaflet-crosshair,.leaflet-crosshair .leaflet-interactive{cursor:crosshair}.leaflet-popup-pane,.leaflet-control{cursor:auto}.leaflet-dragging .leaflet-grab,.leaflet-dragging .leaflet-grab .leaflet-interactive,.leaflet-dragging .leaflet-marker-draggable{cursor:move;cursor:-webkit-grabbing;cursor:-moz-grabbing;cursor:grabbing}.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-image-layer,.leaflet-pane>svg path,.leaflet-tile-container{pointer-events:none}.leaflet-marker-icon.leaflet-interactive,.leaflet-image-layer.leaflet-interactive,.leaflet-pane>svg path.leaflet-interactive,svg.leaflet-image-layer.leaflet-interactive path{pointer-events:visiblePainted;pointer-events:auto}.leaflet-container{background:#ddd;outline-offset:1px}.leaflet-container a{color:#0078a8}.leaflet-zoom-box{border:2px dotted #38f;background:#ffffff80}.leaflet-container{font-family:Helvetica Neue,Arial,Helvetica,sans-serif;font-size:12px;font-size:.75rem;line-height:1.5}.leaflet-bar{box-shadow:0 1px 5px #000000a6;border-radius:4px}.leaflet-bar a{background-color:#fff;border-bottom:1px solid #ccc;width:26px;height:26px;line-height:26px;display:block;text-align:center;text-decoration:none;color:#000}.leaflet-bar a,.leaflet-control-layers-toggle{background-position:50% 50%;background-repeat:no-repeat;display:block}.leaflet-bar a:hover,.leaflet-bar a:focus{background-color:#f4f4f4}.leaflet-bar a:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.leaflet-bar a:last-child{border-bottom-left-radius:4px;border-bottom-right-radius:4px;border-bottom:none}.leaflet-bar a.leaflet-disabled{cursor:default;background-color:#f4f4f4;color:#bbb}.leaflet-touch .leaflet-bar a{width:30px;height:30px;line-height:30px}.leaflet-touch .leaflet-bar a:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.leaflet-touch .leaflet-bar a:last-child{border-bottom-left-radius:2px;border-bottom-right-radius:2px}.leaflet-control-zoom-in,.leaflet-control-zoom-out{font:700 18px Lucida Console,Monaco,monospace;text-indent:1px}.leaflet-touch .leaflet-control-zoom-in,.leaflet-touch .leaflet-control-zoom-out{font-size:22px}.leaflet-control-layers{box-shadow:0 1px 5px #0006;background:#fff;border-radius:5px}.leaflet-control-layers-toggle{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAQAAAADQ4RFAAACf0lEQVR4AY1UM3gkARTePdvdoTxXKc+qTl3aU5U6b2Kbkz3Gtq3Zw6ziLGNPzrYx7946Tr6/ee/XeCQ4D3ykPtL5tHno4n0d/h3+xfuWHGLX81cn7r0iTNzjr7LrlxCqPtkbTQEHeqOrTy4Yyt3VCi/IOB0v7rVC7q45Q3Gr5K6jt+3Gl5nCoDD4MtO+j96Wu8atmhGqcNGHObuf8OM/x3AMx38+4Z2sPqzCxRFK2aF2e5Jol56XTLyggAMTL56XOMoS1W4pOyjUcGGQdZxU6qRh7B9Zp+PfpOFlqt0zyDZckPi1ttmIp03jX8gyJ8a/PG2yutpS/Vol7peZIbZcKBAEEheEIAgFbDkz5H6Zrkm2hVWGiXKiF4Ycw0RWKdtC16Q7qe3X4iOMxruonzegJzWaXFrU9utOSsLUmrc0YjeWYjCW4PDMADElpJSSQ0vQvA1Tm6/JlKnqFs1EGyZiFCqnRZTEJJJiKRYzVYzJck2Rm6P4iH+cmSY0YzimYa8l0EtTODFWhcMIMVqdsI2uiTvKmTisIDHJ3od5GILVhBCarCfVRmo4uTjkhrhzkiBV7SsaqS+TzrzM1qpGGUFt28pIySQHR6h7F6KSwGWm97ay+Z+ZqMcEjEWebE7wxCSQwpkhJqoZA5ivCdZDjJepuJ9IQjGGUmuXJdBFUygxVqVsxFsLMbDe8ZbDYVCGKxs+W080max1hFCarCfV+C1KATwcnvE9gRRuMP2prdbWGowm1KB1y+zwMMENkM755cJ2yPDtqhTI6ED1M/82yIDtC/4j4BijjeObflpO9I9MwXTCsSX8jWAFeHr05WoLTJ5G8IQVS/7vwR6ohirYM7f6HzYpogfS3R2OAAAAAElFTkSuQmCC);width:36px;height:36px}.leaflet-retina .leaflet-control-layers-toggle{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA0CAQAAABvcdNgAAAEsklEQVR4AWL4TydIhpZK1kpWOlg0w3ZXP6D2soBtG42jeI6ZmQTHzAxiTbSJsYLjO9HhP+WOmcuhciVnmHVQcJnp7DFvScowZorad/+V/fVzMdMT2g9Cv9guXGv/7pYOrXh2U+RRR3dSd9JRx6bIFc/ekqHI29JC6pJ5ZEh1yWkhkbcFeSjxgx3L2m1cb1C7bceyxA+CNjT/Ifff+/kDk2u/w/33/IeCMOSaWZ4glosqT3DNnNZQ7Cs58/3Ce5HL78iZH/vKVIaYlqzfdLu8Vi7dnvUbEza5Idt36tquZFldl6N5Z/POLof0XLK61mZCmJSWjVF9tEjUluu74IUXvgttuVIHE7YxSkaYhJZam7yiM9Pv82JYfl9nptxZaxMJE4YSPty+vF0+Y2up9d3wwijfjZbabqm/3bZ9ecKHsiGmRflnn1MW4pjHf9oLufyn2z3y1D6n8g8TZhxyzipLNPnAUpsOiuWimg52psrTZYnOWYNDTMuWBWa0tJb4rgq1UvmutpaYEbZlwU3CLJm/ayYjHW5/h7xWLn9Hh1vepDkyf7dE7MtT5LR4e7yYpHrkhOUpEfssBLq2pPhAqoSWKUkk7EDqkmK6RrCEzqDjhNDWNE+XSMvkJRDWlZTmCW0l0PHQGRZY5t1L83kT0Y3l2SItk5JAWHl2dCOBm+fPu3fo5/3v61RMCO9Jx2EEYYhb0rmNQMX/vm7gqOEJLcXTGw3CAuRNeyaPWwjR8PRqKQ1PDA/dpv+on9Shox52WFnx0KY8onHayrJzm87i5h9xGw/tfkev0jGsQizqezUKjk12hBMKJ4kbCqGPVNXudyyrShovGw5CgxsRICxF6aRmSjlBnHRzg7Gx8fKqEubI2rahQYdR1YgDIRQO7JvQyD52hoIQx0mxa0ODtW2Iozn1le2iIRdzwWewedyZzewidueOGqlsn1MvcnQpuVwLGG3/IR1hIKxCjelIDZ8ldqWz25jWAsnldEnK0Zxro19TGVb2ffIZEsIO89EIEDvKMPrzmBOQcKQ+rroye6NgRRxqR4U8EAkz0CL6uSGOm6KQCdWjvjRiSP1BPalCRS5iQYiEIvxuBMJEWgzSoHADcVMuN7IuqqTeyUPq22qFimFtxDyBBJEwNyt6TM88blFHao/6tWWhuuOM4SAK4EI4QmFHA+SEyWlp4EQoJ13cYGzMu7yszEIBOm2rVmHUNqwAIQabISNMRstmdhNWcFLsSm+0tjJH1MdRxO5Nx0WDMhCtgD6OKgZeljJqJKc9po8juskR9XN0Y1lZ3mWjLR9JCO1jRDMd0fpYC2VnvjBSEFg7wBENc0R9HFlb0xvF1+TBEpF68d+DHR6IOWVv2BECtxo46hOFUBd/APU57WIoEwJhIi2CdpyZX0m93BZicktMj1AS9dClteUFAUNUIEygRZCtik5zSxI9MubTBH1GOiHsiLJ3OCoSZkILa9PxiN0EbvhsAo8tdAf9Seepd36lGWHmtNANTv5Jd0z4QYyeo/UEJqxKRpg5LZx6btLPsOaEmdMyxYdlc8LMaJnikDlhclqmPiQnTEpLUIZEwkRagjYkEibQErwhkTAKCLQEbUgkzJQWc/0PstHHcfEdQ+UAAAAASUVORK5CYII=);background-size:26px 26px}.leaflet-touch .leaflet-control-layers-toggle{width:44px;height:44px}.leaflet-control-layers .leaflet-control-layers-list,.leaflet-control-layers-expanded .leaflet-control-layers-toggle{display:none}.leaflet-control-layers-expanded .leaflet-control-layers-list{display:block;position:relative}.leaflet-control-layers-expanded{padding:6px 10px 6px 6px;color:#333;background:#fff}.leaflet-control-layers-scrollbar{overflow-y:scroll;overflow-x:hidden;padding-right:5px}.leaflet-control-layers-selector{margin-top:2px;position:relative;top:1px}.leaflet-control-layers label{display:block;font-size:13px;font-size:1.08333em}.leaflet-control-layers-separator{height:0;border-top:1px solid #ddd;margin:5px -10px 5px -6px}.leaflet-default-icon-path{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAApCAYAAADAk4LOAAAFgUlEQVR4Aa1XA5BjWRTN2oW17d3YaZtr2962HUzbDNpjszW24mRt28p47v7zq/bXZtrp/lWnXr337j3nPCe85NcypgSFdugCpW5YoDAMRaIMqRi6aKq5E3YqDQO3qAwjVWrD8Ncq/RBpykd8oZUb/kaJutow8r1aP9II0WmLKLIsJyv1w/kqw9Ch2MYdB++12Onxee/QMwvf4/Dk/Lfp/i4nxTXtOoQ4pW5Aj7wpici1A9erdAN2OH64x8OSP9j3Ft3b7aWkTg/Fm91siTra0f9on5sQr9INejH6CUUUpavjFNq1B+Oadhxmnfa8RfEmN8VNAsQhPqF55xHkMzz3jSmChWU6f7/XZKNH+9+hBLOHYozuKQPxyMPUKkrX/K0uWnfFaJGS1QPRtZsOPtr3NsW0uyh6NNCOkU3Yz+bXbT3I8G3xE5EXLXtCXbbqwCO9zPQYPRTZ5vIDXD7U+w7rFDEoUUf7ibHIR4y6bLVPXrz8JVZEql13trxwue/uDivd3fkWRbS6/IA2bID4uk0UpF1N8qLlbBlXs4Ee7HLTfV1j54APvODnSfOWBqtKVvjgLKzF5YdEk5ewRkGlK0i33Eofffc7HT56jD7/6U+qH3Cx7SBLNntH5YIPvODnyfIXZYRVDPqgHtLs5ABHD3YzLuespb7t79FY34DjMwrVrcTuwlT55YMPvOBnRrJ4VXTdNnYug5ucHLBjEpt30701A3Ts+HEa73u6dT3FNWwflY86eMHPk+Yu+i6pzUpRrW7SNDg5JHR4KapmM5Wv2E8Tfcb1HoqqHMHU+uWDD7zg54mz5/2BSnizi9T1Dg4QQXLToGNCkb6tb1NU+QAlGr1++eADrzhn/u8Q2YZhQVlZ5+CAOtqfbhmaUCS1ezNFVm2imDbPmPng5wmz+gwh+oHDce0eUtQ6OGDIyR0uUhUsoO3vfDmmgOezH0mZN59x7MBi++WDL1g/eEiU3avlidO671bkLfwbw5XV2P8Pzo0ydy4t2/0eu33xYSOMOD8hTf4CrBtGMSoXfPLchX+J0ruSePw3LZeK0juPJbYzrhkH0io7B3k164hiGvawhOKMLkrQLyVpZg8rHFW7E2uHOL888IBPlNZ1FPzstSJM694fWr6RwpvcJK60+0HCILTBzZLFNdtAzJaohze60T8qBzyh5ZuOg5e7uwQppofEmf2++DYvmySqGBuKaicF1blQjhuHdvCIMvp8whTTfZzI7RldpwtSzL+F1+wkdZ2TBOW2gIF88PBTzD/gpeREAMEbxnJcaJHNHrpzji0gQCS6hdkEeYt9DF/2qPcEC8RM28Hwmr3sdNyht00byAut2k3gufWNtgtOEOFGUwcXWNDbdNbpgBGxEvKkOQsxivJx33iow0Vw5S6SVTrpVq11ysA2Rp7gTfPfktc6zhtXBBC+adRLshf6sG2RfHPZ5EAc4sVZ83yCN00Fk/4kggu40ZTvIEm5g24qtU4KjBrx/BTTH8ifVASAG7gKrnWxJDcU7x8X6Ecczhm3o6YicvsLXWfh3Ch1W0k8x0nXF+0fFxgt4phz8QvypiwCCFKMqXCnqXExjq10beH+UUA7+nG6mdG/Pu0f3LgFcGrl2s0kNNjpmoJ9o4B29CMO8dMT4Q5ox8uitF6fqsrJOr8qnwNbRzv6hSnG5wP+64C7h9lp30hKNtKdWjtdkbuPA19nJ7Tz3zR/ibgARbhb4AlhavcBebmTHcFl2fvYEnW0ox9xMxKBS8btJ+KiEbq9zA4RthQXDhPa0T9TEe69gWupwc6uBUphquXgf+/FrIjweHQS4/pduMe5ERUMHUd9xv8ZR98CxkS4F2n3EUrUZ10EYNw7BWm9x1GiPssi3GgiGRDKWRYZfXlON+dfNbM+GgIwYdwAAAAASUVORK5CYII=)}.leaflet-container .leaflet-control-attribution{background:#fff;background:#fffc;margin:0}.leaflet-control-attribution,.leaflet-control-scale-line{padding:0 5px;color:#333;line-height:1.4}.leaflet-control-attribution a{text-decoration:none}.leaflet-control-attribution a:hover,.leaflet-control-attribution a:focus{text-decoration:underline}.leaflet-attribution-flag{display:inline!important;vertical-align:baseline!important;width:1em;height:.6669em}.leaflet-left .leaflet-control-scale{margin-left:5px}.leaflet-bottom .leaflet-control-scale{margin-bottom:5px}.leaflet-control-scale-line{border:2px solid #777;border-top:none;line-height:1.1;padding:2px 5px 1px;white-space:nowrap;-moz-box-sizing:border-box;box-sizing:border-box;background:#fffc;text-shadow:1px 1px #fff}.leaflet-control-scale-line:not(:first-child){border-top:2px solid #777;border-bottom:none;margin-top:-2px}.leaflet-control-scale-line:not(:first-child):not(:last-child){border-bottom:2px solid #777}.leaflet-touch .leaflet-control-attribution,.leaflet-touch .leaflet-control-layers,.leaflet-touch .leaflet-bar{box-shadow:none}.leaflet-touch .leaflet-control-layers,.leaflet-touch .leaflet-bar{border:2px solid rgba(0,0,0,.2);background-clip:padding-box}.leaflet-popup{position:absolute;text-align:center;margin-bottom:20px}.leaflet-popup-content-wrapper{padding:1px;text-align:left;border-radius:12px}.leaflet-popup-content{margin:13px 24px 13px 20px;line-height:1.3;font-size:13px;font-size:1.08333em;min-height:1px}.leaflet-popup-content p{margin:1.3em 0}.leaflet-popup-tip-container{width:40px;height:20px;position:absolute;left:50%;margin-top:-1px;margin-left:-20px;overflow:hidden;pointer-events:none}.leaflet-popup-tip{width:17px;height:17px;padding:1px;margin:-10px auto 0;pointer-events:auto;-webkit-transform:rotate(45deg);-moz-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.leaflet-popup-content-wrapper,.leaflet-popup-tip{background:#fff;color:#333;box-shadow:0 3px 14px #0006}.leaflet-container a.leaflet-popup-close-button{position:absolute;top:0;right:0;border:none;text-align:center;width:24px;height:24px;font:16px/24px Tahoma,Verdana,sans-serif;color:#757575;text-decoration:none;background:transparent}.leaflet-container a.leaflet-popup-close-button:hover,.leaflet-container a.leaflet-popup-close-button:focus{color:#585858}.leaflet-popup-scrolled{overflow:auto}.leaflet-oldie .leaflet-popup-content-wrapper{-ms-zoom:1}.leaflet-oldie .leaflet-popup-tip{width:24px;margin:0 auto;-ms-filter:"progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";filter:progid:DXImageTransform.Microsoft.Matrix(M11=.70710678,M12=.70710678,M21=-.70710678,M22=.70710678)}.leaflet-oldie .leaflet-control-zoom,.leaflet-oldie .leaflet-control-layers,.leaflet-oldie .leaflet-popup-content-wrapper,.leaflet-oldie .leaflet-popup-tip{border:1px solid #999}.leaflet-div-icon{background:#fff;border:1px solid #666}.leaflet-tooltip{position:absolute;padding:6px;background-color:#fff;border:1px solid #fff;border-radius:3px;color:#222;white-space:nowrap;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;pointer-events:none;box-shadow:0 1px 3px #0006}.leaflet-tooltip.leaflet-interactive{cursor:pointer;pointer-events:auto}.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{position:absolute;pointer-events:none;border:6px solid transparent;background:transparent;content:""}.leaflet-tooltip-bottom{margin-top:6px}.leaflet-tooltip-top{margin-top:-6px}.leaflet-tooltip-bottom:before,.leaflet-tooltip-top:before{left:50%;margin-left:-6px}.leaflet-tooltip-top:before{bottom:0;margin-bottom:-12px;border-top-color:#fff}.leaflet-tooltip-bottom:before{top:0;margin-top:-12px;margin-left:-6px;border-bottom-color:#fff}.leaflet-tooltip-left{margin-left:-6px}.leaflet-tooltip-right{margin-left:6px}.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{top:50%;margin-top:-6px}.leaflet-tooltip-left:before{right:0;margin-right:-12px;border-left-color:#fff}.leaflet-tooltip-right:before{left:0;margin-left:-12px;border-right-color:#fff}@media print{.leaflet-control{-webkit-print-color-adjust:exact;print-color-adjust:exact}}/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-600:oklch(57.7% .245 27.325);--color-orange-600:oklch(64.6% .222 41.116);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--tracking-widest:.1em;--radius-lg:.5rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--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}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.inset-0{inset:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.top-full{top:100%}.right-0{right:calc(var(--spacing) * 0)}.bottom-0{bottom:calc(var(--spacing) * 0)}.left-0{left:calc(var(--spacing) * 0)}.z-50{z-index:50}.z-\[100\]{z-index:100}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.h-1{height:calc(var(--spacing) * 1)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-32{height:calc(var(--spacing) * 32)}.h-full{height:100%}.h-screen{height:100vh}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[50vh\]{max-height:50vh}.max-h-\[80vh\]{max-height:80vh}.w-1{width:calc(var(--spacing) * 1)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-12{width:calc(var(--spacing) * 12)}.w-40{width:calc(var(--spacing) * 40)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[280px\]{width:280px}.w-\[300px\]{width:300px}.w-\[340px\]{width:340px}.w-\[400px\]{width:400px}.w-\[500px\]{width:500px}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.min-w-\[160px\]{min-width:160px}.flex-1{flex:1}.shrink-0{flex-shrink:0}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-crosshair{cursor:crosshair}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.self-end{align-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-az-border{border-color:var(--color-az-border)}.border-az-orange{border-color:var(--color-az-orange)}.border-white{border-color:var(--color-white)}.border-t-transparent{border-top-color:#0000}.bg-\[\#1e1e1e\]{background-color:#1e1e1e}.bg-az-bg{background-color:var(--color-az-bg)}.bg-az-bg\/50{background-color:#1e1e1e80}@supports (color:color-mix(in lab,red,red)){.bg-az-bg\/50{background-color:color-mix(in oklab,var(--color-az-bg) 50%,transparent)}}.bg-az-blue{background-color:var(--color-az-blue)}.bg-az-blue\/20{background-color:#228be633}@supports (color:color-mix(in lab,red,red)){.bg-az-blue\/20{background-color:color-mix(in oklab,var(--color-az-blue) 20%,transparent)}}.bg-az-border{background-color:var(--color-az-border)}.bg-az-green{background-color:var(--color-az-green)}.bg-az-green\/20{background-color:#40c05733}@supports (color:color-mix(in lab,red,red)){.bg-az-green\/20{background-color:color-mix(in oklab,var(--color-az-green) 20%,transparent)}}.bg-az-header{background-color:var(--color-az-header)}.bg-az-muted\/20{background-color:#6c757d33}@supports (color:color-mix(in lab,red,red)){.bg-az-muted\/20{background-color:color-mix(in oklab,var(--color-az-muted) 20%,transparent)}}.bg-az-orange{background-color:var(--color-az-orange)}.bg-az-panel{background-color:var(--color-az-panel)}.bg-az-red{background-color:var(--color-az-red)}.bg-black{background-color:var(--color-black)}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black) 60%,transparent)}}.bg-transparent{background-color:#0000}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-\[\#adb5bd\]{color:#adb5bd}.text-az-blue{color:var(--color-az-blue)}.text-az-green{color:var(--color-az-green)}.text-az-muted{color:var(--color-az-muted)}.text-az-orange{color:var(--color-az-orange)}.text-az-red{color:var(--color-az-red)}.text-az-text{color:var(--color-az-text)}.text-white{color:var(--color-white)}.accent-az-orange{accent-color:var(--color-az-orange)}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-az-orange{--tw-ring-color:var(--color-az-orange)}.ring-az-red{--tw-ring-color:var(--color-az-red)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.ring-inset{--tw-ring-inset:inset}@media(hover:hover){.hover\:border-az-muted:hover{border-color:var(--color-az-muted)}.hover\:bg-az-bg:hover{background-color:var(--color-az-bg)}.hover\:bg-az-muted:hover{background-color:var(--color-az-muted)}.hover\:bg-az-orange:hover{background-color:var(--color-az-orange)}.hover\:bg-orange-600:hover{background-color:var(--color-orange-600)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:text-az-orange:hover{color:var(--color-az-orange)}.hover\:text-az-red:hover{color:var(--color-az-red)}.hover\:text-white:hover{color:var(--color-white)}}.focus\:border-az-orange:focus{border-color:var(--color-az-orange)}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}}}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:var(--color-az-bg)}::-webkit-scrollbar-thumb{background:var(--color-az-border);border-radius:3px}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@keyframes spin{to{transform:rotate(360deg)}} diff --git a/dist/assets/index-o2ENlayJ.js b/dist/assets/index-o2ENlayJ.js new file mode 100644 index 0000000..167f20e --- /dev/null +++ b/dist/assets/index-o2ENlayJ.js @@ -0,0 +1,63 @@ +(function(){const u=document.createElement("link").relList;if(u&&u.supports&&u.supports("modulepreload"))return;for(const m of document.querySelectorAll('link[rel="modulepreload"]'))c(m);new MutationObserver(m=>{for(const p of m)if(p.type==="childList")for(const _ of p.addedNodes)_.tagName==="LINK"&&_.rel==="modulepreload"&&c(_)}).observe(document,{childList:!0,subtree:!0});function r(m){const p={};return m.integrity&&(p.integrity=m.integrity),m.referrerPolicy&&(p.referrerPolicy=m.referrerPolicy),m.crossOrigin==="use-credentials"?p.credentials="include":m.crossOrigin==="anonymous"?p.credentials="omit":p.credentials="same-origin",p}function c(m){if(m.ep)return;m.ep=!0;const p=r(m);fetch(m.href,p)}})();function zp(f){return f&&f.__esModule&&Object.prototype.hasOwnProperty.call(f,"default")?f.default:f}var bf={exports:{}},go={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Gm;function wv(){if(Gm)return go;Gm=1;var f=Symbol.for("react.transitional.element"),u=Symbol.for("react.fragment");function r(c,m,p){var _=null;if(p!==void 0&&(_=""+p),m.key!==void 0&&(_=""+m.key),"key"in m){p={};for(var z in m)z!=="key"&&(p[z]=m[z])}else p=m;return m=p.ref,{$$typeof:f,type:c,key:_,ref:m!==void 0?m:null,props:p}}return go.Fragment=u,go.jsx=r,go.jsxs=r,go}var Vm;function zv(){return Vm||(Vm=1,bf.exports=wv()),bf.exports}var b=zv(),Sf={exports:{}},Lt={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Xm;function Tv(){if(Xm)return Lt;Xm=1;var f=Symbol.for("react.transitional.element"),u=Symbol.for("react.portal"),r=Symbol.for("react.fragment"),c=Symbol.for("react.strict_mode"),m=Symbol.for("react.profiler"),p=Symbol.for("react.consumer"),_=Symbol.for("react.context"),z=Symbol.for("react.forward_ref"),y=Symbol.for("react.suspense"),x=Symbol.for("react.memo"),T=Symbol.for("react.lazy"),E=Symbol.for("react.activity"),O=Symbol.iterator;function H(N){return N===null||typeof N!="object"?null:(N=O&&N[O]||N["@@iterator"],typeof N=="function"?N:null)}var A={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},B=Object.assign,V={};function et(N,X,$){this.props=N,this.context=X,this.refs=V,this.updater=$||A}et.prototype.isReactComponent={},et.prototype.setState=function(N,X){if(typeof N!="object"&&typeof N!="function"&&N!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,N,X,"setState")},et.prototype.forceUpdate=function(N){this.updater.enqueueForceUpdate(this,N,"forceUpdate")};function U(){}U.prototype=et.prototype;function k(N,X,$){this.props=N,this.context=X,this.refs=V,this.updater=$||A}var F=k.prototype=new U;F.constructor=k,B(F,et.prototype),F.isPureReactComponent=!0;var mt=Array.isArray;function ht(){}var ft={H:null,A:null,T:null,S:null},pt=Object.prototype.hasOwnProperty;function zt(N,X,$){var lt=$.ref;return{$$typeof:f,type:N,key:X,ref:lt!==void 0?lt:null,props:$}}function Dt(N,X){return zt(N.type,X,N.props)}function vt(N){return typeof N=="object"&&N!==null&&N.$$typeof===f}function wt(N){var X={"=":"=0",":":"=2"};return"$"+N.replace(/[=:]/g,function($){return X[$]})}var ce=/\/+/g;function Rt(N,X){return typeof N=="object"&&N!==null&&N.key!=null?wt(""+N.key):X.toString(36)}function Zt(N){switch(N.status){case"fulfilled":return N.value;case"rejected":throw N.reason;default:switch(typeof N.status=="string"?N.then(ht,ht):(N.status="pending",N.then(function(X){N.status==="pending"&&(N.status="fulfilled",N.value=X)},function(X){N.status==="pending"&&(N.status="rejected",N.reason=X)})),N.status){case"fulfilled":return N.value;case"rejected":throw N.reason}}throw N}function D(N,X,$,lt,ot){var rt=typeof N;(rt==="undefined"||rt==="boolean")&&(N=null);var at=!1;if(N===null)at=!0;else switch(rt){case"bigint":case"string":case"number":at=!0;break;case"object":switch(N.$$typeof){case f:case u:at=!0;break;case T:return at=N._init,D(at(N._payload),X,$,lt,ot)}}if(at)return ot=ot(N),at=lt===""?"."+Rt(N,0):lt,mt(ot)?($="",at!=null&&($=at.replace(ce,"$&/")+"/"),D(ot,X,$,"",function(we){return we})):ot!=null&&(vt(ot)&&(ot=Dt(ot,$+(ot.key==null||N&&N.key===ot.key?"":(""+ot.key).replace(ce,"$&/")+"/")+at)),X.push(ot)),1;at=0;var Ot=lt===""?".":lt+":";if(mt(N))for(var Yt=0;Yt>>1,st=D[ut];if(0>>1;utm($,I))ltm(ot,$)?(D[ut]=ot,D[lt]=I,ut=lt):(D[ut]=$,D[X]=I,ut=X);else if(ltm(ot,I))D[ut]=ot,D[lt]=I,ut=lt;else break t}}return q}function m(D,q){var I=D.sortIndex-q.sortIndex;return I!==0?I:D.id-q.id}if(f.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var p=performance;f.unstable_now=function(){return p.now()}}else{var _=Date,z=_.now();f.unstable_now=function(){return _.now()-z}}var y=[],x=[],T=1,E=null,O=3,H=!1,A=!1,B=!1,V=!1,et=typeof setTimeout=="function"?setTimeout:null,U=typeof clearTimeout=="function"?clearTimeout:null,k=typeof setImmediate<"u"?setImmediate:null;function F(D){for(var q=r(x);q!==null;){if(q.callback===null)c(x);else if(q.startTime<=D)c(x),q.sortIndex=q.expirationTime,u(y,q);else break;q=r(x)}}function mt(D){if(B=!1,F(D),!A)if(r(y)!==null)A=!0,ht||(ht=!0,wt());else{var q=r(x);q!==null&&Zt(mt,q.startTime-D)}}var ht=!1,ft=-1,pt=5,zt=-1;function Dt(){return V?!0:!(f.unstable_now()-ztD&&Dt());){var ut=E.callback;if(typeof ut=="function"){E.callback=null,O=E.priorityLevel;var st=ut(E.expirationTime<=D);if(D=f.unstable_now(),typeof st=="function"){E.callback=st,F(D),q=!0;break e}E===r(y)&&c(y),F(D)}else c(y);E=r(y)}if(E!==null)q=!0;else{var N=r(x);N!==null&&Zt(mt,N.startTime-D),q=!1}}break t}finally{E=null,O=I,H=!1}q=void 0}}finally{q?wt():ht=!1}}}var wt;if(typeof k=="function")wt=function(){k(vt)};else if(typeof MessageChannel<"u"){var ce=new MessageChannel,Rt=ce.port2;ce.port1.onmessage=vt,wt=function(){Rt.postMessage(null)}}else wt=function(){et(vt,0)};function Zt(D,q){ft=et(function(){D(f.unstable_now())},q)}f.unstable_IdlePriority=5,f.unstable_ImmediatePriority=1,f.unstable_LowPriority=4,f.unstable_NormalPriority=3,f.unstable_Profiling=null,f.unstable_UserBlockingPriority=2,f.unstable_cancelCallback=function(D){D.callback=null},f.unstable_forceFrameRate=function(D){0>D||125ut?(D.sortIndex=I,u(x,D),r(y)===null&&D===r(x)&&(B?(U(ft),ft=-1):B=!0,Zt(mt,I-ut))):(D.sortIndex=st,u(y,D),A||H||(A=!0,ht||(ht=!0,wt()))),D},f.unstable_shouldYield=Dt,f.unstable_wrapCallback=function(D){var q=O;return function(){var I=O;O=q;try{return D.apply(this,arguments)}finally{O=I}}}})(Tf)),Tf}var Jm;function Lv(){return Jm||(Jm=1,zf.exports=Ev()),zf.exports}var Ef={exports:{}},He={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Fm;function Cv(){if(Fm)return He;Fm=1;var f=Hf();function u(y){var x="https://react.dev/errors/"+y;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(f)}catch(u){console.error(u)}}return f(),Ef.exports=Cv(),Ef.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Wm;function Nv(){if(Wm)return vo;Wm=1;var f=Lv(),u=Hf(),r=Tp();function c(t){var e="https://react.dev/errors/"+t;if(1st||(t.current=ut[st],ut[st]=null,st--)}function $(t,e){st++,ut[st]=t.current,t.current=e}var lt=N(null),ot=N(null),rt=N(null),at=N(null);function Ot(t,e){switch($(rt,e),$(ot,t),$(lt,null),e.nodeType){case 9:case 11:t=(t=e.documentElement)&&(t=t.namespaceURI)?dm(t):0;break;default:if(t=e.tagName,e=e.namespaceURI)e=dm(e),t=mm(e,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}X(lt),$(lt,t)}function Yt(){X(lt),X(ot),X(rt)}function we(t){t.memoizedState!==null&&$(at,t);var e=lt.current,i=mm(e,t.type);e!==i&&($(ot,t),$(lt,i))}function an(t){ot.current===t&&(X(lt),X(ot)),at.current===t&&(X(at),fo._currentValue=I)}var si,us;function li(t){if(si===void 0)try{throw Error()}catch(i){var e=i.stack.trim().match(/\n( *(at )?)/);si=e&&e[1]||"",us=-1)":-1o||R[s]!==G[o]){var W=` +`+R[s].replace(" at new "," at ");return t.displayName&&W.includes("")&&(W=W.replace("",t.displayName)),W}while(1<=s&&0<=o);break}}}finally{cs=!1,Error.prepareStackTrace=i}return(i=t?t.displayName||t.name:"")?li(i):""}function ul(t,e){switch(t.tag){case 26:case 27:case 5:return li(t.type);case 16:return li("Lazy");case 13:return t.child!==e&&e!==null?li("Suspense Fallback"):li("Suspense");case 19:return li("SuspenseList");case 0:case 15:return fs(t.type,!1);case 11:return fs(t.type.render,!1);case 1:return fs(t.type,!0);case 31:return li("Activity");default:return""}}function ya(t){try{var e="",i=null;do e+=ul(t,i),i=t,t=t.return;while(t);return e}catch(s){return` +Error generating stack: `+s.message+` +`+s.stack}}var cl=Object.prototype.hasOwnProperty,hs=f.unstable_scheduleCallback,xa=f.unstable_cancelCallback,Co=f.unstable_shouldYield,No=f.unstable_requestPaint,Pe=f.unstable_now,Su=f.unstable_getCurrentPriorityLevel,ds=f.unstable_ImmediatePriority,fl=f.unstable_UserBlockingPriority,ba=f.unstable_NormalPriority,wu=f.unstable_LowPriority,hl=f.unstable_IdlePriority,Oo=f.log,zu=f.unstable_setDisableYieldValue,ji=null,Me=null;function Ln(t){if(typeof Oo=="function"&&zu(t),Me&&typeof Me.setStrictMode=="function")try{Me.setStrictMode(ji,t)}catch{}}var ke=Math.clz32?Math.clz32:Eu,Sa=Math.log,Tu=Math.LN2;function Eu(t){return t>>>=0,t===0?32:31-(Sa(t)/Tu|0)|0}var wa=256,za=262144,Ta=4194304;function oi(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function ms(t,e,i){var s=t.pendingLanes;if(s===0)return 0;var o=0,h=t.suspendedLanes,v=t.pingedLanes;t=t.warmLanes;var w=s&134217727;return w!==0?(s=w&~h,s!==0?o=oi(s):(v&=w,v!==0?o=oi(v):i||(i=w&~t,i!==0&&(o=oi(i))))):(w=s&~h,w!==0?o=oi(w):v!==0?o=oi(v):i||(i=s&~t,i!==0&&(o=oi(i)))),o===0?0:e!==0&&e!==o&&(e&h)===0&&(h=o&-o,i=e&-e,h>=i||h===32&&(i&4194048)!==0)?e:o}function Ea(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function Lu(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Mo(){var t=Ta;return Ta<<=1,(Ta&62914560)===0&&(Ta=4194304),t}function dl(t){for(var e=[],i=0;31>i;i++)e.push(t);return e}function ri(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Cu(t,e,i,s,o,h){var v=t.pendingLanes;t.pendingLanes=i,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=i,t.entangledLanes&=i,t.errorRecoveryDisabledLanes&=i,t.shellSuspendCounter=0;var w=t.entanglements,R=t.expirationTimes,G=t.hiddenUpdates;for(i=v&~i;0"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var ps=/[\n"\\]/g;function ae(t){return t.replace(ps,function(e){return"\\"+e.charCodeAt(0).toString(16)+" "})}function xl(t,e,i,s,o,h,v,w){t.name="",v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"?t.type=v:t.removeAttribute("type"),e!=null?v==="number"?(e===0&&t.value===""||t.value!=e)&&(t.value=""+ze(e)):t.value!==""+ze(e)&&(t.value=""+ze(e)):v!=="submit"&&v!=="reset"||t.removeAttribute("value"),e!=null?Nn(t,v,ze(e)):i!=null?Nn(t,v,ze(i)):s!=null&&t.removeAttribute("value"),o==null&&h!=null&&(t.defaultChecked=!!h),o!=null&&(t.checked=o&&typeof o!="function"&&typeof o!="symbol"),w!=null&&typeof w!="function"&&typeof w!="symbol"&&typeof w!="boolean"?t.name=""+ze(w):t.removeAttribute("name")}function Oa(t,e,i,s,o,h,v,w){if(h!=null&&typeof h!="function"&&typeof h!="symbol"&&typeof h!="boolean"&&(t.type=h),e!=null||i!=null){if(!(h!=="submit"&&h!=="reset"||e!=null)){Et(t);return}i=i!=null?""+ze(i):"",e=e!=null?""+ze(e):i,w||e===t.value||(t.value=e),t.defaultValue=e}s=s??o,s=typeof s!="function"&&typeof s!="symbol"&&!!s,t.checked=w?t.checked:!!s,t.defaultChecked=!!s,v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"&&(t.name=v),Et(t)}function Nn(t,e,i){e==="number"&&Hi(t.ownerDocument)===t||t.defaultValue===""+i||(t.defaultValue=""+i)}function ne(t,e,i,s){if(t=t.options,e){e={};for(var o=0;o"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Ra=!1;if(ln)try{var on={};Object.defineProperty(on,"passive",{get:function(){Ra=!0}}),window.addEventListener("test",on,on),window.removeEventListener("test",on,on)}catch{Ra=!1}var gn=null,mi=null,fe=null;function Yn(){if(fe)return fe;var t,e=mi,i=e.length,s,o="value"in gn?gn.value:gn.textContent,h=o.length;for(t=0;t=Gi),ws=" ",Jo=!1;function Fo(t,e){switch(t){case"keyup":return Qe.indexOf(e.keyCode)!==-1;case"keydown":return e.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Al(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var Vi=!1;function rn(t,e){switch(t){case"compositionend":return Al(e);case"keypress":return e.which!==32?null:(Jo=!0,ws);case"textInput":return t=e.data,t===ws&&Jo?null:t;default:return null}}function Xi(t,e){if(Vi)return t==="compositionend"||!bs&&Fo(t,e)?(t=Yn(),fe=mi=gn=null,Vi=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(e.ctrlKey||e.altKey||e.metaKey)||e.ctrlKey&&e.altKey){if(e.char&&1=e)return{node:i,offset:e-t};t=s}t:{for(;i;){if(i.nextSibling){i=i.nextSibling;break t}i=i.parentNode}i=void 0}i=Ts(i)}}function Es(t,e){return t&&e?t===e?!0:t&&t.nodeType===3?!1:e&&e.nodeType===3?Es(t,e.parentNode):"contains"in t?t.contains(e):t.compareDocumentPosition?!!(t.compareDocumentPosition(e)&16):!1:!1}function Ya(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var e=Hi(t.document);e instanceof t.HTMLIFrameElement;){try{var i=typeof e.contentWindow.location.href=="string"}catch{i=!1}if(i)t=e.contentWindow;else break;e=Hi(t.document)}return e}function Vn(t){var e=t&&t.nodeName&&t.nodeName.toLowerCase();return e&&(e==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||e==="textarea"||t.contentEditable==="true")}var Ls=ln&&"documentMode"in document&&11>=document.documentMode,Xn=null,Cs=null,Ga=null,Fi=!1;function er(t,e,i){var s=i.window===i?i.document:i.nodeType===9?i:i.ownerDocument;Fi||Xn==null||Xn!==Hi(s)||(s=Xn,"selectionStart"in s&&Vn(s)?s={start:s.selectionStart,end:s.selectionEnd}:(s=(s.ownerDocument&&s.ownerDocument.defaultView||window).getSelection(),s={anchorNode:s.anchorNode,anchorOffset:s.anchorOffset,focusNode:s.focusNode,focusOffset:s.focusOffset}),Ga&&Ji(Ga,s)||(Ga=s,s=kr(Cs,"onSelect"),0>=v,o-=v,nt=1<<32-ke(e)+o|i<At?(qt=gt,gt=null):qt=gt.sibling;var Kt=Q(Z,gt,Y[At],tt);if(Kt===null){gt===null&&(gt=qt);break}t&>&&Kt.alternate===null&&e(Z,gt),j=h(Kt,j,At),Xt===null?xt=Kt:Xt.sibling=Kt,Xt=Kt,gt=qt}if(At===Y.length)return i(Z,gt),Bt&&yt(Z,At),xt;if(gt===null){for(;AtAt?(qt=gt,gt=null):qt=gt.sibling;var _a=Q(Z,gt,Kt.value,tt);if(_a===null){gt===null&&(gt=qt);break}t&>&&_a.alternate===null&&e(Z,gt),j=h(_a,j,At),Xt===null?xt=_a:Xt.sibling=_a,Xt=_a,gt=qt}if(Kt.done)return i(Z,gt),Bt&&yt(Z,At),xt;if(gt===null){for(;!Kt.done;At++,Kt=Y.next())Kt=it(Z,Kt.value,tt),Kt!==null&&(j=h(Kt,j,At),Xt===null?xt=Kt:Xt.sibling=Kt,Xt=Kt);return Bt&&yt(Z,At),xt}for(gt=s(gt);!Kt.done;At++,Kt=Y.next())Kt=J(gt,Z,At,Kt.value,tt),Kt!==null&&(t&&Kt.alternate!==null&>.delete(Kt.key===null?At:Kt.key),j=h(Kt,j,At),Xt===null?xt=Kt:Xt.sibling=Kt,Xt=Kt);return t&>.forEach(function(Sv){return e(Z,Sv)}),Bt&&yt(Z,At),xt}function ee(Z,j,Y,tt){if(typeof Y=="object"&&Y!==null&&Y.type===B&&Y.key===null&&(Y=Y.props.children),typeof Y=="object"&&Y!==null){switch(Y.$$typeof){case H:t:{for(var xt=Y.key;j!==null;){if(j.key===xt){if(xt=Y.type,xt===B){if(j.tag===7){i(Z,j.sibling),tt=o(j,Y.props.children),tt.return=Z,Z=tt;break t}}else if(j.elementType===xt||typeof xt=="object"&&xt!==null&&xt.$$typeof===pt&&es(xt)===j.type){i(Z,j.sibling),tt=o(j,Y.props),ql(tt,Y),tt.return=Z,Z=tt;break t}i(Z,j);break}else e(Z,j);j=j.sibling}Y.type===B?(tt=vi(Y.props.children,Z.mode,tt,Y.key),tt.return=Z,Z=tt):(tt=Fa(Y.type,Y.key,Y.props,null,Z.mode,tt),ql(tt,Y),tt.return=Z,Z=tt)}return v(Z);case A:t:{for(xt=Y.key;j!==null;){if(j.key===xt)if(j.tag===4&&j.stateNode.containerInfo===Y.containerInfo&&j.stateNode.implementation===Y.implementation){i(Z,j.sibling),tt=o(j,Y.children||[]),tt.return=Z,Z=tt;break t}else{i(Z,j);break}else e(Z,j);j=j.sibling}tt=Bl(Y,Z.mode,tt),tt.return=Z,Z=tt}return v(Z);case pt:return Y=es(Y),ee(Z,j,Y,tt)}if(Zt(Y))return dt(Z,j,Y,tt);if(wt(Y)){if(xt=wt(Y),typeof xt!="function")throw Error(c(150));return Y=xt.call(Y),bt(Z,j,Y,tt)}if(typeof Y.then=="function")return ee(Z,j,fr(Y),tt);if(Y.$$typeof===k)return ee(Z,j,or(Z,Y),tt);hr(Z,Y)}return typeof Y=="string"&&Y!==""||typeof Y=="number"||typeof Y=="bigint"?(Y=""+Y,j!==null&&j.tag===6?(i(Z,j.sibling),tt=o(j,Y),tt.return=Z,Z=tt):(i(Z,j),tt=As(Y,Z.mode,tt),tt.return=Z,Z=tt),v(Z)):i(Z,j)}return function(Z,j,Y,tt){try{kl=0;var xt=ee(Z,j,Y,tt);return Hs=null,xt}catch(gt){if(gt===Us||gt===ur)throw gt;var Xt=Ue(29,gt,null,Z.mode);return Xt.lanes=tt,Xt.return=Z,Xt}finally{}}}var is=ah(!0),sh=ah(!1),ea=!1;function Fu(t){t.updateQueue={baseState:t.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Iu(t,e){t=t.updateQueue,e.updateQueue===t&&(e.updateQueue={baseState:t.baseState,firstBaseUpdate:t.firstBaseUpdate,lastBaseUpdate:t.lastBaseUpdate,shared:t.shared,callbacks:null})}function na(t){return{lane:t,tag:0,payload:null,callback:null,next:null}}function ia(t,e,i){var s=t.updateQueue;if(s===null)return null;if(s=s.shared,(Qt&2)!==0){var o=s.pending;return o===null?e.next=e:(e.next=o.next,o.next=e),s.pending=e,e=Ja(t),$i(t,null,i),e}return Jn(t,s,e,i),Ja(t)}function Yl(t,e,i){if(e=e.updateQueue,e!==null&&(e=e.shared,(i&4194048)!==0)){var s=e.lanes;s&=t.pendingLanes,i|=s,e.lanes=i,Ro(t,i)}}function Wu(t,e){var i=t.updateQueue,s=t.alternate;if(s!==null&&(s=s.updateQueue,i===s)){var o=null,h=null;if(i=i.firstBaseUpdate,i!==null){do{var v={lane:i.lane,tag:i.tag,payload:i.payload,callback:null,next:null};h===null?o=h=v:h=h.next=v,i=i.next}while(i!==null);h===null?o=h=e:h=h.next=e}else o=h=e;i={baseState:s.baseState,firstBaseUpdate:o,lastBaseUpdate:h,shared:s.shared,callbacks:s.callbacks},t.updateQueue=i;return}t=i.lastBaseUpdate,t===null?i.firstBaseUpdate=e:t.next=e,i.lastBaseUpdate=e}var $u=!1;function Gl(){if($u){var t=Bs;if(t!==null)throw t}}function Vl(t,e,i,s){$u=!1;var o=t.updateQueue;ea=!1;var h=o.firstBaseUpdate,v=o.lastBaseUpdate,w=o.shared.pending;if(w!==null){o.shared.pending=null;var R=w,G=R.next;R.next=null,v===null?h=G:v.next=G,v=R;var W=t.alternate;W!==null&&(W=W.updateQueue,w=W.lastBaseUpdate,w!==v&&(w===null?W.firstBaseUpdate=G:w.next=G,W.lastBaseUpdate=R))}if(h!==null){var it=o.baseState;v=0,W=G=R=null,w=h;do{var Q=w.lane&-536870913,J=Q!==w.lane;if(J?(kt&Q)===Q:(s&Q)===Q){Q!==0&&Q===js&&($u=!0),W!==null&&(W=W.next={lane:0,tag:w.tag,payload:w.payload,callback:null,next:null});t:{var dt=t,bt=w;Q=e;var ee=i;switch(bt.tag){case 1:if(dt=bt.payload,typeof dt=="function"){it=dt.call(ee,it,Q);break t}it=dt;break t;case 3:dt.flags=dt.flags&-65537|128;case 0:if(dt=bt.payload,Q=typeof dt=="function"?dt.call(ee,it,Q):dt,Q==null)break t;it=E({},it,Q);break t;case 2:ea=!0}}Q=w.callback,Q!==null&&(t.flags|=64,J&&(t.flags|=8192),J=o.callbacks,J===null?o.callbacks=[Q]:J.push(Q))}else J={lane:Q,tag:w.tag,payload:w.payload,callback:w.callback,next:null},W===null?(G=W=J,R=it):W=W.next=J,v|=Q;if(w=w.next,w===null){if(w=o.shared.pending,w===null)break;J=w,w=J.next,J.next=null,o.lastBaseUpdate=J,o.shared.pending=null}}while(!0);W===null&&(R=it),o.baseState=R,o.firstBaseUpdate=G,o.lastBaseUpdate=W,h===null&&(o.shared.lanes=0),ra|=v,t.lanes=v,t.memoizedState=it}}function lh(t,e){if(typeof t!="function")throw Error(c(191,t));t.call(e)}function oh(t,e){var i=t.callbacks;if(i!==null)for(t.callbacks=null,t=0;th?h:8;var v=D.T,w={};D.T=w,_c(t,!1,e,i);try{var R=o(),G=D.S;if(G!==null&&G(w,R),R!==null&&typeof R=="object"&&typeof R.then=="function"){var W=fg(R,s);Ql(t,e,W,dn(t))}else Ql(t,e,s,dn(t))}catch(it){Ql(t,e,{then:function(){},status:"rejected",reason:it},dn())}finally{q.p=h,v!==null&&w.types!==null&&(v.types=w.types),D.T=v}}function vg(){}function gc(t,e,i,s){if(t.tag!==5)throw Error(c(476));var o=Zh(t).queue;Hh(t,o,e,I,i===null?vg:function(){return Ph(t),i(s)})}function Zh(t){var e=t.memoizedState;if(e!==null)return e;e={memoizedState:I,baseState:I,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:bi,lastRenderedState:I},next:null};var i={};return e.next={memoizedState:i,baseState:i,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:bi,lastRenderedState:i},next:null},t.memoizedState=e,t=t.alternate,t!==null&&(t.memoizedState=e),e}function Ph(t){var e=Zh(t);e.next===null&&(e=t.alternate.memoizedState),Ql(t,e.next.queue,{},dn())}function vc(){return Re(fo)}function kh(){return ge().memoizedState}function qh(){return ge().memoizedState}function _g(t){for(var e=t.return;e!==null;){switch(e.tag){case 24:case 3:var i=dn();t=na(i);var s=ia(e,t,i);s!==null&&(nn(s,e,i),Yl(s,e,i)),e={cache:Xu()},t.payload=e;return}e=e.return}}function yg(t,e,i){var s=dn();i={lane:s,revertLane:0,gesture:null,action:i,hasEagerState:!1,eagerState:null,next:null},Sr(t)?Gh(e,i):(i=Rl(t,e,i,s),i!==null&&(nn(i,t,s),Vh(i,e,s)))}function Yh(t,e,i){var s=dn();Ql(t,e,i,s)}function Ql(t,e,i,s){var o={lane:s,revertLane:0,gesture:null,action:i,hasEagerState:!1,eagerState:null,next:null};if(Sr(t))Gh(e,o);else{var h=t.alternate;if(t.lanes===0&&(h===null||h.lanes===0)&&(h=e.lastRenderedReducer,h!==null))try{var v=e.lastRenderedState,w=h(v,i);if(o.hasEagerState=!0,o.eagerState=w,Ce(w,v))return Jn(t,e,o,0),ie===null&&Qa(),!1}catch{}finally{}if(i=Rl(t,e,o,s),i!==null)return nn(i,t,s),Vh(i,e,s),!0}return!1}function _c(t,e,i,s){if(s={lane:2,revertLane:Fc(),gesture:null,action:s,hasEagerState:!1,eagerState:null,next:null},Sr(t)){if(e)throw Error(c(479))}else e=Rl(t,i,s,2),e!==null&&nn(e,t,2)}function Sr(t){var e=t.alternate;return t===Ct||e!==null&&e===Ct}function Gh(t,e){Ps=pr=!0;var i=t.pending;i===null?e.next=e:(e.next=i.next,i.next=e),t.pending=e}function Vh(t,e,i){if((i&4194048)!==0){var s=e.lanes;s&=t.pendingLanes,i|=s,e.lanes=i,Ro(t,i)}}var Jl={readContext:Re,use:_r,useCallback:de,useContext:de,useEffect:de,useImperativeHandle:de,useLayoutEffect:de,useInsertionEffect:de,useMemo:de,useReducer:de,useRef:de,useState:de,useDebugValue:de,useDeferredValue:de,useTransition:de,useSyncExternalStore:de,useId:de,useHostTransitionStatus:de,useFormState:de,useActionState:de,useOptimistic:de,useMemoCache:de,useCacheRefresh:de};Jl.useEffectEvent=de;var Xh={readContext:Re,use:_r,useCallback:function(t,e){return Ge().memoizedState=[t,e===void 0?null:e],t},useContext:Re,useEffect:Nh,useImperativeHandle:function(t,e,i){i=i!=null?i.concat([t]):null,xr(4194308,4,Rh.bind(null,e,t),i)},useLayoutEffect:function(t,e){return xr(4194308,4,t,e)},useInsertionEffect:function(t,e){xr(4,2,t,e)},useMemo:function(t,e){var i=Ge();e=e===void 0?null:e;var s=t();if(as){Ln(!0);try{t()}finally{Ln(!1)}}return i.memoizedState=[s,e],s},useReducer:function(t,e,i){var s=Ge();if(i!==void 0){var o=i(e);if(as){Ln(!0);try{i(e)}finally{Ln(!1)}}}else o=e;return s.memoizedState=s.baseState=o,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:o},s.queue=t,t=t.dispatch=yg.bind(null,Ct,t),[s.memoizedState,t]},useRef:function(t){var e=Ge();return t={current:t},e.memoizedState=t},useState:function(t){t=fc(t);var e=t.queue,i=Yh.bind(null,Ct,e);return e.dispatch=i,[t.memoizedState,i]},useDebugValue:mc,useDeferredValue:function(t,e){var i=Ge();return pc(i,t,e)},useTransition:function(){var t=fc(!1);return t=Hh.bind(null,Ct,t.queue,!0,!1),Ge().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,e,i){var s=Ct,o=Ge();if(Bt){if(i===void 0)throw Error(c(407));i=i()}else{if(i=e(),ie===null)throw Error(c(349));(kt&127)!==0||dh(s,e,i)}o.memoizedState=i;var h={value:i,getSnapshot:e};return o.queue=h,Nh(ph.bind(null,s,h,t),[t]),s.flags|=2048,qs(9,{destroy:void 0},mh.bind(null,s,h,i,e),null),i},useId:function(){var t=Ge(),e=ie.identifierPrefix;if(Bt){var i=ct,s=nt;i=(s&~(1<<32-ke(s)-1)).toString(32)+i,e="_"+e+"R_"+i,i=gr++,0<\/script>",h=h.removeChild(h.firstChild);break;case"select":h=typeof s.is=="string"?v.createElement("select",{is:s.is}):v.createElement("select"),s.multiple?h.multiple=!0:s.size&&(h.size=s.size);break;default:h=typeof s.is=="string"?v.createElement(o,{is:s.is}):v.createElement(o)}}h[ve]=e,h[Le]=s;t:for(v=e.child;v!==null;){if(v.tag===5||v.tag===6)h.appendChild(v.stateNode);else if(v.tag!==4&&v.tag!==27&&v.child!==null){v.child.return=v,v=v.child;continue}if(v===e)break t;for(;v.sibling===null;){if(v.return===null||v.return===e)break t;v=v.return}v.sibling.return=v.return,v=v.sibling}e.stateNode=h;t:switch(je(h,o,s),o){case"button":case"input":case"select":case"textarea":s=!!s.autoFocus;break t;case"img":s=!0;break t;default:s=!1}s&&wi(e)}}return se(e),Ac(e,e.type,t===null?null:t.memoizedProps,e.pendingProps,i),null;case 6:if(t&&e.stateNode!=null)t.memoizedProps!==s&&wi(e);else{if(typeof s!="string"&&e.stateNode===null)throw Error(c(166));if(t=rt.current,Dn(e)){if(t=e.stateNode,i=e.memoizedProps,s=null,o=Gt,o!==null)switch(o.tag){case 27:case 5:s=o.memoizedProps}t[ve]=e,t=!!(t.nodeValue===i||s!==null&&s.suppressHydrationWarning===!0||fm(t.nodeValue,i)),t||In(e,!0)}else t=qr(t).createTextNode(s),t[ve]=e,e.stateNode=t}return se(e),null;case 31:if(i=e.memoizedState,t===null||t.memoizedState!==null){if(s=Dn(e),i!==null){if(t===null){if(!s)throw Error(c(318));if(t=e.memoizedState,t=t!==null?t.dehydrated:null,!t)throw Error(c(557));t[ve]=e}else _i(),(e.flags&128)===0&&(e.memoizedState=null),e.flags|=4;se(e),t=!1}else i=Hl(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=i),t=!0;if(!t)return e.flags&256?(cn(e),e):(cn(e),null);if((e.flags&128)!==0)throw Error(c(558))}return se(e),null;case 13:if(s=e.memoizedState,t===null||t.memoizedState!==null&&t.memoizedState.dehydrated!==null){if(o=Dn(e),s!==null&&s.dehydrated!==null){if(t===null){if(!o)throw Error(c(318));if(o=e.memoizedState,o=o!==null?o.dehydrated:null,!o)throw Error(c(317));o[ve]=e}else _i(),(e.flags&128)===0&&(e.memoizedState=null),e.flags|=4;se(e),o=!1}else o=Hl(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=o),o=!0;if(!o)return e.flags&256?(cn(e),e):(cn(e),null)}return cn(e),(e.flags&128)!==0?(e.lanes=i,e):(i=s!==null,t=t!==null&&t.memoizedState!==null,i&&(s=e.child,o=null,s.alternate!==null&&s.alternate.memoizedState!==null&&s.alternate.memoizedState.cachePool!==null&&(o=s.alternate.memoizedState.cachePool.pool),h=null,s.memoizedState!==null&&s.memoizedState.cachePool!==null&&(h=s.memoizedState.cachePool.pool),h!==o&&(s.flags|=2048)),i!==t&&i&&(e.child.flags|=8192),Lr(e,e.updateQueue),se(e),null);case 4:return Yt(),t===null&&tf(e.stateNode.containerInfo),se(e),null;case 10:return yi(e.type),se(e),null;case 19:if(X(pe),s=e.memoizedState,s===null)return se(e),null;if(o=(e.flags&128)!==0,h=s.rendering,h===null)if(o)Il(s,!1);else{if(me!==0||t!==null&&(t.flags&128)!==0)for(t=e.child;t!==null;){if(h=mr(t),h!==null){for(e.flags|=128,Il(s,!1),t=h.updateQueue,e.updateQueue=t,Lr(e,t),e.subtreeFlags=0,t=i,i=e.child;i!==null;)Dl(i,t),i=i.sibling;return $(pe,pe.current&1|2),Bt&&yt(e,s.treeForkCount),e.child}t=t.sibling}s.tail!==null&&Pe()>Ar&&(e.flags|=128,o=!0,Il(s,!1),e.lanes=4194304)}else{if(!o)if(t=mr(h),t!==null){if(e.flags|=128,o=!0,t=t.updateQueue,e.updateQueue=t,Lr(e,t),Il(s,!0),s.tail===null&&s.tailMode==="hidden"&&!h.alternate&&!Bt)return se(e),null}else 2*Pe()-s.renderingStartTime>Ar&&i!==536870912&&(e.flags|=128,o=!0,Il(s,!1),e.lanes=4194304);s.isBackwards?(h.sibling=e.child,e.child=h):(t=s.last,t!==null?t.sibling=h:e.child=h,s.last=h)}return s.tail!==null?(t=s.tail,s.rendering=t,s.tail=t.sibling,s.renderingStartTime=Pe(),t.sibling=null,i=pe.current,$(pe,o?i&1|2:i&1),Bt&&yt(e,s.treeForkCount),t):(se(e),null);case 22:case 23:return cn(e),ec(),s=e.memoizedState!==null,t!==null?t.memoizedState!==null!==s&&(e.flags|=8192):s&&(e.flags|=8192),s?(i&536870912)!==0&&(e.flags&128)===0&&(se(e),e.subtreeFlags&6&&(e.flags|=8192)):se(e),i=e.updateQueue,i!==null&&Lr(e,i.retryQueue),i=null,t!==null&&t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(i=t.memoizedState.cachePool.pool),s=null,e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(s=e.memoizedState.cachePool.pool),s!==i&&(e.flags|=2048),t!==null&&X(ts),null;case 24:return i=null,t!==null&&(i=t.memoizedState.cache),e.memoizedState.cache!==i&&(e.flags|=2048),yi(ye),se(e),null;case 25:return null;case 30:return null}throw Error(c(156,e.tag))}function zg(t,e){switch(he(e),e.tag){case 1:return t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 3:return yi(ye),Yt(),t=e.flags,(t&65536)!==0&&(t&128)===0?(e.flags=t&-65537|128,e):null;case 26:case 27:case 5:return an(e),null;case 31:if(e.memoizedState!==null){if(cn(e),e.alternate===null)throw Error(c(340));_i()}return t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 13:if(cn(e),t=e.memoizedState,t!==null&&t.dehydrated!==null){if(e.alternate===null)throw Error(c(340));_i()}return t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 19:return X(pe),null;case 4:return Yt(),null;case 10:return yi(e.type),null;case 22:case 23:return cn(e),ec(),t!==null&&X(ts),t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 24:return yi(ye),null;case 25:return null;default:return null}}function gd(t,e){switch(he(e),e.tag){case 3:yi(ye),Yt();break;case 26:case 27:case 5:an(e);break;case 4:Yt();break;case 31:e.memoizedState!==null&&cn(e);break;case 13:cn(e);break;case 19:X(pe);break;case 10:yi(e.type);break;case 22:case 23:cn(e),ec(),t!==null&&X(ts);break;case 24:yi(ye)}}function Wl(t,e){try{var i=e.updateQueue,s=i!==null?i.lastEffect:null;if(s!==null){var o=s.next;i=o;do{if((i.tag&t)===t){s=void 0;var h=i.create,v=i.inst;s=h(),v.destroy=s}i=i.next}while(i!==o)}}catch(w){It(e,e.return,w)}}function la(t,e,i){try{var s=e.updateQueue,o=s!==null?s.lastEffect:null;if(o!==null){var h=o.next;s=h;do{if((s.tag&t)===t){var v=s.inst,w=v.destroy;if(w!==void 0){v.destroy=void 0,o=e;var R=i,G=w;try{G()}catch(W){It(o,R,W)}}}s=s.next}while(s!==h)}}catch(W){It(e,e.return,W)}}function vd(t){var e=t.updateQueue;if(e!==null){var i=t.stateNode;try{oh(e,i)}catch(s){It(t,t.return,s)}}}function _d(t,e,i){i.props=ss(t.type,t.memoizedProps),i.state=t.memoizedState;try{i.componentWillUnmount()}catch(s){It(t,e,s)}}function $l(t,e){try{var i=t.ref;if(i!==null){switch(t.tag){case 26:case 27:case 5:var s=t.stateNode;break;case 30:s=t.stateNode;break;default:s=t.stateNode}typeof i=="function"?t.refCleanup=i(s):i.current=s}}catch(o){It(t,e,o)}}function $n(t,e){var i=t.ref,s=t.refCleanup;if(i!==null)if(typeof s=="function")try{s()}catch(o){It(t,e,o)}finally{t.refCleanup=null,t=t.alternate,t!=null&&(t.refCleanup=null)}else if(typeof i=="function")try{i(null)}catch(o){It(t,e,o)}else i.current=null}function yd(t){var e=t.type,i=t.memoizedProps,s=t.stateNode;try{t:switch(e){case"button":case"input":case"select":case"textarea":i.autoFocus&&s.focus();break t;case"img":i.src?s.src=i.src:i.srcSet&&(s.srcset=i.srcSet)}}catch(o){It(t,t.return,o)}}function Rc(t,e,i){try{var s=t.stateNode;Xg(s,t.type,i,e),s[Le]=e}catch(o){It(t,t.return,o)}}function xd(t){return t.tag===5||t.tag===3||t.tag===26||t.tag===27&&da(t.type)||t.tag===4}function Dc(t){t:for(;;){for(;t.sibling===null;){if(t.return===null||xd(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;t.tag!==5&&t.tag!==6&&t.tag!==18;){if(t.tag===27&&da(t.type)||t.flags&2||t.child===null||t.tag===4)continue t;t.child.return=t,t=t.child}if(!(t.flags&2))return t.stateNode}}function jc(t,e,i){var s=t.tag;if(s===5||s===6)t=t.stateNode,e?(i.nodeType===9?i.body:i.nodeName==="HTML"?i.ownerDocument.body:i).insertBefore(t,e):(e=i.nodeType===9?i.body:i.nodeName==="HTML"?i.ownerDocument.body:i,e.appendChild(t),i=i._reactRootContainer,i!=null||e.onclick!==null||(e.onclick=Ke));else if(s!==4&&(s===27&&da(t.type)&&(i=t.stateNode,e=null),t=t.child,t!==null))for(jc(t,e,i),t=t.sibling;t!==null;)jc(t,e,i),t=t.sibling}function Cr(t,e,i){var s=t.tag;if(s===5||s===6)t=t.stateNode,e?i.insertBefore(t,e):i.appendChild(t);else if(s!==4&&(s===27&&da(t.type)&&(i=t.stateNode),t=t.child,t!==null))for(Cr(t,e,i),t=t.sibling;t!==null;)Cr(t,e,i),t=t.sibling}function bd(t){var e=t.stateNode,i=t.memoizedProps;try{for(var s=t.type,o=e.attributes;o.length;)e.removeAttributeNode(o[0]);je(e,s,i),e[ve]=t,e[Le]=i}catch(h){It(t,t.return,h)}}var zi=!1,Se=!1,Bc=!1,Sd=typeof WeakSet=="function"?WeakSet:Set,Oe=null;function Tg(t,e){if(t=t.containerInfo,af=Jr,t=Ya(t),Vn(t)){if("selectionStart"in t)var i={start:t.selectionStart,end:t.selectionEnd};else t:{i=(i=t.ownerDocument)&&i.defaultView||window;var s=i.getSelection&&i.getSelection();if(s&&s.rangeCount!==0){i=s.anchorNode;var o=s.anchorOffset,h=s.focusNode;s=s.focusOffset;try{i.nodeType,h.nodeType}catch{i=null;break t}var v=0,w=-1,R=-1,G=0,W=0,it=t,Q=null;e:for(;;){for(var J;it!==i||o!==0&&it.nodeType!==3||(w=v+o),it!==h||s!==0&&it.nodeType!==3||(R=v+s),it.nodeType===3&&(v+=it.nodeValue.length),(J=it.firstChild)!==null;)Q=it,it=J;for(;;){if(it===t)break e;if(Q===i&&++G===o&&(w=v),Q===h&&++W===s&&(R=v),(J=it.nextSibling)!==null)break;it=Q,Q=it.parentNode}it=J}i=w===-1||R===-1?null:{start:w,end:R}}else i=null}i=i||{start:0,end:0}}else i=null;for(sf={focusedElem:t,selectionRange:i},Jr=!1,Oe=e;Oe!==null;)if(e=Oe,t=e.child,(e.subtreeFlags&1028)!==0&&t!==null)t.return=e,Oe=t;else for(;Oe!==null;){switch(e=Oe,h=e.alternate,t=e.flags,e.tag){case 0:if((t&4)!==0&&(t=e.updateQueue,t=t!==null?t.events:null,t!==null))for(i=0;i title"))),je(h,s,i),h[ve]=t,_e(h),s=h;break t;case"link":var v=Cm("link","href",o).get(s+(i.href||""));if(v){for(var w=0;wee&&(v=ee,ee=bt,bt=v);var Z=qa(w,bt),j=qa(w,ee);if(Z&&j&&(J.rangeCount!==1||J.anchorNode!==Z.node||J.anchorOffset!==Z.offset||J.focusNode!==j.node||J.focusOffset!==j.offset)){var Y=it.createRange();Y.setStart(Z.node,Z.offset),J.removeAllRanges(),bt>ee?(J.addRange(Y),J.extend(j.node,j.offset)):(Y.setEnd(j.node,j.offset),J.addRange(Y))}}}}for(it=[],J=w;J=J.parentNode;)J.nodeType===1&&it.push({element:J,left:J.scrollLeft,top:J.scrollTop});for(typeof w.focus=="function"&&w.focus(),w=0;wi?32:i,D.T=null,i=Yc,Yc=null;var h=ca,v=Ni;if(Ee=0,Ks=ca=null,Ni=0,(Qt&6)!==0)throw Error(c(331));var w=Qt;if(Qt|=4,Rd(h.current),Od(h,h.current,v,i),Qt=w,so(0,!1),Me&&typeof Me.onPostCommitFiberRoot=="function")try{Me.onPostCommitFiberRoot(ji,h)}catch{}return!0}finally{q.p=o,D.T=s,Id(t,e)}}function $d(t,e,i){e=a(i,e),e=Sc(t.stateNode,e,2),t=ia(t,e,2),t!==null&&(ri(t,2),ti(t))}function It(t,e,i){if(t.tag===3)$d(t,t,i);else for(;e!==null;){if(e.tag===3){$d(e,t,i);break}else if(e.tag===1){var s=e.stateNode;if(typeof e.type.getDerivedStateFromError=="function"||typeof s.componentDidCatch=="function"&&(ua===null||!ua.has(s))){t=a(i,t),i=td(2),s=ia(e,i,2),s!==null&&(ed(i,s,e,t),ri(s,2),ti(s));break}}e=e.return}}function Kc(t,e,i){var s=t.pingCache;if(s===null){s=t.pingCache=new Cg;var o=new Set;s.set(e,o)}else o=s.get(e),o===void 0&&(o=new Set,s.set(e,o));o.has(i)||(Zc=!0,o.add(i),t=Rg.bind(null,t,e,i),e.then(t,t))}function Rg(t,e,i){var s=t.pingCache;s!==null&&s.delete(e),t.pingedLanes|=t.suspendedLanes&i,t.warmLanes&=~i,ie===t&&(kt&i)===i&&(me===4||me===3&&(kt&62914560)===kt&&300>Pe()-Mr?(Qt&2)===0&&Qs(t,0):Pc|=i,Xs===kt&&(Xs=0)),ti(t)}function tm(t,e){e===0&&(e=Mo()),t=bn(t,e),t!==null&&(ri(t,e),ti(t))}function Dg(t){var e=t.memoizedState,i=0;e!==null&&(i=e.retryLane),tm(t,i)}function jg(t,e){var i=0;switch(t.tag){case 31:case 13:var s=t.stateNode,o=t.memoizedState;o!==null&&(i=o.retryLane);break;case 19:s=t.stateNode;break;case 22:s=t.stateNode._retryCache;break;default:throw Error(c(314))}s!==null&&s.delete(e),tm(t,i)}function Bg(t,e){return hs(t,e)}var Hr=null,Fs=null,Qc=!1,Zr=!1,Jc=!1,ha=0;function ti(t){t!==Fs&&t.next===null&&(Fs===null?Hr=Fs=t:Fs=Fs.next=t),Zr=!0,Qc||(Qc=!0,Hg())}function so(t,e){if(!Jc&&Zr){Jc=!0;do for(var i=!1,s=Hr;s!==null;){if(t!==0){var o=s.pendingLanes;if(o===0)var h=0;else{var v=s.suspendedLanes,w=s.pingedLanes;h=(1<<31-ke(42|t)+1)-1,h&=o&~(v&~w),h=h&201326741?h&201326741|1:h?h|2:0}h!==0&&(i=!0,am(s,h))}else h=kt,h=ms(s,s===ie?h:0,s.cancelPendingCommit!==null||s.timeoutHandle!==-1),(h&3)===0||Ea(s,h)||(i=!0,am(s,h));s=s.next}while(i);Jc=!1}}function Ug(){em()}function em(){Zr=Qc=!1;var t=0;ha!==0&&Qg()&&(t=ha);for(var e=Pe(),i=null,s=Hr;s!==null;){var o=s.next,h=nm(s,e);h===0?(s.next=null,i===null?Hr=o:i.next=o,o===null&&(Fs=i)):(i=s,(t!==0||(h&3)!==0)&&(Zr=!0)),s=o}Ee!==0&&Ee!==5||so(t),ha!==0&&(ha=0)}function nm(t,e){for(var i=t.suspendedLanes,s=t.pingedLanes,o=t.expirationTimes,h=t.pendingLanes&-62914561;0w)break;var W=R.transferSize,it=R.initiatorType;W&&hm(it)&&(R=R.responseEnd,v+=W*(R"u"?null:document;function zm(t,e,i){var s=Is;if(s&&typeof e=="string"&&e){var o=ae(e);o='link[rel="'+t+'"][href="'+o+'"]',typeof i=="string"&&(o+='[crossorigin="'+i+'"]'),wm.has(o)||(wm.add(o),t={rel:t,crossOrigin:i,href:e},s.querySelector(o)===null&&(e=s.createElement("link"),je(e,"link",t),_e(e),s.head.appendChild(e)))}}function iv(t){Oi.D(t),zm("dns-prefetch",t,null)}function av(t,e){Oi.C(t,e),zm("preconnect",t,e)}function sv(t,e,i){Oi.L(t,e,i);var s=Is;if(s&&t&&e){var o='link[rel="preload"][as="'+ae(e)+'"]';e==="image"&&i&&i.imageSrcSet?(o+='[imagesrcset="'+ae(i.imageSrcSet)+'"]',typeof i.imageSizes=="string"&&(o+='[imagesizes="'+ae(i.imageSizes)+'"]')):o+='[href="'+ae(t)+'"]';var h=o;switch(e){case"style":h=Ws(t);break;case"script":h=$s(t)}Tn.has(h)||(t=E({rel:"preload",href:e==="image"&&i&&i.imageSrcSet?void 0:t,as:e},i),Tn.set(h,t),s.querySelector(o)!==null||e==="style"&&s.querySelector(uo(h))||e==="script"&&s.querySelector(co(h))||(e=s.createElement("link"),je(e,"link",t),_e(e),s.head.appendChild(e)))}}function lv(t,e){Oi.m(t,e);var i=Is;if(i&&t){var s=e&&typeof e.as=="string"?e.as:"script",o='link[rel="modulepreload"][as="'+ae(s)+'"][href="'+ae(t)+'"]',h=o;switch(s){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":h=$s(t)}if(!Tn.has(h)&&(t=E({rel:"modulepreload",href:t},e),Tn.set(h,t),i.querySelector(o)===null)){switch(s){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(i.querySelector(co(h)))return}s=i.createElement("link"),je(s,"link",t),_e(s),i.head.appendChild(s)}}}function ov(t,e,i){Oi.S(t,e,i);var s=Is;if(s&&t){var o=Ui(s).hoistableStyles,h=Ws(t);e=e||"default";var v=o.get(h);if(!v){var w={loading:0,preload:null};if(v=s.querySelector(uo(h)))w.loading=5;else{t=E({rel:"stylesheet",href:t,"data-precedence":e},i),(i=Tn.get(h))&&hf(t,i);var R=v=s.createElement("link");_e(R),je(R,"link",t),R._p=new Promise(function(G,W){R.onload=G,R.onerror=W}),R.addEventListener("load",function(){w.loading|=1}),R.addEventListener("error",function(){w.loading|=2}),w.loading|=4,Gr(v,e,s)}v={type:"stylesheet",instance:v,count:1,state:w},o.set(h,v)}}}function rv(t,e){Oi.X(t,e);var i=Is;if(i&&t){var s=Ui(i).hoistableScripts,o=$s(t),h=s.get(o);h||(h=i.querySelector(co(o)),h||(t=E({src:t,async:!0},e),(e=Tn.get(o))&&df(t,e),h=i.createElement("script"),_e(h),je(h,"link",t),i.head.appendChild(h)),h={type:"script",instance:h,count:1,state:null},s.set(o,h))}}function uv(t,e){Oi.M(t,e);var i=Is;if(i&&t){var s=Ui(i).hoistableScripts,o=$s(t),h=s.get(o);h||(h=i.querySelector(co(o)),h||(t=E({src:t,async:!0,type:"module"},e),(e=Tn.get(o))&&df(t,e),h=i.createElement("script"),_e(h),je(h,"link",t),i.head.appendChild(h)),h={type:"script",instance:h,count:1,state:null},s.set(o,h))}}function Tm(t,e,i,s){var o=(o=rt.current)?Yr(o):null;if(!o)throw Error(c(446));switch(t){case"meta":case"title":return null;case"style":return typeof i.precedence=="string"&&typeof i.href=="string"?(e=Ws(i.href),i=Ui(o).hoistableStyles,s=i.get(e),s||(s={type:"style",instance:null,count:0,state:null},i.set(e,s)),s):{type:"void",instance:null,count:0,state:null};case"link":if(i.rel==="stylesheet"&&typeof i.href=="string"&&typeof i.precedence=="string"){t=Ws(i.href);var h=Ui(o).hoistableStyles,v=h.get(t);if(v||(o=o.ownerDocument||o,v={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},h.set(t,v),(h=o.querySelector(uo(t)))&&!h._p&&(v.instance=h,v.state.loading=5),Tn.has(t)||(i={rel:"preload",as:"style",href:i.href,crossOrigin:i.crossOrigin,integrity:i.integrity,media:i.media,hrefLang:i.hrefLang,referrerPolicy:i.referrerPolicy},Tn.set(t,i),h||cv(o,t,i,v.state))),e&&s===null)throw Error(c(528,""));return v}if(e&&s!==null)throw Error(c(529,""));return null;case"script":return e=i.async,i=i.src,typeof i=="string"&&e&&typeof e!="function"&&typeof e!="symbol"?(e=$s(i),i=Ui(o).hoistableScripts,s=i.get(e),s||(s={type:"script",instance:null,count:0,state:null},i.set(e,s)),s):{type:"void",instance:null,count:0,state:null};default:throw Error(c(444,t))}}function Ws(t){return'href="'+ae(t)+'"'}function uo(t){return'link[rel="stylesheet"]['+t+"]"}function Em(t){return E({},t,{"data-precedence":t.precedence,precedence:null})}function cv(t,e,i,s){t.querySelector('link[rel="preload"][as="style"]['+e+"]")?s.loading=1:(e=t.createElement("link"),s.preload=e,e.addEventListener("load",function(){return s.loading|=1}),e.addEventListener("error",function(){return s.loading|=2}),je(e,"link",i),_e(e),t.head.appendChild(e))}function $s(t){return'[src="'+ae(t)+'"]'}function co(t){return"script[async]"+t}function Lm(t,e,i){if(e.count++,e.instance===null)switch(e.type){case"style":var s=t.querySelector('style[data-href~="'+ae(i.href)+'"]');if(s)return e.instance=s,_e(s),s;var o=E({},i,{"data-href":i.href,"data-precedence":i.precedence,href:null,precedence:null});return s=(t.ownerDocument||t).createElement("style"),_e(s),je(s,"style",o),Gr(s,i.precedence,t),e.instance=s;case"stylesheet":o=Ws(i.href);var h=t.querySelector(uo(o));if(h)return e.state.loading|=4,e.instance=h,_e(h),h;s=Em(i),(o=Tn.get(o))&&hf(s,o),h=(t.ownerDocument||t).createElement("link"),_e(h);var v=h;return v._p=new Promise(function(w,R){v.onload=w,v.onerror=R}),je(h,"link",s),e.state.loading|=4,Gr(h,i.precedence,t),e.instance=h;case"script":return h=$s(i.src),(o=t.querySelector(co(h)))?(e.instance=o,_e(o),o):(s=i,(o=Tn.get(h))&&(s=E({},i),df(s,o)),t=t.ownerDocument||t,o=t.createElement("script"),_e(o),je(o,"link",s),t.head.appendChild(o),e.instance=o);case"void":return null;default:throw Error(c(443,e.type))}else e.type==="stylesheet"&&(e.state.loading&4)===0&&(s=e.instance,e.state.loading|=4,Gr(s,i.precedence,t));return e.instance}function Gr(t,e,i){for(var s=i.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),o=s.length?s[s.length-1]:null,h=o,v=0;v title"):null)}function fv(t,e,i){if(i===1||e.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof e.precedence!="string"||typeof e.href!="string"||e.href==="")break;return!0;case"link":if(typeof e.rel!="string"||typeof e.href!="string"||e.href===""||e.onLoad||e.onError)break;switch(e.rel){case"stylesheet":return t=e.disabled,typeof e.precedence=="string"&&t==null;default:return!0}case"script":if(e.async&&typeof e.async!="function"&&typeof e.async!="symbol"&&!e.onLoad&&!e.onError&&e.src&&typeof e.src=="string")return!0}return!1}function Om(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function hv(t,e,i,s){if(i.type==="stylesheet"&&(typeof s.media!="string"||matchMedia(s.media).matches!==!1)&&(i.state.loading&4)===0){if(i.instance===null){var o=Ws(s.href),h=e.querySelector(uo(o));if(h){e=h._p,e!==null&&typeof e=="object"&&typeof e.then=="function"&&(t.count++,t=Xr.bind(t),e.then(t,t)),i.state.loading|=4,i.instance=h,_e(h);return}h=e.ownerDocument||e,s=Em(s),(o=Tn.get(o))&&hf(s,o),h=h.createElement("link"),_e(h);var v=h;v._p=new Promise(function(w,R){v.onload=w,v.onerror=R}),je(h,"link",s),i.instance=h}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(i,e),(e=i.state.preload)&&(i.state.loading&3)===0&&(t.count++,i=Xr.bind(t),e.addEventListener("load",i),e.addEventListener("error",i))}}var mf=0;function dv(t,e){return t.stylesheets&&t.count===0&&Qr(t,t.stylesheets),0mf?50:800)+e);return t.unsuspend=i,function(){t.unsuspend=null,clearTimeout(s),clearTimeout(o)}}:null}function Xr(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Qr(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var Kr=null;function Qr(t,e){t.stylesheets=null,t.unsuspend!==null&&(t.count++,Kr=new Map,e.forEach(mv,t),Kr=null,Xr.call(t))}function mv(t,e){if(!(e.state.loading&4)){var i=Kr.get(t);if(i)var s=i.get(null);else{i=new Map,Kr.set(t,i);for(var o=t.querySelectorAll("link[data-precedence],style[data-precedence]"),h=0;h"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(f)}catch(u){console.error(u)}}return f(),wf.exports=Nv(),wf.exports}var Mv=Ov();/** + * react-router v7.13.1 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */var tp="popstate";function ep(f){return typeof f=="object"&&f!=null&&"pathname"in f&&"search"in f&&"hash"in f&&"state"in f&&"key"in f}function Av(f={}){function u(c,m){var x;let p=(x=m.state)==null?void 0:x.masked,{pathname:_,search:z,hash:y}=p||c.location;return Af("",{pathname:_,search:z,hash:y},m.state&&m.state.usr||null,m.state&&m.state.key||"default",p?{pathname:c.location.pathname,search:c.location.search,hash:c.location.hash}:void 0)}function r(c,m){return typeof m=="string"?m:zo(m)}return Dv(u,r,null,f)}function re(f,u){if(f===!1||f===null||typeof f>"u")throw new Error(u)}function Hn(f,u){if(!f){typeof console<"u"&&console.warn(u);try{throw new Error(u)}catch{}}}function Rv(){return Math.random().toString(36).substring(2,10)}function np(f,u){return{usr:f.state,key:f.key,idx:u,masked:f.unstable_mask?{pathname:f.pathname,search:f.search,hash:f.hash}:void 0}}function Af(f,u,r=null,c,m){return{pathname:typeof f=="string"?f:f.pathname,search:"",hash:"",...typeof u=="string"?sl(u):u,state:r,key:u&&u.key||c||Rv(),unstable_mask:m}}function zo({pathname:f="/",search:u="",hash:r=""}){return u&&u!=="?"&&(f+=u.charAt(0)==="?"?u:"?"+u),r&&r!=="#"&&(f+=r.charAt(0)==="#"?r:"#"+r),f}function sl(f){let u={};if(f){let r=f.indexOf("#");r>=0&&(u.hash=f.substring(r),f=f.substring(0,r));let c=f.indexOf("?");c>=0&&(u.search=f.substring(c),f=f.substring(0,c)),f&&(u.pathname=f)}return u}function Dv(f,u,r,c={}){let{window:m=document.defaultView,v5Compat:p=!1}=c,_=m.history,z="POP",y=null,x=T();x==null&&(x=0,_.replaceState({..._.state,idx:x},""));function T(){return(_.state||{idx:null}).idx}function E(){z="POP";let V=T(),et=V==null?null:V-x;x=V,y&&y({action:z,location:B.location,delta:et})}function O(V,et){z="PUSH";let U=ep(V)?V:Af(B.location,V,et);x=T()+1;let k=np(U,x),F=B.createHref(U.unstable_mask||U);try{_.pushState(k,"",F)}catch(mt){if(mt instanceof DOMException&&mt.name==="DataCloneError")throw mt;m.location.assign(F)}p&&y&&y({action:z,location:B.location,delta:1})}function H(V,et){z="REPLACE";let U=ep(V)?V:Af(B.location,V,et);x=T();let k=np(U,x),F=B.createHref(U.unstable_mask||U);_.replaceState(k,"",F),p&&y&&y({action:z,location:B.location,delta:0})}function A(V){return jv(V)}let B={get action(){return z},get location(){return f(m,_)},listen(V){if(y)throw new Error("A history only accepts one active listener");return m.addEventListener(tp,E),y=V,()=>{m.removeEventListener(tp,E),y=null}},createHref(V){return u(m,V)},createURL:A,encodeLocation(V){let et=A(V);return{pathname:et.pathname,search:et.search,hash:et.hash}},push:O,replace:H,go(V){return _.go(V)}};return B}function jv(f,u=!1){let r="http://localhost";typeof window<"u"&&(r=window.location.origin!=="null"?window.location.origin:window.location.href),re(r,"No window.location.(origin|href) available to create URL");let c=typeof f=="string"?f:zo(f);return c=c.replace(/ $/,"%20"),!u&&c.startsWith("//")&&(c=r+c),new URL(c,r)}function Ep(f,u,r="/"){return Bv(f,u,r,!1)}function Bv(f,u,r,c){let m=typeof u=="string"?sl(u):u,p=Ri(m.pathname||"/",r);if(p==null)return null;let _=Lp(f);Uv(_);let z=null;for(let y=0;z==null&&y<_.length;++y){let x=Qv(p);z=Xv(_[y],x,c)}return z}function Lp(f,u=[],r=[],c="",m=!1){let p=(_,z,y=m,x)=>{let T={relativePath:x===void 0?_.path||"":x,caseSensitive:_.caseSensitive===!0,childrenIndex:z,route:_};if(T.relativePath.startsWith("/")){if(!T.relativePath.startsWith(c)&&y)return;re(T.relativePath.startsWith(c),`Absolute route path "${T.relativePath}" nested under path "${c}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),T.relativePath=T.relativePath.slice(c.length)}let E=ii([c,T.relativePath]),O=r.concat(T);_.children&&_.children.length>0&&(re(_.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${E}".`),Lp(_.children,u,O,E,y)),!(_.path==null&&!_.index)&&u.push({path:E,score:Gv(E,_.index),routesMeta:O})};return f.forEach((_,z)=>{var y;if(_.path===""||!((y=_.path)!=null&&y.includes("?")))p(_,z);else for(let x of Cp(_.path))p(_,z,!0,x)}),u}function Cp(f){let u=f.split("/");if(u.length===0)return[];let[r,...c]=u,m=r.endsWith("?"),p=r.replace(/\?$/,"");if(c.length===0)return m?[p,""]:[p];let _=Cp(c.join("/")),z=[];return z.push(..._.map(y=>y===""?p:[p,y].join("/"))),m&&z.push(..._),z.map(y=>f.startsWith("/")&&y===""?"/":y)}function Uv(f){f.sort((u,r)=>u.score!==r.score?r.score-u.score:Vv(u.routesMeta.map(c=>c.childrenIndex),r.routesMeta.map(c=>c.childrenIndex)))}var Hv=/^:[\w-]+$/,Zv=3,Pv=2,kv=1,qv=10,Yv=-2,ip=f=>f==="*";function Gv(f,u){let r=f.split("/"),c=r.length;return r.some(ip)&&(c+=Yv),u&&(c+=Pv),r.filter(m=>!ip(m)).reduce((m,p)=>m+(Hv.test(p)?Zv:p===""?kv:qv),c)}function Vv(f,u){return f.length===u.length&&f.slice(0,-1).every((c,m)=>c===u[m])?f[f.length-1]-u[u.length-1]:0}function Xv(f,u,r=!1){let{routesMeta:c}=f,m={},p="/",_=[];for(let z=0;z{if(T==="*"){let A=z[O]||"";_=p.slice(0,p.length-A.length).replace(/(.)\/+$/,"$1")}const H=z[O];return E&&!H?x[T]=void 0:x[T]=(H||"").replace(/%2F/g,"/"),x},{}),pathname:p,pathnameBase:_,pattern:f}}function Kv(f,u=!1,r=!0){Hn(f==="*"||!f.endsWith("*")||f.endsWith("/*"),`Route path "${f}" will be treated as if it were "${f.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${f.replace(/\*$/,"/*")}".`);let c=[],m="^"+f.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(_,z,y,x,T)=>{if(c.push({paramName:z,isOptional:y!=null}),y){let E=T.charAt(x+_.length);return E&&E!=="/"?"/([^\\/]*)":"(?:/([^\\/]*))?"}return"/([^\\/]+)"}).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return f.endsWith("*")?(c.push({paramName:"*"}),m+=f==="*"||f==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):r?m+="\\/*$":f!==""&&f!=="/"&&(m+="(?:(?=\\/|$))"),[new RegExp(m,u?void 0:"i"),c]}function Qv(f){try{return f.split("/").map(u=>decodeURIComponent(u).replace(/\//g,"%2F")).join("/")}catch(u){return Hn(!1,`The URL path "${f}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${u}).`),f}}function Ri(f,u){if(u==="/")return f;if(!f.toLowerCase().startsWith(u.toLowerCase()))return null;let r=u.endsWith("/")?u.length-1:u.length,c=f.charAt(r);return c&&c!=="/"?null:f.slice(r)||"/"}var Jv=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function Fv(f,u="/"){let{pathname:r,search:c="",hash:m=""}=typeof f=="string"?sl(f):f,p;return r?(r=r.replace(/\/\/+/g,"/"),r.startsWith("/")?p=ap(r.substring(1),"/"):p=ap(r,u)):p=u,{pathname:p,search:$v(c),hash:t_(m)}}function ap(f,u){let r=u.replace(/\/+$/,"").split("/");return f.split("/").forEach(m=>{m===".."?r.length>1&&r.pop():m!=="."&&r.push(m)}),r.length>1?r.join("/"):"/"}function Lf(f,u,r,c){return`Cannot include a '${f}' character in a manually specified \`to.${u}\` field [${JSON.stringify(c)}]. Please separate it out to the \`to.${r}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function Iv(f){return f.filter((u,r)=>r===0||u.route.path&&u.route.path.length>0)}function Zf(f){let u=Iv(f);return u.map((r,c)=>c===u.length-1?r.pathname:r.pathnameBase)}function du(f,u,r,c=!1){let m;typeof f=="string"?m=sl(f):(m={...f},re(!m.pathname||!m.pathname.includes("?"),Lf("?","pathname","search",m)),re(!m.pathname||!m.pathname.includes("#"),Lf("#","pathname","hash",m)),re(!m.search||!m.search.includes("#"),Lf("#","search","hash",m)));let p=f===""||m.pathname==="",_=p?"/":m.pathname,z;if(_==null)z=r;else{let E=u.length-1;if(!c&&_.startsWith("..")){let O=_.split("/");for(;O[0]==="..";)O.shift(),E-=1;m.pathname=O.join("/")}z=E>=0?u[E]:"/"}let y=Fv(m,z),x=_&&_!=="/"&&_.endsWith("/"),T=(p||_===".")&&r.endsWith("/");return!y.pathname.endsWith("/")&&(x||T)&&(y.pathname+="/"),y}var ii=f=>f.join("/").replace(/\/\/+/g,"/"),Wv=f=>f.replace(/\/+$/,"").replace(/^\/*/,"/"),$v=f=>!f||f==="?"?"":f.startsWith("?")?f:"?"+f,t_=f=>!f||f==="#"?"":f.startsWith("#")?f:"#"+f,e_=class{constructor(f,u,r,c=!1){this.status=f,this.statusText=u||"",this.internal=c,r instanceof Error?(this.data=r.toString(),this.error=r):this.data=r}};function n_(f){return f!=null&&typeof f.status=="number"&&typeof f.statusText=="string"&&typeof f.internal=="boolean"&&"data"in f}function i_(f){return f.map(u=>u.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var Np=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function Op(f,u){let r=f;if(typeof r!="string"||!Jv.test(r))return{absoluteURL:void 0,isExternal:!1,to:r};let c=r,m=!1;if(Np)try{let p=new URL(window.location.href),_=r.startsWith("//")?new URL(p.protocol+r):new URL(r),z=Ri(_.pathname,u);_.origin===p.origin&&z!=null?r=z+_.search+_.hash:m=!0}catch{Hn(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:c,isExternal:m,to:r}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var Mp=["POST","PUT","PATCH","DELETE"];new Set(Mp);var a_=["GET",...Mp];new Set(a_);var ll=C.createContext(null);ll.displayName="DataRouter";var mu=C.createContext(null);mu.displayName="DataRouterState";var s_=C.createContext(!1),Ap=C.createContext({isTransitioning:!1});Ap.displayName="ViewTransition";var l_=C.createContext(new Map);l_.displayName="Fetchers";var o_=C.createContext(null);o_.displayName="Await";var mn=C.createContext(null);mn.displayName="Navigation";var Eo=C.createContext(null);Eo.displayName="Location";var ai=C.createContext({outlet:null,matches:[],isDataRoute:!1});ai.displayName="Route";var Pf=C.createContext(null);Pf.displayName="RouteError";var Rp="REACT_ROUTER_ERROR",r_="REDIRECT",u_="ROUTE_ERROR_RESPONSE";function c_(f){if(f.startsWith(`${Rp}:${r_}:{`))try{let u=JSON.parse(f.slice(28));if(typeof u=="object"&&u&&typeof u.status=="number"&&typeof u.statusText=="string"&&typeof u.location=="string"&&typeof u.reloadDocument=="boolean"&&typeof u.replace=="boolean")return u}catch{}}function f_(f){if(f.startsWith(`${Rp}:${u_}:{`))try{let u=JSON.parse(f.slice(40));if(typeof u=="object"&&u&&typeof u.status=="number"&&typeof u.statusText=="string")return new e_(u.status,u.statusText,u.data)}catch{}}function h_(f,{relative:u}={}){re(ol(),"useHref() may be used only in the context of a component.");let{basename:r,navigator:c}=C.useContext(mn),{hash:m,pathname:p,search:_}=Lo(f,{relative:u}),z=p;return r!=="/"&&(z=p==="/"?r:ii([r,p])),c.createHref({pathname:z,search:_,hash:m})}function ol(){return C.useContext(Eo)!=null}function Di(){return re(ol(),"useLocation() may be used only in the context of a component."),C.useContext(Eo).location}var Dp="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function jp(f){C.useContext(mn).static||C.useLayoutEffect(f)}function pu(){let{isDataRoute:f}=C.useContext(ai);return f?T_():d_()}function d_(){re(ol(),"useNavigate() may be used only in the context of a component.");let f=C.useContext(ll),{basename:u,navigator:r}=C.useContext(mn),{matches:c}=C.useContext(ai),{pathname:m}=Di(),p=JSON.stringify(Zf(c)),_=C.useRef(!1);return jp(()=>{_.current=!0}),C.useCallback((y,x={})=>{if(Hn(_.current,Dp),!_.current)return;if(typeof y=="number"){r.go(y);return}let T=du(y,JSON.parse(p),m,x.relative==="path");f==null&&u!=="/"&&(T.pathname=T.pathname==="/"?u:ii([u,T.pathname])),(x.replace?r.replace:r.push)(T,x.state,x)},[u,r,p,m,f])}C.createContext(null);function Lo(f,{relative:u}={}){let{matches:r}=C.useContext(ai),{pathname:c}=Di(),m=JSON.stringify(Zf(r));return C.useMemo(()=>du(f,JSON.parse(m),c,u==="path"),[f,m,c,u])}function m_(f,u){return Bp(f,u)}function Bp(f,u,r){var V;re(ol(),"useRoutes() may be used only in the context of a component.");let{navigator:c}=C.useContext(mn),{matches:m}=C.useContext(ai),p=m[m.length-1],_=p?p.params:{},z=p?p.pathname:"/",y=p?p.pathnameBase:"/",x=p&&p.route;{let et=x&&x.path||"";Hp(z,!x||et.endsWith("*")||et.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${z}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let T=Di(),E;if(u){let et=typeof u=="string"?sl(u):u;re(y==="/"||((V=et.pathname)==null?void 0:V.startsWith(y)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${y}" but pathname "${et.pathname}" was given in the \`location\` prop.`),E=et}else E=T;let O=E.pathname||"/",H=O;if(y!=="/"){let et=y.replace(/^\//,"").split("/");H="/"+O.replace(/^\//,"").split("/").slice(et.length).join("/")}let A=Ep(f,{pathname:H});Hn(x||A!=null,`No routes matched location "${E.pathname}${E.search}${E.hash}" `),Hn(A==null||A[A.length-1].route.element!==void 0||A[A.length-1].route.Component!==void 0||A[A.length-1].route.lazy!==void 0,`Matched leaf route at location "${E.pathname}${E.search}${E.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let B=y_(A&&A.map(et=>Object.assign({},et,{params:Object.assign({},_,et.params),pathname:ii([y,c.encodeLocation?c.encodeLocation(et.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:et.pathname]),pathnameBase:et.pathnameBase==="/"?y:ii([y,c.encodeLocation?c.encodeLocation(et.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:et.pathnameBase])})),m,r);return u&&B?C.createElement(Eo.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",unstable_mask:void 0,...E},navigationType:"POP"}},B):B}function p_(){let f=z_(),u=n_(f)?`${f.status} ${f.statusText}`:f instanceof Error?f.message:JSON.stringify(f),r=f instanceof Error?f.stack:null,c="rgba(200,200,200, 0.5)",m={padding:"0.5rem",backgroundColor:c},p={padding:"2px 4px",backgroundColor:c},_=null;return console.error("Error handled by React Router default ErrorBoundary:",f),_=C.createElement(C.Fragment,null,C.createElement("p",null,"💿 Hey developer 👋"),C.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",C.createElement("code",{style:p},"ErrorBoundary")," or"," ",C.createElement("code",{style:p},"errorElement")," prop on your route.")),C.createElement(C.Fragment,null,C.createElement("h2",null,"Unexpected Application Error!"),C.createElement("h3",{style:{fontStyle:"italic"}},u),r?C.createElement("pre",{style:m},r):null,_)}var g_=C.createElement(p_,null),Up=class extends C.Component{constructor(f){super(f),this.state={location:f.location,revalidation:f.revalidation,error:f.error}}static getDerivedStateFromError(f){return{error:f}}static getDerivedStateFromProps(f,u){return u.location!==f.location||u.revalidation!=="idle"&&f.revalidation==="idle"?{error:f.error,location:f.location,revalidation:f.revalidation}:{error:f.error!==void 0?f.error:u.error,location:u.location,revalidation:f.revalidation||u.revalidation}}componentDidCatch(f,u){this.props.onError?this.props.onError(f,u):console.error("React Router caught the following error during render",f)}render(){let f=this.state.error;if(this.context&&typeof f=="object"&&f&&"digest"in f&&typeof f.digest=="string"){const r=f_(f.digest);r&&(f=r)}let u=f!==void 0?C.createElement(ai.Provider,{value:this.props.routeContext},C.createElement(Pf.Provider,{value:f,children:this.props.component})):this.props.children;return this.context?C.createElement(v_,{error:f},u):u}};Up.contextType=s_;var Cf=new WeakMap;function v_({children:f,error:u}){let{basename:r}=C.useContext(mn);if(typeof u=="object"&&u&&"digest"in u&&typeof u.digest=="string"){let c=c_(u.digest);if(c){let m=Cf.get(u);if(m)throw m;let p=Op(c.location,r);if(Np&&!Cf.get(u))if(p.isExternal||c.reloadDocument)window.location.href=p.absoluteURL||p.to;else{const _=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(p.to,{replace:c.replace}));throw Cf.set(u,_),_}return C.createElement("meta",{httpEquiv:"refresh",content:`0;url=${p.absoluteURL||p.to}`})}}return f}function __({routeContext:f,match:u,children:r}){let c=C.useContext(ll);return c&&c.static&&c.staticContext&&(u.route.errorElement||u.route.ErrorBoundary)&&(c.staticContext._deepestRenderedBoundaryId=u.route.id),C.createElement(ai.Provider,{value:f},r)}function y_(f,u=[],r){let c=r==null?void 0:r.state;if(f==null){if(!c)return null;if(c.errors)f=c.matches;else if(u.length===0&&!c.initialized&&c.matches.length>0)f=c.matches;else return null}let m=f,p=c==null?void 0:c.errors;if(p!=null){let T=m.findIndex(E=>E.route.id&&(p==null?void 0:p[E.route.id])!==void 0);re(T>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(p).join(",")}`),m=m.slice(0,Math.min(m.length,T+1))}let _=!1,z=-1;if(r&&c){_=c.renderFallback;for(let T=0;T=0?m=m.slice(0,z+1):m=[m[0]];break}}}}let y=r==null?void 0:r.onError,x=c&&y?(T,E)=>{var O,H;y(T,{location:c.location,params:((H=(O=c.matches)==null?void 0:O[0])==null?void 0:H.params)??{},unstable_pattern:i_(c.matches),errorInfo:E})}:void 0;return m.reduceRight((T,E,O)=>{let H,A=!1,B=null,V=null;c&&(H=p&&E.route.id?p[E.route.id]:void 0,B=E.route.errorElement||g_,_&&(z<0&&O===0?(Hp("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),A=!0,V=null):z===O&&(A=!0,V=E.route.hydrateFallbackElement||null)));let et=u.concat(m.slice(0,O+1)),U=()=>{let k;return H?k=B:A?k=V:E.route.Component?k=C.createElement(E.route.Component,null):E.route.element?k=E.route.element:k=T,C.createElement(__,{match:E,routeContext:{outlet:T,matches:et,isDataRoute:c!=null},children:k})};return c&&(E.route.ErrorBoundary||E.route.errorElement||O===0)?C.createElement(Up,{location:c.location,revalidation:c.revalidation,component:B,error:H,children:U(),routeContext:{outlet:null,matches:et,isDataRoute:!0},onError:x}):U()},null)}function kf(f){return`${f} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function x_(f){let u=C.useContext(ll);return re(u,kf(f)),u}function b_(f){let u=C.useContext(mu);return re(u,kf(f)),u}function S_(f){let u=C.useContext(ai);return re(u,kf(f)),u}function qf(f){let u=S_(f),r=u.matches[u.matches.length-1];return re(r.route.id,`${f} can only be used on routes that contain a unique "id"`),r.route.id}function w_(){return qf("useRouteId")}function z_(){var c;let f=C.useContext(Pf),u=b_("useRouteError"),r=qf("useRouteError");return f!==void 0?f:(c=u.errors)==null?void 0:c[r]}function T_(){let{router:f}=x_("useNavigate"),u=qf("useNavigate"),r=C.useRef(!1);return jp(()=>{r.current=!0}),C.useCallback(async(m,p={})=>{Hn(r.current,Dp),r.current&&(typeof m=="number"?await f.navigate(m):await f.navigate(m,{fromRouteId:u,...p}))},[f,u])}var sp={};function Hp(f,u,r){!u&&!sp[f]&&(sp[f]=!0,Hn(!1,r))}C.memo(E_);function E_({routes:f,future:u,state:r,isStatic:c,onError:m}){return Bp(f,void 0,{state:r,isStatic:c,onError:m})}function Zp({to:f,replace:u,state:r,relative:c}){re(ol()," may be used only in the context of a component.");let{static:m}=C.useContext(mn);Hn(!m," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:p}=C.useContext(ai),{pathname:_}=Di(),z=pu(),y=du(f,Zf(p),_,c==="path"),x=JSON.stringify(y);return C.useEffect(()=>{z(JSON.parse(x),{replace:u,state:r,relative:c})},[z,x,c,u,r]),null}function Mi(f){re(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function L_({basename:f="/",children:u=null,location:r,navigationType:c="POP",navigator:m,static:p=!1,unstable_useTransitions:_}){re(!ol(),"You cannot render a inside another . You should never have more than one in your app.");let z=f.replace(/^\/*/,"/"),y=C.useMemo(()=>({basename:z,navigator:m,static:p,unstable_useTransitions:_,future:{}}),[z,m,p,_]);typeof r=="string"&&(r=sl(r));let{pathname:x="/",search:T="",hash:E="",state:O=null,key:H="default",unstable_mask:A}=r,B=C.useMemo(()=>{let V=Ri(x,z);return V==null?null:{location:{pathname:V,search:T,hash:E,state:O,key:H,unstable_mask:A},navigationType:c}},[z,x,T,E,O,H,c,A]);return Hn(B!=null,` is not able to match the URL "${x}${T}${E}" because it does not start with the basename, so the won't render anything.`),B==null?null:C.createElement(mn.Provider,{value:y},C.createElement(Eo.Provider,{children:u,value:B}))}function lp({children:f,location:u}){return m_(Rf(f),u)}function Rf(f,u=[]){let r=[];return C.Children.forEach(f,(c,m)=>{if(!C.isValidElement(c))return;let p=[...u,m];if(c.type===C.Fragment){r.push.apply(r,Rf(c.props.children,p));return}re(c.type===Mi,`[${typeof c.type=="string"?c.type:c.type.name}] is not a component. All component children of must be a or `),re(!c.props.index||!c.props.children,"An index route cannot have child routes.");let _={id:c.props.id||p.join("-"),caseSensitive:c.props.caseSensitive,element:c.props.element,Component:c.props.Component,index:c.props.index,path:c.props.path,middleware:c.props.middleware,loader:c.props.loader,action:c.props.action,hydrateFallbackElement:c.props.hydrateFallbackElement,HydrateFallback:c.props.HydrateFallback,errorElement:c.props.errorElement,ErrorBoundary:c.props.ErrorBoundary,hasErrorBoundary:c.props.hasErrorBoundary===!0||c.props.ErrorBoundary!=null||c.props.errorElement!=null,shouldRevalidate:c.props.shouldRevalidate,handle:c.props.handle,lazy:c.props.lazy};c.props.children&&(_.children=Rf(c.props.children,p)),r.push(_)}),r}var su="get",lu="application/x-www-form-urlencoded";function gu(f){return typeof HTMLElement<"u"&&f instanceof HTMLElement}function C_(f){return gu(f)&&f.tagName.toLowerCase()==="button"}function N_(f){return gu(f)&&f.tagName.toLowerCase()==="form"}function O_(f){return gu(f)&&f.tagName.toLowerCase()==="input"}function M_(f){return!!(f.metaKey||f.altKey||f.ctrlKey||f.shiftKey)}function A_(f,u){return f.button===0&&(!u||u==="_self")&&!M_(f)}var nu=null;function R_(){if(nu===null)try{new FormData(document.createElement("form"),0),nu=!1}catch{nu=!0}return nu}var D_=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function Nf(f){return f!=null&&!D_.has(f)?(Hn(!1,`"${f}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${lu}"`),null):f}function j_(f,u){let r,c,m,p,_;if(N_(f)){let z=f.getAttribute("action");c=z?Ri(z,u):null,r=f.getAttribute("method")||su,m=Nf(f.getAttribute("enctype"))||lu,p=new FormData(f)}else if(C_(f)||O_(f)&&(f.type==="submit"||f.type==="image")){let z=f.form;if(z==null)throw new Error('Cannot submit a - - - - ); -}; - -export default AdminLogin; diff --git a/src/components/Admin/components/AdminContent.tsx b/src/components/Admin/components/AdminContent.tsx deleted file mode 100644 index 067e21c..0000000 --- a/src/components/Admin/components/AdminContent.tsx +++ /dev/null @@ -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 = ({ - 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(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) => { - 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 ( -
-
-
-

{outputTitle}

- {currentOpKey === 'list-users' && ( - 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)'; - }} - /> - )} -
- - {currentOpKey === 'list-users' && ( - - )} -
-
{outputMeta}
-
- {/* Render React components based on current operation */} - {currentOpKey === 'show-chart' && users && ( - - )} - - {(currentOpKey === 'list-users' || currentOpKey === 'current-user') && ( - - )} - - {currentOpKey === 'list-resources' && ( - - )} - - {/* Fallback for HTML content */} - {output && ( -
- )} -
- {status && ( -
- Status: {status} -
- )} - - {/* Create User Modal */} - { - setIsCreateUserModalOpen(false); - // Reset form when closing - }} - onSubmit={handleCreateUser} - onError={handleCreateUserError} - api={api} - setStatusMessage={setStatusMessage} - /> - - {/* Upload File Modal */} - { - 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 */} - { - 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 */} - -
- ); -}; - -export default AdminContent; diff --git a/src/components/Admin/components/AdminHeader.tsx b/src/components/Admin/components/AdminHeader.tsx deleted file mode 100644 index 760ad7f..0000000 --- a/src/components/Admin/components/AdminHeader.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; - -interface AdminHeaderProps { - onLogout: () => void; -} - -const AdminHeader: React.FC = ({ onLogout }) => { - return ( -
-
-

Azaion Admin

-
- -
-
-
- ); -}; - -export default AdminHeader; - diff --git a/src/components/Admin/components/AdminSidebar.tsx b/src/components/Admin/components/AdminSidebar.tsx deleted file mode 100644 index 0d52e88..0000000 --- a/src/components/Admin/components/AdminSidebar.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import { OPERATIONS_CONFIG } from '../config/constants.ts'; -import { Operation } from '../types'; - -interface AdminSidebarProps { - operations: Record; - currentOpKey: string; - onOpClick: (key: string) => void; -} - -const AdminSidebar: React.FC = ({ operations, currentOpKey, onOpClick }) => { - return ( - - ); -}; - -export default AdminSidebar; diff --git a/src/components/Admin/components/ClearFolderModal.tsx b/src/components/Admin/components/ClearFolderModal.tsx deleted file mode 100644 index c3f5d23..0000000 --- a/src/components/Admin/components/ClearFolderModal.tsx +++ /dev/null @@ -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 = ({ - isOpen, - onClose, - onSuccess, - onError, - api, - setStatusMessage -}) => { - const [folderPath, setFolderPath] = useState(''); - const [isClearing, setIsClearing] = useState(false); - const [confirmText, setConfirmText] = useState(''); - - 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 ( -
-
-
-

- ⚠️ Clear Folder -

- -
- - {/* Warning Message */} -
- ⚠️ -
-
- Warning: This action cannot be undone! -
-
- All files in the specified folder will be permanently deleted. -
-
-
- -
-
- - 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'; - }} - /> -
- -
- - 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'; - }} - /> -
- -
- - -
-
-
-
- ); -}; - -export default ClearFolderModal; - - - diff --git a/src/components/Admin/components/CreateUserModal.tsx b/src/components/Admin/components/CreateUserModal.tsx deleted file mode 100644 index 122c7d8..0000000 --- a/src/components/Admin/components/CreateUserModal.tsx +++ /dev/null @@ -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 = ({ - isOpen, - onClose, - onSubmit, - onError, - api, - setStatusMessage -}) => { - const [formData, setFormData] = useState({ - 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 ( -
-
-
-

- Create New User -

- -
- - -
-
- - 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'; - }} - /> -
- -
- - 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'; - }} - /> -
- -
- - -
- -
- - -
-
-
-
- ); -}; - -export default CreateUserModal; diff --git a/src/components/Admin/components/HardwareCharts.tsx b/src/components/Admin/components/HardwareCharts.tsx deleted file mode 100644 index 174ccd8..0000000 --- a/src/components/Admin/components/HardwareCharts.tsx +++ /dev/null @@ -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 = ({ users }) => { - const arr = Array.isArray(users) ? users : (users ? [users] : []); - if (!arr.length) { - return
No users to chart.
; - } - - // Aggregate hardware data - const cpuMap = new Map(); - const gpuMap = new Map(); - const memMap = new Map(); - - 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): 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 = ({ 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 ( -
-

{title}

-
-
    - {segments.map((s, i) => ( -
  • - - {s.label} - -  — {s.value} ({s.percent}%) - -
  • - ))} -
-
- ); - }; - - return ( -
- - - -
- ); -}; - -export default HardwareCharts; diff --git a/src/components/Admin/components/ListResources.tsx b/src/components/Admin/components/ListResources.tsx deleted file mode 100644 index 63c00f7..0000000 --- a/src/components/Admin/components/ListResources.tsx +++ /dev/null @@ -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 = ({ - api, - setStatusMessage, - setOutputMeta, - updateOutputTitle -}) => { - const [prodResources, setProdResources] = useState(null); - const [stageResources, setStageResources] = useState(null); - const [isLoading, setIsLoading] = useState(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 = {}; - 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 ( -
-
-

- {title} -

- -
- - {resources ? ( - (resources as ResourceData).error ? ( -
-              {html(formatJSON((resources as ResourceData).data || (resources as ResourceData).error))}
-            
- ) : ( -
- {Array.isArray(resources) ? ( - resources.length > 0 ? ( -
    - {resources.map((item, index) => ( -
  • - {typeof item === 'string' ? item : JSON.stringify(item)} -
  • - ))} -
- ) : ( -
- No resources found. -
- ) - ) : ( -
- No resources found. -
- )} -
- ) - ) : ( -
- {isLoading ? 'Loading...' : 'Click Reload to load resources'} -
- )} -
- ); - }; - - return ( -
- {/* Two-column layout for environments */} -
- {renderResourceSection(prodResources, 'Prod Env', 'prod')} - {renderResourceSection(stageResources, 'Stage Env', 'stage')} -
- - {/* Reload Button underneath */} -
- -
-
- ); -}; - -export default ListResources; diff --git a/src/components/Admin/components/NotificationBadge.tsx b/src/components/Admin/components/NotificationBadge.tsx deleted file mode 100644 index ed6100a..0000000 --- a/src/components/Admin/components/NotificationBadge.tsx +++ /dev/null @@ -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 = ({ - 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 ( -
- {icon} -
-
- {isSuccess ? 'Success!' : 'Error!'} -
-
- {message} -
-
- -
- ); -}; - -export default NotificationBadge; - - - diff --git a/src/components/Admin/components/UploadFileModal.tsx b/src/components/Admin/components/UploadFileModal.tsx deleted file mode 100644 index daf3a64..0000000 --- a/src/components/Admin/components/UploadFileModal.tsx +++ /dev/null @@ -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 = ({ - isOpen, - onClose, - onSuccess, - onError, - api, - setStatusMessage -}) => { - const [selectedFile, setSelectedFile] = useState(null); - const [folderPath, setFolderPath] = useState(''); - const [isUploading, setIsUploading] = useState(false); - const fileInputRef = useRef(null); - - const handleFileSelect = (e: React.ChangeEvent) => { - 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 ( -
-
-
-

- Upload File -

- -
- -
-
- - { - 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 && ( -
- Selected: {selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB) -
- )} -
- -
- - 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'; - }} - /> -
- -
- - -
-
-
-
- ); -}; - -export default UploadFileModal; - - - diff --git a/src/components/Admin/components/UsersList.tsx b/src/components/Admin/components/UsersList.tsx deleted file mode 100644 index 790c724..0000000 --- a/src/components/Admin/components/UsersList.tsx +++ /dev/null @@ -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 = ({ - 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
No users found.
; - } - - // 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 ( -
- {/* Header with email and badges */} -
-

- {u.email || 'User'} -

-
-
- {rText && ( - - {rText} - - )} -
- - Last Login: {lastLoginDisplay} - -
-
- - {/* Queue Panel */} - {queueOffset && ( -
-
-
Queue Offset
-
{queueOffset}
-
- -
- )} - - {/* Hardware Panel */} - {hw && ( -
-
-
Hardware
-
- {hw.cpu &&
{hw.cpu}
} - {hw.gpu && ( -
- {hw.gpu} -
- )} - {hw.memory &&
{formatMemoryGB(hw.memory)}
} -
-
- -
- )} - - {/* User Action Buttons */} -
- - - -
-
- ); - })} - - ); -}; - -export default UsersList; diff --git a/src/components/Admin/config/constants.ts b/src/components/Admin/config/constants.ts deleted file mode 100644 index 20ad15b..0000000 --- a/src/components/Admin/config/constants.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Static configurations for the admin dashboard -import { RoleInfo, OperationConfig, RoleOption } from '../types'; - -export const ROLES: Record = { - 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 = { - '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 = { - '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' -]; diff --git a/src/components/Admin/hooks/useAdminOperations.ts b/src/components/Admin/hooks/useAdminOperations.ts deleted file mode 100644 index c7f9d55..0000000 --- a/src/components/Admin/hooks/useAdminOperations.ts +++ /dev/null @@ -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('list-users'); - const [outputTitle, setOutputTitle] = useState('Users'); - const [outputMeta, setOutputMeta] = useState(''); - const [output, setOutput] = useState(''); - const [status, setStatus] = useState(''); - const [users, setUsers] = useState(null); - const [searchValue, setSearchValue] = useState(''); - - let API_BASE = 'https://api.azaion.com'; - let apiResolved = false; - let AUTH_TOKEN = localStorage.getItem('authToken') || ''; - - const resolveApiBase = async (): Promise => { - 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 = { ...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(`
Error: ${e.message}
`); - } - } - }, - '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(`
Error: ${e.message}
`); - } - } - }, - '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(`
Error: ${e.message}
`); - } - } - }, - '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(` -
-

Upload File

-
-

- Click the "Upload File" button to open the upload dialog. -

- -
-
- `); - } - }, - 'clear-folder': { - ...OPERATIONS_CONFIG['clear-folder'], - run: async () => { - setCurrentOpKey('clear-folder'); - updateOutputTitle('clear-folder'); - setUsers(null); - setOutput(` -
-

⚠️ Clear Folder

-
-

- Click the "Clear Folder" button to open the clear folder dialog. -
Warning: This action cannot be undone! -

- -
-
- `); - } - } - }), [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; diff --git a/src/components/Admin/types/index.ts b/src/components/Admin/types/index.ts deleted file mode 100644 index 2f3a963..0000000 --- a/src/components/Admin/types/index.ts +++ /dev/null @@ -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 { - 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; -} - -export interface UseAdminOperationsReturn { - currentOpKey: string; - outputTitle: string; - outputMeta: string; - output: string; - status: string; - users: User[] | null; - searchValue: string; - operations: Record; - api: ApiFunction; - setStatusMessage: (text: string, type?: string) => void; - setOutputMeta: (meta: string) => void; - updateOutputTitle: (opKey: string, extra?: string) => void; - handleUserSearch: (searchEmail: string) => Promise; -} - -export interface ApiFunction { - (path: string, options?: { - method?: string; - json?: any; - formData?: FormData; - headers?: Record; - }): Promise; -} - -export type MemoryThreshold = 'VERY_LARGE' | 'LARGE' | 'MEDIUM'; - -export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; - diff --git a/src/components/Admin/utils/formatters.ts b/src/components/Admin/utils/formatters.ts deleted file mode 100644 index 85c40a2..0000000 --- a/src/components/Admin/utils/formatters.ts +++ /dev/null @@ -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 -} diff --git a/src/components/Admin/utils/parsers.ts b/src/components/Admin/utils/parsers.ts deleted file mode 100644 index fa91141..0000000 --- a/src/components/Admin/utils/parsers.ts +++ /dev/null @@ -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'; - } -}; diff --git a/src/components/AnnotationControls/AnnotationControls.css b/src/components/AnnotationControls/AnnotationControls.css deleted file mode 100644 index 8da947c..0000000 --- a/src/components/AnnotationControls/AnnotationControls.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/components/AnnotationControls/AnnotationControls.js b/src/components/AnnotationControls/AnnotationControls.js deleted file mode 100644 index 4e5363c..0000000 --- a/src/components/AnnotationControls/AnnotationControls.js +++ /dev/null @@ -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 ( -
-
-

{formatDuration(currentTime)}

- - {videoRef.current !== null - ?

{formatDuration(videoRef.current.duration - currentTime)}

- :

{formatDuration(0)}

- } -
- -
- - - - - - - - -
-
- ); -} - -export default AnnotationControls; \ No newline at end of file diff --git a/src/components/AnnotationList/AnnotationList.css b/src/components/AnnotationList/AnnotationList.css deleted file mode 100644 index c5091be..0000000 --- a/src/components/AnnotationList/AnnotationList.css +++ /dev/null @@ -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 ; -} \ No newline at end of file diff --git a/src/components/AnnotationList/AnnotationList.js b/src/components/AnnotationList/AnnotationList.js deleted file mode 100644 index 29efa7b..0000000 --- a/src/components/AnnotationList/AnnotationList.js +++ /dev/null @@ -1,18 +0,0 @@ -import './AnnotationList.css' - -function AnnotationList({ annotations, onAnnotationClick }) { - return ( -
-

Annotations

-
    - {annotations.map((annotation, index) => ( -
  • onAnnotationClick(index)}> - Frame {index + 1} - {annotation.detections.length} objects -
  • - ))} -
-
- ); -} - -export default AnnotationList; \ No newline at end of file diff --git a/src/components/AnnotationMain/AnnotationMain.css b/src/components/AnnotationMain/AnnotationMain.css deleted file mode 100644 index 87005d5..0000000 --- a/src/components/AnnotationMain/AnnotationMain.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/components/AnnotationMain/AnnotationMain.js b/src/components/AnnotationMain/AnnotationMain.js deleted file mode 100644 index 436e1b0..0000000 --- a/src/components/AnnotationMain/AnnotationMain.js +++ /dev/null @@ -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 ( -
-
- - - -
- -
- {errorMessage && ( -
- {errorMessage} -
- )} - -
-
- - - -
- - -
-
- -
- -
-
- ); -} - -export default AnnotationMain; \ No newline at end of file diff --git a/src/components/CanvasEditor/CanvasEditor.css b/src/components/CanvasEditor/CanvasEditor.css deleted file mode 100644 index 57a20cc..0000000 --- a/src/components/CanvasEditor/CanvasEditor.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/components/CanvasEditor/CanvasEditor.js b/src/components/CanvasEditor/CanvasEditor.js deleted file mode 100644 index d779f9b..0000000 --- a/src/components/CanvasEditor/CanvasEditor.js +++ /dev/null @@ -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 ( -
-
- {children} - -
-
- ); -} - -export default CanvasEditor; \ No newline at end of file diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..fe88333 --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -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(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 ( +
+
+

{title}

+ {message &&

{message}

} +
+ + +
+
+
+ ) +} diff --git a/src/components/Detection.js b/src/components/Detection.js deleted file mode 100644 index c1e0f1f..0000000 --- a/src/components/Detection.js +++ /dev/null @@ -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 ( -
- {isSelected && resizeHandles.map((handle) => ( -
handleResizeMouseDown(e, handle.position)} - /> - ))} - - {detection.class.Name} {detectionType !== detectionTypes.day && '(' + detectionType + ')'} - -
- ); -} - -export default Detection; \ No newline at end of file diff --git a/src/components/DetectionClassList/DetectionClassList.css b/src/components/DetectionClassList/DetectionClassList.css deleted file mode 100644 index 0b642bb..0000000 --- a/src/components/DetectionClassList/DetectionClassList.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/components/DetectionClassList/DetectionClassList.js b/src/components/DetectionClassList/DetectionClassList.js deleted file mode 100644 index a8d68a0..0000000 --- a/src/components/DetectionClassList/DetectionClassList.js +++ /dev/null @@ -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 ( -
- -
-

Classes

-
    - {detectionClasses.map((cls) => { - const backgroundColor = calculateColor(cls.Id); - const darkBg = calculateColor(cls.Id, '0.8'); - const isSelected = selectedClass && selectedClass.Id === cls.Id; - - return ( -
  • handleClassClick(cls)} - > - {cls.Name} -
  • - ); - })} -
-
- -
- - - - - -
-
- ); -} - -export default DetectionClassList; \ No newline at end of file diff --git a/src/components/DetectionClasses.tsx b/src/components/DetectionClasses.tsx new file mode 100644 index 0000000..744749c --- /dev/null +++ b/src/components/DetectionClasses.tsx @@ -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([]) + + useEffect(() => { + api.get('/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 ( +
+
{t('annotations.classes')}
+
+ {modes.map(m => ( + + ))} +
+
+ {classes.filter(c => c.photoMode === photoMode).map((c, i) => ( + + ))} +
+
+ ) +} diff --git a/src/components/DetectionContainer.js b/src/components/DetectionContainer.js deleted file mode 100644 index 1c64e1c..0000000 --- a/src/components/DetectionContainer.js +++ /dev/null @@ -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) => ( - onDetectionMouseDown(e, index)} - onResize={(e, position) => onResize(e, index, position)} - detectionType={detectionType} - /> - ))} - {currentDetection && ( - {}} // No-op handler for the current detection - onResize={() => {}} // No-op handler for the current detection - detectionType={detectionType} - /> - )} - - ); -} - -export default DetectionContainer; \ No newline at end of file diff --git a/src/components/FlightContext.tsx b/src/components/FlightContext.tsx new file mode 100644 index 0000000..33e8728 --- /dev/null +++ b/src/components/FlightContext.tsx @@ -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 +} + +const FlightContext = createContext(null!) + +export function useFlight() { + return useContext(FlightContext) +} + +export function FlightProvider({ children }: { children: ReactNode }) { + const [flights, setFlights] = useState([]) + const [selectedFlight, setSelectedFlight] = useState(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('/api/annotations/settings/user') + .then(settings => { + if (settings?.selectedFlightId) { + api.get(`/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 ( + + {children} + + ) +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..2eda074 --- /dev/null +++ b/src/components/Header.tsx @@ -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(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 ( +
+ AZAION + +
+ + {showDropdown && ( +
+ setFilter(e.target.value)} + autoFocus + /> +
+ {filtered.map((f: Flight) => ( + + ))} + {filtered.length === 0 && ( +
No flights
+ )} +
+
+ )} +
+ + + +
+ + {user?.email} + + + + + + {/* Mobile bottom nav */} + + setShowHelp(false)} /> +
+ ) +} diff --git a/src/components/HelpModal.tsx b/src/components/HelpModal.tsx new file mode 100644 index 0000000..63282ad --- /dev/null +++ b/src/components/HelpModal.tsx @@ -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 ( +
+
e.stopPropagation()}> +

How to Annotate

+
    + {GUIDELINES.map((g, i) => ( +
  1. + {i + 1}. + {g[lang]} +
  2. + ))} +
+ +

Keyboard Shortcuts

+
+ SpacePlay / Pause + ← →Frame step + Ctrl + ← →5 second skip + EnterSave annotation + DeleteDelete selected + XDelete all detections + 1-9Select detection class + MMute / Unmute + Ctrl + ScrollZoom canvas + EscClose dialog / editor + VValidate (Dataset) + PageUp/DownNavigate media / pages +
+ +
+ +
+
+
+ ) +} diff --git a/src/components/MediaList/MediaList.css b/src/components/MediaList/MediaList.css deleted file mode 100644 index bc70a8e..0000000 --- a/src/components/MediaList/MediaList.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/components/MediaList/MediaList.js b/src/components/MediaList/MediaList.js deleted file mode 100644 index fc66f85..0000000 --- a/src/components/MediaList/MediaList.js +++ /dev/null @@ -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 ( -
-
-

Files

- - -
- -
    - {filteredFiles.map((file) => ( -
  • onFileSelect(file)} - > - {file.name} -
  • - ))} -
-
- -
- -
- {isDragActive ? ( -

Drop here

- ) : ( -

Drag new files

- )} -
-
- ); -} - -export default MediaList; \ No newline at end of file diff --git a/src/components/VideoPlayer/VideoPlayer.css b/src/components/VideoPlayer/VideoPlayer.css deleted file mode 100644 index 4d1c8c0..0000000 --- a/src/components/VideoPlayer/VideoPlayer.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/components/VideoPlayer/VideoPlayer.js b/src/components/VideoPlayer/VideoPlayer.js deleted file mode 100644 index 4ec95bf..0000000 --- a/src/components/VideoPlayer/VideoPlayer.js +++ /dev/null @@ -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 ( -
-
- ); -} - -export default VideoPlayer; \ No newline at end of file diff --git a/src/constants/detectionTypes.js b/src/constants/detectionTypes.js deleted file mode 100644 index f5625c5..0000000 --- a/src/constants/detectionTypes.js +++ /dev/null @@ -1,5 +0,0 @@ -export const detectionTypes = { - day: 'day', - night: 'night', - winter: 'winter' -} \ No newline at end of file diff --git a/src/features/admin/AdminPage.tsx b/src/features/admin/AdminPage.tsx new file mode 100644 index 0000000..711c78f --- /dev/null +++ b/src/features/admin/AdminPage.tsx @@ -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([]) + const [aircrafts, setAircrafts] = useState([]) + const [users, setUsers] = useState([]) + const [newClass, setNewClass] = useState({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 }) + const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'Annotator' }) + const [deactivateId, setDeactivateId] = useState(null) + + useEffect(() => { + api.get('/api/annotations/classes').then(setClasses).catch(() => {}) + api.get('/api/flights/aircrafts').then(setAircrafts).catch(() => {}) + api.get('/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('/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('/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 ( +
+ {/* Detection classes */} +
+

{t('admin.classes')}

+
+ + + + + + + + + + + {classes.map(c => ( + + + + + + + ))} + +
#NameColor
{c.id}{c.name}
+
+ 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" /> + setNewClass(p => ({ ...p, color: e.target.value }))} className="w-8 h-7 border-0 bg-transparent cursor-pointer" /> + +
+
+
+ + {/* Center: AI + GPS settings */} +
+
+

{t('admin.aiSettings')}

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

{t('admin.gpsSettings')}

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + {/* Users */} +
+

{t('admin.users')}

+
+ + + + + + + + + + + + {users.map(u => ( + + + + + + + + ))} + +
NameEmailRoleStatus
{u.name}{u.email}{u.role} + + {u.isActive ? 'Active' : 'Inactive'} + + + {u.isActive && ( + + )} +
+
+ 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" /> + 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" /> + 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" /> + + +
+
+
+
+ + {/* Aircrafts sidebar */} +
+

{t('admin.aircrafts')}

+
+ {aircrafts.map(a => ( +
handleToggleDefault(a)} className="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-az-bg text-xs text-az-text"> + + {a.type === 'Plane' ? 'P' : 'C'} + + {a.model} + +
+ ))} +
+
+ + setDeactivateId(null)} + /> +
+ ) +} diff --git a/src/features/annotations/AnnotationsPage.tsx b/src/features/annotations/AnnotationsPage.tsx new file mode 100644 index 0000000..c04fc02 --- /dev/null +++ b/src/features/annotations/AnnotationsPage.tsx @@ -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(null) + const [currentTime, setCurrentTime] = useState(0) + const [annotations, setAnnotations] = useState([]) + const [selectedAnnotation, setSelectedAnnotation] = useState(null) + const [selectedClassNum, setSelectedClassNum] = useState(0) + const [photoMode, setPhotoMode] = useState(0) + const [detections, setDetections] = useState([]) + 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 ( +
+ {/* Left panel */} +
+ + +
+
+ + {/* Center - video/canvas */} +
+ {selectedMedia && isVideo && ( + + )} + {selectedMedia && ( + + )} + {!selectedMedia && ( +
+ Select a media file to start +
+ )} +
+ + {/* Right panel */} +
+
+ +
+
+ ) +} diff --git a/src/features/annotations/AnnotationsSidebar.tsx b/src/features/annotations/AnnotationsSidebar.tsx new file mode 100644 index 0000000..624acb3 --- /dev/null +++ b/src/features/annotations/AnnotationsSidebar.tsx @@ -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([]) + + useEffect(() => { + if (!media) return + return createSSE<{ annotationId: string; mediaId: string; status: number }>('/api/annotations/annotations/events', (event) => { + if (event.mediaId === media.id) { + api.get>( + `/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 = {} + 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 ( +
+
+ {t('annotations.title')} + +
+ +
+ {annotations.map(ann => ( +
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) }} + > +
+ {ann.time || '—'} + {ann.detections.length > 0 ? ann.detections[0].label : '—'} +
+
+ ))} + {annotations.length === 0 && ( +
{t('common.noData')}
+ )} +
+ + {detecting && ( +
+
+

{t('annotations.detect')}

+
+ {detectLog.map((line, i) =>
{line}
)} +
+ +
+
+ )} +
+ ) +} diff --git a/src/features/annotations/CanvasEditor.tsx b/src/features/annotations/CanvasEditor.tsx new file mode 100644 index 0000000..284980d --- /dev/null +++ b/src/features/annotations/CanvasEditor.tsx @@ -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 = { + 0: '#FFD700', + 1: '#228be6', + 2: '#fa5252', +} + +export default function CanvasEditor({ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations }: Props) { + const canvasRef = useRef(null) + const containerRef = useRef(null) + const imgRef = useRef(null) + const [zoom, setZoom] = useState(1) + const [pan, setPan] = useState({ x: 0, y: 0 }) + const [selected, setSelected] = useState>(new Set()) + const [dragState, setDragState] = useState(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 ( +
+ +
+ ) +} diff --git a/src/features/annotations/MediaList.tsx b/src/features/annotations/MediaList.tsx new file mode 100644 index 0000000..08a6a0e --- /dev/null +++ b/src/features/annotations/MediaList.tsx @@ -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([]) + const [filter, setFilter] = useState('') + const debouncedFilter = useDebounce(filter, 300) + const [deleteId, setDeleteId] = useState(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>(`/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>( + `/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) => { + 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 ( +
{ e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + > +
+ 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" + /> + +
+
+ {media.map(m => ( +
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`} + > + + {m.mediaType === 2 ? 'V' : 'P'} + + {m.name} + {m.duration && {m.duration}} +
+ ))} +
+ setDeleteId(null)} + /> +
+ ) +} diff --git a/src/features/annotations/VideoPlayer.tsx b/src/features/annotations/VideoPlayer.tsx new file mode 100644 index 0000000..e525b29 --- /dev/null +++ b/src/features/annotations/VideoPlayer.tsx @@ -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(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 ( +
+