feat: setup dataset enrichment app codebase and scripts
This commit is contained in:
1 parent
b5c28cc98a
commit
d07578462e
72 files changed
+11370
No files matched your search
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234c8dff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<title>Dataset Enrichment</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# A base model upload is a whole .pt checkpoint; the default 1 MB would
|
||||
# reject every one of them.
|
||||
client_max_body_size 512m;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Video seeking and job logs both want bytes as they come, not after
|
||||
# nginx has collected a full buffer.
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
Generated
+1665
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,143 @@
|
||||
import { Component, useEffect, useState } from 'react'
|
||||
import { api } from './api'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import ProjectsPage from './pages/ProjectsPage'
|
||||
import LibraryPage from './pages/LibraryPage'
|
||||
import TrimPage from './pages/TrimPage'
|
||||
import ReviewPage from './pages/ReviewPage'
|
||||
import ModelsPage from './pages/ModelsPage'
|
||||
import './roboflow.css'
|
||||
|
||||
function parseRoute(hash) {
|
||||
const rawPath = (hash || '').replace(/^#/, '') || '/projects'
|
||||
const [path, queryString] = rawPath.split('?')
|
||||
const query = new URLSearchParams(queryString || '')
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
|
||||
if (parts[0] === 'batches' && parts[1]) {
|
||||
return { name: 'review', batchId: Number(parts[1]) }
|
||||
}
|
||||
|
||||
if (parts[0] === 'projects' && parts[1]) {
|
||||
const projectId = Number(parts[1])
|
||||
if (parts[2] === 'trim' && parts[3]) {
|
||||
return { name: 'trim', projectId, rel: decodeURIComponent(parts[3]) }
|
||||
}
|
||||
if (parts[2] === 'models') return { name: 'models', projectId }
|
||||
if (parts[2] === 'review') {
|
||||
const batchId = query.get('batch') ? Number(query.get('batch')) : null
|
||||
return { name: 'review', projectId, batchId }
|
||||
}
|
||||
return { name: 'library', projectId }
|
||||
}
|
||||
|
||||
return { name: 'projects' }
|
||||
}
|
||||
|
||||
export function navigate(path) {
|
||||
window.location.hash = path
|
||||
}
|
||||
|
||||
function useRoute() {
|
||||
const [route, setRoute] = useState(() => parseRoute(window.location.hash))
|
||||
useEffect(() => {
|
||||
const onChange = () => setRoute(parseRoute(window.location.hash))
|
||||
window.addEventListener('hashchange', onChange)
|
||||
return () => window.removeEventListener('hashchange', onChange)
|
||||
}, [])
|
||||
return route
|
||||
}
|
||||
|
||||
function useTheme() {
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark')
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme])
|
||||
return [theme, () => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))]
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('UI Exception:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: '#f3f4f6' }}>
|
||||
<h2>View Exception Caught</h2>
|
||||
<p style={{ color: '#ef4444', margin: '12px 0' }}>{this.state.error?.toString()}</p>
|
||||
<button className="btn btn-primary" onClick={() => window.location.reload()}>
|
||||
Reload Application
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const route = useRoute()
|
||||
const [theme, toggleTheme] = useTheme()
|
||||
const [currentProject, setCurrentProject] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (route.projectId) {
|
||||
api.getProject(route.projectId).then(setCurrentProject).catch(() => {})
|
||||
} else if (!currentProject) {
|
||||
api.listProjects().then((list) => {
|
||||
if (list.length > 0) setCurrentProject(list[0])
|
||||
}).catch(() => {})
|
||||
}
|
||||
}, [route.projectId, route.name, currentProject])
|
||||
|
||||
|
||||
return (
|
||||
<div className="roboflow-layout">
|
||||
<Sidebar
|
||||
route={route}
|
||||
currentProject={currentProject}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
/>
|
||||
|
||||
<main className="roboflow-main">
|
||||
<ErrorBoundary key={route.name + (route.batchId || route.projectId || '')}>
|
||||
{route.name === 'projects' && <ProjectsPage />}
|
||||
{route.name === 'library' && (
|
||||
<LibraryPage
|
||||
projectId={route.projectId}
|
||||
onProject={(p) => setCurrentProject(p)}
|
||||
/>
|
||||
)}
|
||||
{route.name === 'trim' && <TrimPage projectId={route.projectId} rel={route.rel} />}
|
||||
{route.name === 'review' && (
|
||||
<ReviewPage
|
||||
batchId={route.batchId}
|
||||
projectId={route.projectId}
|
||||
onProject={(p) => setCurrentProject(p)}
|
||||
/>
|
||||
)}
|
||||
{route.name === 'models' && (
|
||||
<ModelsPage
|
||||
projectId={route.projectId}
|
||||
onProject={(p) => setCurrentProject(p)}
|
||||
/>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/* One place that knows how to talk to the backend.
|
||||
*
|
||||
* FastAPI reports failures as {"detail": "..."} — unwrapped here so callers can
|
||||
* show the message the backend actually wrote instead of "500". */
|
||||
|
||||
async function request(path, { method = 'GET', body, form } = {}) {
|
||||
const options = { method, headers: {} }
|
||||
if (form) {
|
||||
options.body = form
|
||||
} else if (body !== undefined) {
|
||||
options.headers['Content-Type'] = 'application/json'
|
||||
options.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api${path}`, options)
|
||||
const text = await response.text()
|
||||
const payload = text ? JSON.parse(text) : null
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = payload?.detail
|
||||
throw new Error(typeof detail === 'string' ? detail : `${response.status} ${response.statusText}`)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () => request('/health'),
|
||||
|
||||
listProjects: () => request('/projects'),
|
||||
getProject: (id) => request(`/projects/${id}`),
|
||||
createProject: (body) => request('/projects', { method: 'POST', body }),
|
||||
patchProject: (id, body) => request(`/projects/${id}`, { method: 'PATCH', body }),
|
||||
deleteProject: (id) => request(`/projects/${id}`, { method: 'DELETE' }),
|
||||
addClass: (id, body) => request(`/projects/${id}/classes`, { method: 'POST', body }),
|
||||
deleteClass: (id, classId) =>
|
||||
request(`/projects/${id}/classes/${classId}`, { method: 'DELETE' }),
|
||||
uploadBaseModel: (id, file) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request(`/projects/${id}/base-model`, { method: 'POST', form })
|
||||
},
|
||||
uploadSecondaryModel: (id, file) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request(`/projects/${id}/secondary-model`, { method: 'POST', form })
|
||||
},
|
||||
|
||||
listDates: (id) => request(`/projects/${id}/library`),
|
||||
listVideos: (id, date) => request(`/projects/${id}/library/${encodeURIComponent(date)}`),
|
||||
videoInfo: (id, rel) => request(`/projects/${id}/video/info?rel=${encodeURIComponent(rel)}`),
|
||||
videoUrl: (id, rel) => `/api/projects/${id}/video?rel=${encodeURIComponent(rel)}`,
|
||||
|
||||
createBatch: (id, body) => request(`/projects/${id}/batches`, { method: 'POST', body }),
|
||||
listBatches: (id) => request(`/projects/${id}/batches`),
|
||||
getBatch: (id) => request(`/batches/${id}`),
|
||||
patchBatch: (id, body) => request(`/batches/${id}`, { method: 'PATCH', body }),
|
||||
deleteBatch: (id) => request(`/batches/${id}`, { method: 'DELETE' }),
|
||||
listFrames: (id) => request(`/batches/${id}/frames`),
|
||||
frameUrl: (id, width) => `/api/frames/${id}/image${width ? `?w=${width}` : ''}`,
|
||||
|
||||
startAutolabel: (batchId, body) =>
|
||||
request(`/batches/${batchId}/autolabel`, { method: 'POST', body: body ?? {} }),
|
||||
clearBatchClassAnnotations: (batchId, classId) =>
|
||||
request(`/batches/${batchId}/classes/${classId}/annotations`, { method: 'DELETE' }),
|
||||
nextPending: (batchId, afterIdx = -1) =>
|
||||
request(`/batches/${batchId}/next-pending?after_idx=${afterIdx}`),
|
||||
|
||||
frameAnnotations: (frameId) => request(`/frames/${frameId}/annotations`),
|
||||
addAnnotation: (frameId, body) =>
|
||||
request(`/frames/${frameId}/annotations`, { method: 'POST', body }),
|
||||
patchAnnotation: (id, body) => request(`/annotations/${id}`, { method: 'PATCH', body }),
|
||||
deleteAnnotation: (id) => request(`/annotations/${id}`, { method: 'DELETE' }),
|
||||
assist: (frameId, body) => request(`/frames/${frameId}/assist`, { method: 'POST', body }),
|
||||
setFrameStatus: (frameId, status) =>
|
||||
request(`/frames/${frameId}/status`, { method: 'POST', body: { status } }),
|
||||
|
||||
approveAllBatchFrames: (batchId) => request(`/batches/${batchId}/approve-all`, { method: 'POST' }),
|
||||
approveBatch: (batchId) => request(`/batches/${batchId}/approve`, { method: 'POST' }),
|
||||
datasetSummary: (projectId) => request(`/projects/${projectId}/dataset`),
|
||||
datasetDownloadUrl: (projectId) => `/api/projects/${projectId}/dataset/download`,
|
||||
|
||||
hardware: () => request('/hardware'),
|
||||
startTraining: (projectId, body) =>
|
||||
request(`/projects/${projectId}/train`, { method: 'POST', body }),
|
||||
listModels: (projectId) => request(`/projects/${projectId}/models`),
|
||||
promoteModel: (modelId) => request(`/models/${modelId}/promote`, { method: 'POST' }),
|
||||
weightsUrl: (modelId) => `/api/models/${modelId}/weights`,
|
||||
|
||||
listJobs: (projectId) => request(`/jobs${projectId ? `?project_id=${projectId}` : ''}`),
|
||||
getJob: (id) => request(`/jobs/${id}`),
|
||||
cancelJob: (id) => request(`/jobs/${id}/cancel`, { method: 'POST' }),
|
||||
}
|
||||
|
||||
export function classColor(classId) {
|
||||
return `var(--class-${classId % 8})`
|
||||
}
|
||||
|
||||
export function formatDuration(seconds) {
|
||||
// 0 is a real timestamp — a trim starting at the first frame reads 0:00, not a dash.
|
||||
if (seconds == null || Number.isNaN(seconds)) return '—'
|
||||
const total = Math.round(seconds)
|
||||
const h = Math.floor(total / 3600)
|
||||
const m = Math.floor((total % 3600) / 60)
|
||||
const s = total % 60
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
.app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0b0f19;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 52px;
|
||||
margin: 10px 14px 0 14px;
|
||||
padding: 0 18px;
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.topbar .brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.topbar .brand svg { color: var(--accent); }
|
||||
|
||||
.topbar .spacer { flex: 1; }
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.crumbs svg { color: var(--accent); flex: none; }
|
||||
|
||||
.crumbs .current {
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.health {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.dot.ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
|
||||
.dot.bad { background: var(--danger); box-shadow: 0 0 6px var(--danger); }
|
||||
|
||||
main.page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: calc(var(--space) * 2);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.page-head .spacer { flex: 1; }
|
||||
|
||||
/* --- project cards ---------------------------------------------------- */
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
transition: border-color var(--transition), transform var(--transition),
|
||||
box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.project-card .title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.project-card .title-row h2 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-card dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 12px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-card dt { color: var(--text-faint); }
|
||||
.project-card dd { margin: 0; color: var(--text-muted); }
|
||||
|
||||
.project-card .classes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.class-tag { padding-right: 3px; }
|
||||
|
||||
.chip-x {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.chip-x:hover:not(:disabled) {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.project-card .actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-card .actions .spacer { flex: 1; }
|
||||
|
||||
/* --- forms ------------------------------------------------------------ */
|
||||
|
||||
.form-panel {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.class-rows {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.class-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 1fr 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* --- library ---------------------------------------------------------- */
|
||||
|
||||
.library {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
height: calc(100vh - 120px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.date-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px;
|
||||
gap: 2px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.date-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.date-item:hover { background: var(--panel-raised); color: var(--text); }
|
||||
|
||||
.date-item[aria-current='true'] {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(168, 85, 247, 0.4);
|
||||
}
|
||||
|
||||
.date-item .count { margin-left: auto; font-size: 12px; color: var(--text-faint); }
|
||||
|
||||
.video-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.video-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.video-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.video-table tbody tr {
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.video-table tbody tr:hover { background: var(--panel-raised); }
|
||||
.video-table td:first-child { color: var(--text); font-weight: 500; }
|
||||
.video-table td.num { font-family: var(--mono); font-size: 12px; }
|
||||
|
||||
.table-wrap {
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* --- trim ------------------------------------------------------------- */
|
||||
|
||||
.trim {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
height: calc(100vh - 120px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trim-player {
|
||||
padding: 8px;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trim-player video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.trim-controls {
|
||||
padding: 16px;
|
||||
position: sticky;
|
||||
top: 68px;
|
||||
}
|
||||
|
||||
.range-group {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.range-group input[type='range'] {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
height: 22px;
|
||||
accent-color: var(--accent);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.range-group input[type='range']:focus { box-shadow: none; }
|
||||
|
||||
.job-status {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-raised);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width var(--transition);
|
||||
}
|
||||
|
||||
/* --- review ----------------------------------------------------------- */
|
||||
|
||||
.panel {
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.review {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.review-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.canvas-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin: 0 auto;
|
||||
background: #030712;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
line-height: 0;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.canvas-wrap img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.canvas-wrap svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.canvas-wrap svg.assist { cursor: copy; }
|
||||
|
||||
.canvas-wrap .shape rect,
|
||||
.canvas-wrap .shape polygon {
|
||||
fill: rgba(255, 255, 255, 0.05);
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
|
||||
.canvas-wrap .shape:hover rect,
|
||||
.canvas-wrap .shape:hover polygon { stroke-width: 3; }
|
||||
|
||||
@keyframes shape-march {
|
||||
to {
|
||||
stroke-dashoffset: -20;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-wrap .shape.selected rect,
|
||||
.canvas-wrap .shape.selected polygon {
|
||||
stroke-width: 3.5;
|
||||
stroke-dasharray: 8 4;
|
||||
animation: shape-march 0.8s linear infinite;
|
||||
fill: rgba(255, 255, 255, 0.22);
|
||||
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.85));
|
||||
}
|
||||
|
||||
.canvas-wrap .handle {
|
||||
cursor: nwse-resize;
|
||||
stroke: #ffffff;
|
||||
stroke-width: 2;
|
||||
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.9));
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.canvas-wrap .handle-ne, .canvas-wrap .handle-sw { cursor: nesw-resize; }
|
||||
.canvas-wrap .handle-vertex, .canvas-wrap .handle-midpoint { cursor: pointer; }
|
||||
|
||||
.canvas-wrap .draft {
|
||||
fill: rgba(255, 255, 255, 0.08);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 4;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.frame-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.frame-bar .spacer { flex: 1; }
|
||||
|
||||
.status-pill {
|
||||
margin-left: 8px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font);
|
||||
background: var(--panel-raised);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-pill.approved { background: rgba(16, 185, 129, 0.2); color: var(--ok); border: 1px solid rgba(16, 185, 129, 0.4); }
|
||||
.status-pill.rejected { background: var(--danger-soft); color: var(--danger); border: 1px solid rgba(239, 68, 68, 0.4); }
|
||||
|
||||
.filmstrip {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding: 8px;
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius);
|
||||
scrollbar-width: thin;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 92px;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
overflow: hidden;
|
||||
line-height: 0;
|
||||
transition: border-color var(--transition), opacity var(--transition);
|
||||
}
|
||||
|
||||
.thumb img { width: 100%; display: block; }
|
||||
.thumb.approved { border-color: var(--ok); }
|
||||
.thumb.rejected { border-color: var(--danger); opacity: 0.45; }
|
||||
.thumb:hover { border-color: var(--text-faint); }
|
||||
.thumb[aria-current='true'] { border-color: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
||||
|
||||
.thumb .badge {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.review-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.side-panel { padding: 12px 14px; display: grid; gap: 10px; }
|
||||
|
||||
.class-list, .shape-list { display: grid; gap: 4px; }
|
||||
|
||||
.class-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.class-chip:hover { background: var(--panel-raised); color: var(--text); }
|
||||
|
||||
.class-chip.active {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.class-chip .mono { margin-left: auto; }
|
||||
|
||||
.shape-list { list-style: none; margin: 0; padding: 0; }
|
||||
|
||||
.shape-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.shape-list li.selected { background: var(--accent-soft); }
|
||||
|
||||
.shape-pick {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.shape-pick:hover { color: var(--text); }
|
||||
.shape-pick .mono { margin-left: auto; }
|
||||
|
||||
.metrics td.better { color: var(--ok); }
|
||||
.metrics td.worse { color: var(--danger); }
|
||||
|
||||
.job-log {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.shortcuts { margin: 0; display: grid; gap: 5px; font-size: 12px; }
|
||||
.shortcuts > div { display: flex; gap: 10px; align-items: baseline; }
|
||||
.shortcuts dt { flex: none; width: 62px; }
|
||||
.shortcuts dd { margin: 0; color: var(--text-muted); }
|
||||
|
||||
kbd {
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
background: var(--panel-raised);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.trim { grid-template-columns: 1fr; }
|
||||
.trim-controls { position: static; }
|
||||
.review { grid-template-columns: 1fr; }
|
||||
.review-side { position: static; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.library { grid-template-columns: 1fr; }
|
||||
.date-list { position: static; flex-direction: row; flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.topbar .crumbs { display: none; }
|
||||
main.page { padding: 16px 12px 40px; }
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { classColor } from '../api'
|
||||
import Shape from './Shape'
|
||||
|
||||
/* The annotation surface: the frame with an SVG overlay on top.
|
||||
*
|
||||
* SVG rather than <canvas> on purpose — shapes are elements, so selection,
|
||||
* hover and focus come from the DOM instead of hand-written hit-testing, and
|
||||
* the whole thing stays keyboard-reachable.
|
||||
*
|
||||
* Geometry is normalized 0–1 everywhere; only the handle size is converted to
|
||||
* frame units, so it stays the same physical size at any zoom. */
|
||||
|
||||
const HANDLE_PX = 12
|
||||
const MIN_SIZE = 0.004
|
||||
|
||||
const CORNERS = [
|
||||
['nw', 0, 0], ['ne', 1, 0], ['se', 1, 1], ['sw', 0, 1],
|
||||
]
|
||||
|
||||
function boxPoints(geometry) {
|
||||
return geometry.type === 'bbox'
|
||||
? geometry.points
|
||||
: (() => {
|
||||
const xs = geometry.points.map((p) => p[0])
|
||||
const ys = geometry.points.map((p) => p[1])
|
||||
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]
|
||||
})()
|
||||
}
|
||||
|
||||
function normalise([x0, y0, x1, y1]) {
|
||||
return [Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1)]
|
||||
}
|
||||
|
||||
export default function AnnotationCanvas({
|
||||
frame, imageUrl, annotations, selectedId, activeClass, assistMode, classes,
|
||||
onSelect, onCreate, onUpdate, onAssist,
|
||||
}) {
|
||||
const wrapRef = useRef(null)
|
||||
const svgRef = useRef(null)
|
||||
const [displayWidth, setDisplayWidth] = useState(0)
|
||||
const [draft, setDraft] = useState(null) // box being drawn
|
||||
const [drag, setDrag] = useState(null) // move/resize in progress
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = wrapRef.current
|
||||
if (!element) return
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
setDisplayWidth(entry.contentRect.width)
|
||||
})
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const width = frame?.width || 1
|
||||
const height = frame?.height || 1
|
||||
const scale = displayWidth ? width / displayWidth : 1
|
||||
const handle = HANDLE_PX * scale
|
||||
|
||||
function pointAt(event) {
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
return [
|
||||
Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
|
||||
Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
|
||||
]
|
||||
}
|
||||
|
||||
function startDraw(event) {
|
||||
if (event.button !== 0) return
|
||||
const [x, y] = pointAt(event)
|
||||
onSelect(null)
|
||||
setDraft([x, y, x, y])
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function startMove(event, annotation) {
|
||||
event.stopPropagation()
|
||||
const [x, y] = pointAt(event)
|
||||
onSelect(annotation.id)
|
||||
setDrag({ kind: 'move', id: annotation.id, origin: [x, y],
|
||||
start: annotation.geometry })
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function startResize(event, annotation, corner) {
|
||||
event.stopPropagation()
|
||||
onSelect(annotation.id)
|
||||
setDrag({ kind: 'resize', id: annotation.id, corner,
|
||||
start: annotation.geometry })
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function startVertex(event, annotation, pointIndex) {
|
||||
event.stopPropagation()
|
||||
onSelect(annotation.id)
|
||||
setDrag({ kind: 'vertex', id: annotation.id, pointIndex, start: annotation.geometry })
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function startMidpoint(event, annotation, afterIndex, newPoint) {
|
||||
event.stopPropagation()
|
||||
onSelect(annotation.id)
|
||||
const newPoints = [...annotation.geometry.points]
|
||||
newPoints.splice(afterIndex + 1, 0, newPoint)
|
||||
const newGeom = { type: 'polygon', points: newPoints }
|
||||
onUpdate(annotation.id, newGeom, { local: true })
|
||||
setDrag({ kind: 'vertex', id: annotation.id, pointIndex: afterIndex + 1, start: newGeom })
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function deleteVertex(annotation, pointIndex) {
|
||||
if (annotation.geometry.points.length <= 3) return
|
||||
const newPoints = annotation.geometry.points.filter((_, i) => i !== pointIndex)
|
||||
onUpdate(annotation.id, { type: 'polygon', points: newPoints }, { commit: true })
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (draft) {
|
||||
const [x, y] = pointAt(event)
|
||||
setDraft([draft[0], draft[1], x, y])
|
||||
return
|
||||
}
|
||||
if (!drag) return
|
||||
const [x, y] = pointAt(event)
|
||||
|
||||
if (drag.kind === 'vertex') {
|
||||
const points = drag.start.points.map((p, i) => (i === drag.pointIndex ? [x, y] : p))
|
||||
onUpdate(drag.id, { type: 'polygon', points }, { local: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (drag.kind === 'move') {
|
||||
const [dx, dy] = [x - drag.origin[0], y - drag.origin[1]]
|
||||
onUpdate(drag.id, shift(drag.start, dx, dy), { local: true })
|
||||
} else {
|
||||
const [x0, y0, x1, y1] = boxPoints(drag.start)
|
||||
const next = drag.corner === 'nw' ? [x, y, x1, y1]
|
||||
: drag.corner === 'ne' ? [x0, y, x, y1]
|
||||
: drag.corner === 'se' ? [x0, y0, x, y]
|
||||
: [x, y0, x1, y]
|
||||
onUpdate(drag.id, { type: 'bbox', points: normalise(next) }, { local: true })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function onPointerUp() {
|
||||
if (draft) {
|
||||
const [x0, y0, x1, y1] = normalise(draft)
|
||||
setDraft(null)
|
||||
if (x1 - x0 >= MIN_SIZE && y1 - y0 >= MIN_SIZE) {
|
||||
if (assistMode) onAssist([x0, y0, x1, y1])
|
||||
else onCreate({ type: 'bbox', points: [x0, y0, x1, y1] })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (drag) {
|
||||
onUpdate(drag.id, null, { commit: true })
|
||||
setDrag(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Escape cancels whatever gesture is in flight rather than committing it.
|
||||
useEffect(() => {
|
||||
const onKey = (event) => {
|
||||
if (event.key !== 'Escape') return
|
||||
setDraft(null)
|
||||
setDrag(null)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
// Bound the height by bounding the width at the frame's aspect ratio: a
|
||||
// portrait frame would otherwise be three screens tall, and constraining the
|
||||
// image itself would leave the SVG overlay misaligned with it.
|
||||
return (
|
||||
<div
|
||||
className="canvas-wrap"
|
||||
ref={wrapRef}
|
||||
style={{ maxWidth: `calc(72vh * ${width} / ${height})` }}
|
||||
>
|
||||
<img src={imageUrl} alt={`Frame ${frame?.idx ?? ''}`} draggable={false} />
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className={assistMode ? 'assist' : undefined}
|
||||
onPointerDown={startDraw}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{annotations.map((annotation) => (
|
||||
<Shape
|
||||
key={annotation.id}
|
||||
annotation={annotation}
|
||||
width={width}
|
||||
height={height}
|
||||
scale={scale}
|
||||
handle={handle}
|
||||
selected={annotation.id === selectedId}
|
||||
classes={classes}
|
||||
onStartMove={startMove}
|
||||
onStartResize={startResize}
|
||||
onStartVertex={startVertex}
|
||||
onStartMidpoint={startMidpoint}
|
||||
onDeleteVertex={deleteVertex}
|
||||
/>
|
||||
))}
|
||||
|
||||
{draft && (() => {
|
||||
const [x0, y0, x1, y1] = normalise(draft)
|
||||
return (
|
||||
<rect
|
||||
className={assistMode ? 'draft assist' : 'draft'}
|
||||
x={x0 * width} y={y0 * height}
|
||||
width={(x1 - x0) * width} height={(y1 - y0) * height}
|
||||
stroke={assistMode ? 'var(--accent)' : classColor(activeClass)}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function shift(geometry, dx, dy) {
|
||||
if (geometry.type === 'bbox') {
|
||||
const [x0, y0, x1, y1] = geometry.points
|
||||
const clampedX = Math.min(Math.max(dx, -x0), 1 - x1)
|
||||
const clampedY = Math.min(Math.max(dy, -y0), 1 - y1)
|
||||
return { type: 'bbox',
|
||||
points: [x0 + clampedX, y0 + clampedY, x1 + clampedX, y1 + clampedY] }
|
||||
}
|
||||
const xs = geometry.points.map((p) => p[0])
|
||||
const ys = geometry.points.map((p) => p[1])
|
||||
const clampedX = Math.min(Math.max(dx, -Math.min(...xs)), 1 - Math.max(...xs))
|
||||
const clampedY = Math.min(Math.max(dy, -Math.min(...ys)), 1 - Math.max(...ys))
|
||||
return { type: 'polygon',
|
||||
points: geometry.points.map(([x, y]) => [x + clampedX, y + clampedY]) }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
export default function Filmstrip({ frames, index, onSelectIndex, stripRef }) {
|
||||
return (
|
||||
<div className="filmstrip" ref={stripRef}>
|
||||
{frames.map((item, position) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`thumb ${item.review_status}`}
|
||||
aria-current={position === index}
|
||||
onClick={() => onSelectIndex(position)}
|
||||
title={`${item.filename} — ${item.review_status}`}
|
||||
>
|
||||
<img src={api.frameUrl(item.id, 120)} alt="" loading="lazy" />
|
||||
{item.annotation_count > 0 && <span className="badge">{item.annotation_count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/* Inline SVG icons (Lucide geometry). No emoji as icons — see ../../../AGENTS.md
|
||||
* section 7. They inherit currentColor and the surrounding font size. */
|
||||
|
||||
function Icon({ children, size = 16, ...rest }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const PlusIcon = (props) => (
|
||||
<Icon {...props}><path d="M12 5v14M5 12h14" /></Icon>
|
||||
)
|
||||
|
||||
export const TrashIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const UploadIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const SunIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const MoonIcon = (props) => (
|
||||
<Icon {...props}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></Icon>
|
||||
)
|
||||
|
||||
export const LayersIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ChevronRightIcon = (props) => (
|
||||
<Icon {...props}><path d="m9 18 6-6-6-6" /></Icon>
|
||||
)
|
||||
|
||||
export const AlertIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" />
|
||||
<path d="M12 9v4M12 17h.01" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const CheckIcon = (props) => (
|
||||
<Icon {...props}><path d="M20 6 9 17l-5-5" /></Icon>
|
||||
)
|
||||
|
||||
export const XIcon = (props) => (
|
||||
<Icon {...props}><path d="M18 6 6 18M6 6l12 12" /></Icon>
|
||||
)
|
||||
|
||||
export const FolderIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M20 20H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4l2 3h10a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const DatabaseIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const CpuIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" ry="2" />
|
||||
<rect x="9" y="9" width="6" height="6" />
|
||||
<path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 15h3M1 9h3M1 15h3" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const RocketIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.71.79-1.81.79-1.81l-3-3s-1.1.08-1.79.81z" />
|
||||
<path d="M15 9l-6 6" />
|
||||
<path d="M9 18l3 3c.87.87 2.18.99 3.1.27l5.9-5.9c1.6-1.6 1.6-4.2 0-5.8l-1.5-1.5c-1.6-1.6-4.2-1.6-5.8 0l-5.9 5.9c-.72.92-.6 2.23.27 3.1z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const BarChartIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M12 20V10M18 20V4M6 20v-4" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const TagIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2z" />
|
||||
<circle cx="7" cy="7" r="1.5" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const SlidersIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<line x1="4" y1="21" x2="4" y2="14" />
|
||||
<line x1="4" y1="10" x2="4" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="3" />
|
||||
<line x1="20" y1="21" x2="20" y2="16" />
|
||||
<line x1="20" y1="12" x2="20" y2="3" />
|
||||
<line x1="1" y1="14" x2="7" y2="14" />
|
||||
<line x1="9" y1="8" x2="15" y2="8" />
|
||||
<line x1="17" y1="16" x2="23" y2="16" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ZapIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react'
|
||||
import { classColor } from '../api'
|
||||
import { TrashIcon } from './Icons'
|
||||
|
||||
export default function QuickReclassBar({ classesList, reclass, removeSelected }) {
|
||||
return (
|
||||
<div className="quick-reclass-bar panel" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', background: 'rgba(168, 85, 247, 0.18)', border: '1px solid rgba(168, 85, 247, 0.45)', borderRadius: '8px' }}>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: '#c084fc' }}>
|
||||
Selected Shape Hotkeys:
|
||||
</span>
|
||||
{classesList.map((c) => (
|
||||
<button
|
||||
key={c.class_id}
|
||||
className="btn"
|
||||
style={{ padding: '3px 10px', fontSize: '0.78rem', background: 'rgba(0,0,0,0.4)', borderColor: classColor(c.class_id), color: '#fff' }}
|
||||
onClick={() => reclass(c.class_id)}
|
||||
>
|
||||
<span style={{ background: classColor(c.class_id), width: 8, height: 8, borderRadius: '50%', display: 'inline-block', marginRight: 5 }} />
|
||||
<strong>[{c.class_id + 1}]</strong> {c.name}
|
||||
</button>
|
||||
))}
|
||||
<span className="spacer" />
|
||||
<button className="btn btn-danger" style={{ padding: '3px 10px', fontSize: '0.78rem' }} onClick={removeSelected}>
|
||||
<TrashIcon size={12} /> Delete [Del]
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react'
|
||||
import { classColor } from '../api'
|
||||
import { TrashIcon } from './Icons'
|
||||
import ShortcutsPanel from './ShortcutsPanel'
|
||||
|
||||
export default function ReviewSidebar({
|
||||
classesList,
|
||||
activeClass,
|
||||
reclass,
|
||||
clearClassInBatch,
|
||||
annotations,
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
removeSelected,
|
||||
project,
|
||||
jumpToNextAnnotated,
|
||||
batchAnnotationCount,
|
||||
}) {
|
||||
return (
|
||||
<aside className="review-side stack">
|
||||
<div className="panel side-panel">
|
||||
<h2>Classes</h2>
|
||||
<div className="class-list">
|
||||
{classesList.map((item) => (
|
||||
<div className="class-row" key={item.class_id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button
|
||||
className={`class-chip ${item.class_id === activeClass ? 'active' : ''}`}
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => reclass(item.class_id)}
|
||||
>
|
||||
<span className="swatch" style={{ background: classColor(item.class_id) }} />
|
||||
{item.name}
|
||||
<span className="faint mono">{item.class_id + 1}</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: '4px 6px' }}
|
||||
title={`Clear all "${item.name}" shapes in this batch`}
|
||||
onClick={() => clearClassInBatch(item)}
|
||||
>
|
||||
<TrashIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel side-panel">
|
||||
<h2>Shapes on this frame ({annotations.length})</h2>
|
||||
{annotations.length === 0 ? (
|
||||
<div>
|
||||
<p className="hint" style={{ marginBottom: 8 }}>
|
||||
None on this frame — drag to draw a shape.
|
||||
</p>
|
||||
{batchAnnotationCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{
|
||||
width: '100%',
|
||||
fontSize: '0.78rem',
|
||||
borderColor: 'rgba(168, 85, 247, 0.4)',
|
||||
color: '#c084fc',
|
||||
background: 'rgba(168, 85, 247, 0.1)',
|
||||
}}
|
||||
onClick={jumpToNextAnnotated}
|
||||
>
|
||||
🏷️ Jump to Frame with Shapes [N]
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="shape-list">
|
||||
{annotations.map((item) => (
|
||||
<li key={item.id} className={item.id === selectedId ? 'selected' : ''}>
|
||||
<button className="shape-pick" onClick={() => setSelectedId(item.id)}>
|
||||
<span className="swatch" style={{ background: classColor(item.class_id) }} />
|
||||
{project?.classes?.[item.class_id]?.name ?? `class ${item.class_id}`}
|
||||
<span className="faint mono">
|
||||
{item.source === 'auto' ? item.score.toFixed(2) : 'manual'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
aria-label="Delete shape"
|
||||
onClick={() => {
|
||||
setSelectedId(item.id)
|
||||
removeSelected()
|
||||
}}
|
||||
>
|
||||
<TrashIcon size={13} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ShortcutsPanel />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { classColor } from '../api'
|
||||
|
||||
const CORNERS = [
|
||||
['nw', 0, 0], ['ne', 1, 0], ['se', 1, 1], ['sw', 0, 1],
|
||||
]
|
||||
|
||||
function boxPoints(geometry) {
|
||||
return geometry.type === 'bbox'
|
||||
? geometry.points
|
||||
: (() => {
|
||||
const xs = geometry.points.map((p) => p[0])
|
||||
const ys = geometry.points.map((p) => p[1])
|
||||
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]
|
||||
})()
|
||||
}
|
||||
|
||||
export default function Shape({
|
||||
annotation, width, height, scale, handle, selected, classes,
|
||||
onStartMove, onStartResize, onStartVertex, onStartMidpoint, onDeleteVertex,
|
||||
}) {
|
||||
const colour = classColor(annotation.class_id)
|
||||
const [x0, y0, x1, y1] = boxPoints(annotation.geometry)
|
||||
const isPolygon = annotation.geometry.type === 'polygon'
|
||||
const className = classes?.[annotation.class_id]?.name || `Class ${annotation.class_id + 1}`
|
||||
|
||||
return (
|
||||
<g className={selected ? 'shape selected' : 'shape'}>
|
||||
{isPolygon ? (
|
||||
<polygon
|
||||
points={annotation.geometry.points
|
||||
.map(([px, py]) => `${px * width},${py * height}`).join(' ')}
|
||||
stroke={colour}
|
||||
onPointerDown={(event) => onStartMove(event, annotation)}
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
x={x0 * width} y={y0 * height}
|
||||
width={(x1 - x0) * width} height={(y1 - y0) * height}
|
||||
stroke={colour}
|
||||
onPointerDown={(event) => onStartMove(event, annotation)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<g transform={`translate(${x0 * width}, ${Math.max(22 * scale, y0 * height - 6 * scale)})`}>
|
||||
<rect
|
||||
x="0" y={-16 * scale}
|
||||
width={Math.max(70 * scale, className.length * 8.5 * scale + 32 * scale)}
|
||||
height={18 * scale}
|
||||
rx={3 * scale} ry={3 * scale}
|
||||
fill="rgba(10, 10, 14, 0.9)"
|
||||
stroke={colour}
|
||||
strokeWidth={1.5 * scale}
|
||||
/>
|
||||
<text
|
||||
x={6 * scale} y={-3 * scale}
|
||||
fill="#ffffff"
|
||||
fontSize={12 * scale}
|
||||
fontWeight="bold"
|
||||
fontFamily="sans-serif"
|
||||
>
|
||||
{`[${annotation.class_id + 1}] ${className}`}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{selected && !isPolygon && CORNERS.map(([corner, cx, cy]) => (
|
||||
<rect
|
||||
key={corner}
|
||||
className={`handle handle-${corner}`}
|
||||
x={(x0 + (x1 - x0) * cx) * width - handle / 2}
|
||||
y={(y0 + (y1 - y0) * cy) * height - handle / 2}
|
||||
width={handle} height={handle}
|
||||
fill={colour}
|
||||
onPointerDown={(event) => onStartResize(event, annotation, corner)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{selected && isPolygon && (() => {
|
||||
const points = annotation.geometry.points
|
||||
const vertices = points.map(([px, py], i) => (
|
||||
<circle
|
||||
key={`v-${i}`}
|
||||
className="handle handle-vertex"
|
||||
cx={px * width}
|
||||
cy={py * height}
|
||||
r={handle / 2}
|
||||
fill={colour}
|
||||
onPointerDown={(event) => {
|
||||
if (event.altKey) {
|
||||
event.stopPropagation()
|
||||
if (points.length > 3) {
|
||||
onDeleteVertex?.(annotation, i)
|
||||
}
|
||||
} else {
|
||||
onStartVertex?.(event, annotation, i)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
const midpoints = points.map(([px, py], i) => {
|
||||
const next = points[(i + 1) % points.length]
|
||||
const mx = (px + next[0]) / 2
|
||||
const my = (py + next[1]) / 2
|
||||
return (
|
||||
<circle
|
||||
key={`m-${i}`}
|
||||
className="handle handle-midpoint"
|
||||
cx={mx * width}
|
||||
cy={my * height}
|
||||
r={handle / 2.5}
|
||||
fill={colour}
|
||||
style={{ opacity: 0.45 }}
|
||||
onPointerDown={(event) => onStartMidpoint?.(event, annotation, i, [mx, my])}
|
||||
/>
|
||||
)
|
||||
})
|
||||
return <g>{vertices}{midpoints}</g>
|
||||
})()}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export default function ShortcutsPanel() {
|
||||
return (
|
||||
<div className="panel side-panel">
|
||||
<h2>Shortcuts</h2>
|
||||
<dl className="shortcuts">
|
||||
<div>
|
||||
<dt><kbd>Drag</kbd></dt>
|
||||
<dd>Add box / resize / move</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><kbd>Hold S</kbd></dt>
|
||||
<dd>SAM3 assisted shape</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><kbd>1</kbd>–<kbd>9</kbd></dt>
|
||||
<dd>Pick class</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><kbd>Del</kbd></dt>
|
||||
<dd>Remove selected</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><kbd>←</kbd> <kbd>→</kbd></dt>
|
||||
<dd>Prev / next frame</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><kbd>A</kbd> <kbd>X</kbd></dt>
|
||||
<dd>Approve / reject</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><kbd>U</kbd></dt>
|
||||
<dd>Next unreviewed</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { navigate } from '../App'
|
||||
import {
|
||||
FolderIcon,
|
||||
DatabaseIcon,
|
||||
RocketIcon,
|
||||
TagIcon,
|
||||
BarChartIcon,
|
||||
SunIcon,
|
||||
MoonIcon,
|
||||
ChevronRightIcon,
|
||||
} from './Icons'
|
||||
|
||||
export default function Sidebar({ route, currentProject, theme, onToggleTheme }) {
|
||||
const [health, setHealth] = useState(null)
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('sidebar_collapsed') === 'true')
|
||||
|
||||
useEffect(() => {
|
||||
api.health().then(setHealth).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const toggleCollapse = () => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev
|
||||
localStorage.setItem('sidebar_collapsed', String(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const pId = currentProject?.id || route.projectId || 1
|
||||
|
||||
const handleNav = (e, path) => {
|
||||
e.preventDefault()
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`roboflow-sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-logo">{collapsed ? 'DE' : 'Dataset Enrichment'}</div>
|
||||
<button className="sidebar-collapse-btn" onClick={toggleCollapse} title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}>
|
||||
{collapsed ? <ChevronRightIcon size={14} /> : '❮'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
{!collapsed && <div className="sidebar-section-title">WORKSPACE</div>}
|
||||
<a href="#/projects" onClick={(e) => handleNav(e, '/projects')} className={`sidebar-item ${route.name === 'projects' ? 'active' : ''}`} title="Projects">
|
||||
<span className="sidebar-icon"><FolderIcon size={16} /></span>
|
||||
{!collapsed && <span>Projects</span>}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
{!collapsed && <div className="sidebar-section-title">DATA</div>}
|
||||
<a href={`#/projects/${pId}`} onClick={(e) => handleNav(e, `/projects/${pId}`)} className={`sidebar-item ${route.name === 'library' ? 'active' : ''}`} title="Video Archive">
|
||||
<span className="sidebar-icon"><DatabaseIcon size={16} /></span>
|
||||
{!collapsed && <span>Video Archive</span>}
|
||||
</a>
|
||||
<a
|
||||
href={route.batchId ? `#/batches/${route.batchId}` : `#/projects/${pId}`}
|
||||
onClick={(e) => handleNav(e, route.batchId ? `/batches/${route.batchId}` : `/projects/${pId}`)}
|
||||
className={`sidebar-item ${route.name === 'review' || route.name === 'trim' ? 'active' : ''}`}
|
||||
title="Annotate / Review"
|
||||
>
|
||||
<span className="sidebar-icon"><TagIcon size={16} /></span>
|
||||
{!collapsed && <span>Annotate / Review</span>}
|
||||
</a>
|
||||
<a href={`#/projects/${pId}/models`} onClick={(e) => handleNav(e, `/projects/${pId}/models`)} className={`sidebar-item ${route.name === 'models' ? 'active' : ''}`} title="Master Dataset">
|
||||
<span className="sidebar-icon"><BarChartIcon size={16} /></span>
|
||||
{!collapsed && <span>Master Dataset</span>}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
{!collapsed && <div className="sidebar-section-title">MODELS</div>}
|
||||
<a href={`#/projects/${pId}/models`} onClick={(e) => handleNav(e, `/projects/${pId}/models`)} className={`sidebar-item ${route.name === 'models' ? 'active' : ''}`} title="Train & Select Engine">
|
||||
<span className="sidebar-icon"><RocketIcon size={16} /></span>
|
||||
{!collapsed && <span>Train & Select Engine</span>}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-spacer" />
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button className="sidebar-theme-toggle" onClick={onToggleTheme} title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}>
|
||||
{collapsed ? (theme === 'dark' ? <SunIcon size={14} /> : <MoonIcon size={14} />) : (theme === 'dark' ? 'Light Mode' : 'Dark Mode')}
|
||||
</button>
|
||||
{health && !collapsed && (
|
||||
<div className="sidebar-health">
|
||||
<div className="health-item" title={health.gpu || ''} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}>
|
||||
<span style={{ float: 'left' }}>GPU:</span>
|
||||
<span style={{ float: 'right' }}>{health.gpu ? health.gpu.replace('NVIDIA GeForce ', '').replace(' Laptop GPU', '') : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="health-item"><span>VRAM:</span> <span>{health.vram_free_gb ? `${health.vram_free_gb.toFixed(1)}GB` : 'N/A'}</span></div>
|
||||
<div className="health-item"><span>SAM3:</span> <span>{health.sam3_ready ? 'Ready' : 'Not Ready'}</span></div>
|
||||
<div className="health-item"><span>FFmpeg:</span> <span>{health.ffmpeg ? 'OK' : 'Error'}</span></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './theme.css'
|
||||
import './app.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,591 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, formatDuration } from '../api'
|
||||
import { navigate } from '../App'
|
||||
import { AlertIcon } from '../components/Icons'
|
||||
|
||||
function megabytes(bytes) {
|
||||
if (!bytes) return '—'
|
||||
const mb = bytes / (1024 * 1024)
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
|
||||
if (mb >= 10) return `${Math.round(mb)} MB`
|
||||
if (mb >= 1) return `${mb.toFixed(1)} MB`
|
||||
return `${Math.round(bytes / 1024)} KB`
|
||||
}
|
||||
|
||||
function ActiveJobsBanner({ jobs, onCancel }) {
|
||||
if (!jobs || jobs.length === 0) return null
|
||||
return (
|
||||
<div className="panel side-panel" style={{ marginBottom: 16, border: '1px solid rgba(168, 85, 247, 0.4)', background: 'rgba(24, 24, 27, 0.8)' }}>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '0.95rem', color: '#c084fc' }}>Active System Tasks ({jobs.length})</h3>
|
||||
{jobs.map((job) => (
|
||||
<div key={job.id} style={{ marginBottom: 10, padding: '8px 12px', background: 'rgba(0,0,0,0.4)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
<div className="row" style={{ fontSize: '0.85rem' }}>
|
||||
<span className={`dot ${job.status === 'running' ? 'ok' : ''}`} />
|
||||
<strong style={{ textTransform: 'capitalize' }}>{job.type}</strong>
|
||||
<span className="muted">({job.status})</span>
|
||||
<span className="spacer" />
|
||||
<span className="mono">{job.progress}/{job.total || '—'}</span>
|
||||
<button className="btn btn-ghost" style={{ padding: '2px 6px', fontSize: '0.75rem' }} onClick={() => onCancel(job.id)}>Cancel</button>
|
||||
</div>
|
||||
<div className="progress" style={{ margin: '6px 0' }}>
|
||||
<span style={{ width: `${job.total ? (job.progress / job.total) * 100 : job.status === 'running' ? 50 : 10}%` }} />
|
||||
</div>
|
||||
{job.log?.length > 0 && (
|
||||
<p className="hint mono" style={{ fontSize: '0.75rem', margin: 0, opacity: 0.8 }}>
|
||||
{job.log[job.log.length - 1]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BatchList({ project, batches, activeJobs, onChanged, onError }) {
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
const [selectedEngine, setSelectedEngine] = useState({})
|
||||
const [engineClassMap, setEngineClassMap] = useState({})
|
||||
const [expandedFilterBatchId, setExpandedFilterBatchId] = useState(null)
|
||||
|
||||
const hasSecondaryModel = Boolean(project?.secondary_model_path)
|
||||
const model1Classes = project?.classes?.map((c) => c.name) || []
|
||||
const model2Classes = project?.secondary_model_classes?.length > 0
|
||||
? project.secondary_model_classes
|
||||
: model1Classes
|
||||
|
||||
const defaultEngines = project?.base_model_path ? ['base_model'] : ['sam3']
|
||||
const [selectedThreshold, setSelectedThreshold] = useState({})
|
||||
|
||||
async function autolabel(batch, resume = false) {
|
||||
setBusyId(batch.id)
|
||||
const engines = selectedEngine[batch.id] || defaultEngines
|
||||
const threshold = selectedThreshold[batch.id] ?? 0.35
|
||||
|
||||
// Default class filter for each active engine if not customized
|
||||
const currentBatchMap = engineClassMap[batch.id] || {}
|
||||
const engine_classes = {}
|
||||
|
||||
if (engines.includes('sam3')) {
|
||||
engine_classes.sam3 = currentBatchMap.sam3 || model1Classes
|
||||
}
|
||||
if (engines.includes('base_model')) {
|
||||
engine_classes.base_model = currentBatchMap.base_model || model1Classes
|
||||
}
|
||||
if (engines.includes('secondary_model')) {
|
||||
engine_classes.secondary_model = currentBatchMap.secondary_model || model2Classes
|
||||
}
|
||||
|
||||
try {
|
||||
await api.startAutolabel(batch.id, { resume, engines, engine_classes, threshold })
|
||||
onChanged()
|
||||
} catch (exc) {
|
||||
onError(exc.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEngine = (batchId, engineKey) => {
|
||||
const current = selectedEngine[batchId] || defaultEngines
|
||||
let next
|
||||
if (current.includes(engineKey)) {
|
||||
if (current.length === 1) return // keep at least 1 engine selected
|
||||
next = current.filter((e) => e !== engineKey)
|
||||
} else {
|
||||
next = [...current, engineKey]
|
||||
}
|
||||
setSelectedEngine({ ...selectedEngine, [batchId]: next })
|
||||
}
|
||||
|
||||
const toggleEngineClass = (batchId, engineKey, className, defaultClasses) => {
|
||||
const batchFilters = engineClassMap[batchId] || {}
|
||||
const currentEngClasses = batchFilters[engineKey] || defaultClasses
|
||||
let next
|
||||
if (currentEngClasses.includes(className)) {
|
||||
if (currentEngClasses.length === 1) return // keep at least 1 class
|
||||
next = currentEngClasses.filter((c) => c !== className)
|
||||
} else {
|
||||
next = [...currentEngClasses, className]
|
||||
}
|
||||
setEngineClassMap({
|
||||
...engineClassMap,
|
||||
[batchId]: {
|
||||
...batchFilters,
|
||||
[engineKey]: next,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteBatch(batch) {
|
||||
if (!window.confirm(`Delete batch "${batch.batch_label}" and all its extracted frames?`)) return
|
||||
setBusyId(batch.id)
|
||||
try {
|
||||
await api.deleteBatch(batch.id)
|
||||
onChanged()
|
||||
} catch (exc) {
|
||||
onError(exc.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function editBatch(batch) {
|
||||
const newLabel = window.prompt("Enter new batch label:", batch.batch_label)
|
||||
if (!newLabel || newLabel.trim() === batch.batch_label) return
|
||||
setBusyId(batch.id)
|
||||
try {
|
||||
await api.patchBatch(batch.id, { batch_label: newLabel.trim() })
|
||||
onChanged()
|
||||
} catch (exc) {
|
||||
onError(exc.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (batches.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="panel table-wrap" style={{ marginTop: 16 }}>
|
||||
<table className="video-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Batch</th><th>Range</th><th>Frames</th><th>Reviewed</th>
|
||||
<th>Shapes</th><th>Model & Class Configuration</th><th>Status</th><th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map((batch) => {
|
||||
const batchJob = activeJobs?.find((j) => j.batch_id === batch.id)
|
||||
const isProcessing = Boolean(batchJob || busyId === batch.id)
|
||||
const activeEngines = selectedEngine[batch.id] || defaultEngines
|
||||
const isFilterExpanded = expandedFilterBatchId === batch.id
|
||||
const batchMap = engineClassMap[batch.id] || {}
|
||||
|
||||
const sam3Active = batchMap.sam3 || model1Classes
|
||||
const model1Active = batchMap.base_model || model1Classes
|
||||
const model2Active = batchMap.secondary_model || model2Classes
|
||||
|
||||
return (
|
||||
<React.Fragment key={batch.id}>
|
||||
<tr>
|
||||
<td>
|
||||
<span style={{ cursor: 'pointer', borderBottom: '1px dashed rgba(255,255,255,0.3)' }} onClick={() => editBatch(batch)} title="Click to rename batch">
|
||||
{batch.date_label} · {batch.batch_label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
{formatDuration(batch.start_sec)}–{formatDuration(batch.end_sec)} @ {batch.fps}fps
|
||||
</td>
|
||||
<td className="num">{batch.frame_count}</td>
|
||||
<td className="num">{batch.reviewed}/{batch.frame_count}</td>
|
||||
<td className="num">{batch.annotation_count}</td>
|
||||
<td style={{ minWidth: 260 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${activeEngines.includes('sam3') ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ padding: '2px 6px', fontSize: '0.72rem', cursor: 'pointer', opacity: activeEngines.includes('sam3') ? 1 : 0.6 }}
|
||||
onClick={() => toggleEngine(batch.id, 'sam3')}
|
||||
disabled={isProcessing}
|
||||
title="SAM3 Zero-shot Text Prompt Engine"
|
||||
>
|
||||
🤖 SAM3
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${activeEngines.includes('base_model') ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ padding: '2px 6px', fontSize: '0.72rem', cursor: 'pointer', opacity: activeEngines.includes('base_model') ? 1 : 0.6 }}
|
||||
onClick={() => toggleEngine(batch.id, 'base_model')}
|
||||
disabled={isProcessing}
|
||||
title="Primary Model 1 (Base / Trained)"
|
||||
>
|
||||
⚡ Model 1
|
||||
</button>
|
||||
{hasSecondaryModel && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${activeEngines.includes('secondary_model') ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ padding: '2px 6px', fontSize: '0.72rem', cursor: 'pointer', opacity: activeEngines.includes('secondary_model') ? 1 : 0.6 }}
|
||||
onClick={() => toggleEngine(batch.id, 'secondary_model')}
|
||||
disabled={isProcessing}
|
||||
title={project?.secondary_model_name || "Secondary Uploaded Model 2"}
|
||||
>
|
||||
🎯 Model 2
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.72rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
borderColor: isFilterExpanded ? '#c084fc' : 'rgba(255,255,255,0.2)',
|
||||
color: isFilterExpanded ? '#c084fc' : '#e4e4e7',
|
||||
}}
|
||||
onClick={() => setExpandedFilterBatchId(isFilterExpanded ? null : batch.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
⚙️ {isFilterExpanded ? 'Hide' : 'Per-Engine'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center', fontSize: '0.72rem' }}>
|
||||
<span className="muted" style={{ fontSize: '0.7rem' }}>Classes:</span>
|
||||
{model1Classes.map((clsName) => {
|
||||
const isModel1On = activeEngines.includes('base_model') && model1Active.includes(clsName)
|
||||
const isSam3On = activeEngines.includes('sam3') && sam3Active.includes(clsName)
|
||||
const isSecOn = activeEngines.includes('secondary_model') && hasSecondaryModel && model2Active.includes(clsName)
|
||||
const isActiveAny = isModel1On || isSam3On || isSecOn
|
||||
|
||||
return (
|
||||
<button
|
||||
key={clsName}
|
||||
type="button"
|
||||
className="tag"
|
||||
style={{
|
||||
padding: '1px 5px',
|
||||
fontSize: '0.7rem',
|
||||
cursor: 'pointer',
|
||||
background: isActiveAny ? 'rgba(56, 189, 248, 0.25)' : 'rgba(255,255,255,0.05)',
|
||||
color: isActiveAny ? '#38bdf8' : '#71717a',
|
||||
border: isActiveAny ? '1px solid rgba(56, 189, 248, 0.5)' : '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
title={`Toggle ${clsName} filter`}
|
||||
disabled={isProcessing}
|
||||
onClick={() => {
|
||||
if (activeEngines.includes('base_model')) toggleEngineClass(batch.id, 'base_model', clsName, model1Classes)
|
||||
if (activeEngines.includes('sam3')) toggleEngineClass(batch.id, 'sam3', clsName, model1Classes)
|
||||
if (activeEngines.includes('secondary_model')) toggleEngineClass(batch.id, 'secondary_model', clsName, model2Classes)
|
||||
}}
|
||||
>
|
||||
{isActiveAny ? '✓ ' : ''}{clsName}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`tag ${batchJob ? 'info' : ''}`}>
|
||||
{batchJob ? `${batchJob.type} (${batchJob.status})` : batch.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<button className="btn btn-primary" disabled={isProcessing || batch.frame_count === 0}
|
||||
onClick={() => autolabel(batch)}>
|
||||
{batchJob?.type === 'autolabel' ? 'Processing…' : `Auto-annotate (${activeEngines.map(e => e === 'sam3' ? 'SAM3' : e === 'base_model' ? 'Model 1' : 'Model 2').join('+')})`}
|
||||
</button>
|
||||
{batch.annotation_count > 0 && (
|
||||
<button className="btn" disabled={isProcessing || batch.frame_count === 0}
|
||||
title="Skip frames that already have automatic shapes"
|
||||
onClick={() => autolabel(batch, true)}>
|
||||
Resume
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" disabled={batch.frame_count === 0}
|
||||
onClick={() => navigate(`/projects/${project.id}/review?batch=${batch.id}`)}>
|
||||
Review ({batch.reviewed}/{batch.frame_count})
|
||||
</button>
|
||||
<button className="btn btn-danger" disabled={isProcessing}
|
||||
onClick={() => deleteBatch(batch)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Inline Per-Engine Class Filter Panel */}
|
||||
{isFilterExpanded && (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ background: '#09090b', padding: '12px 16px', borderBottom: '1px solid rgba(168,85,247,0.3)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ fontSize: '0.82rem', fontWeight: 600, color: '#c084fc' }}>
|
||||
🛠️ Inline Per-Engine Class Filters (Select target classes per detector):
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
|
||||
{/* SAM3 Section */}
|
||||
{activeEngines.includes('sam3') && (
|
||||
<div style={{ background: 'rgba(24, 24, 27, 0.8)', padding: 10, borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: '#a855f7', marginBottom: 6 }}>
|
||||
🤖 SAM3 Text Prompts
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{model1Classes.map((clsName) => {
|
||||
const isActive = sam3Active.includes(clsName)
|
||||
return (
|
||||
<button
|
||||
key={clsName}
|
||||
type="button"
|
||||
className="tag"
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? 'rgba(168, 85, 247, 0.3)' : 'rgba(255,255,255,0.05)',
|
||||
color: isActive ? '#f3e8ff' : '#666',
|
||||
border: isActive ? '1px solid rgba(168, 85, 247, 0.6)' : '1px solid transparent',
|
||||
}}
|
||||
onClick={() => toggleEngineClass(batch.id, 'sam3', clsName, model1Classes)}
|
||||
>
|
||||
{isActive ? '✓ ' : ''}{clsName}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model 1 Section */}
|
||||
{activeEngines.includes('base_model') && (
|
||||
<div style={{ background: 'rgba(24, 24, 27, 0.8)', padding: 10, borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: '#38bdf8', marginBottom: 6 }}>
|
||||
⚡ Model 1 (Primary Base) Classes
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{model1Classes.map((clsName) => {
|
||||
const isActive = model1Active.includes(clsName)
|
||||
return (
|
||||
<button
|
||||
key={clsName}
|
||||
type="button"
|
||||
className="tag"
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? 'rgba(56, 189, 248, 0.3)' : 'rgba(255,255,255,0.05)',
|
||||
color: isActive ? '#e0f2fe' : '#666',
|
||||
border: isActive ? '1px solid rgba(56, 189, 248, 0.6)' : '1px solid transparent',
|
||||
}}
|
||||
onClick={() => toggleEngineClass(batch.id, 'base_model', clsName, model1Classes)}
|
||||
>
|
||||
{isActive ? '✓ ' : ''}{clsName}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model 2 Section */}
|
||||
{activeEngines.includes('secondary_model') && hasSecondaryModel && (
|
||||
<div style={{ background: 'rgba(24, 24, 27, 0.8)', padding: 10, borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: '#f43f5e', marginBottom: 6 }}>
|
||||
🎯 Model 2 (Secondary Engine) Native Classes
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{model2Classes.map((clsName) => {
|
||||
const isActive = model2Active.includes(clsName)
|
||||
return (
|
||||
<button
|
||||
key={clsName}
|
||||
type="button"
|
||||
className="tag"
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? 'rgba(244, 63, 94, 0.3)' : 'rgba(255,255,255,0.05)',
|
||||
color: isActive ? '#ffe4e6' : '#666',
|
||||
border: isActive ? '1px solid rgba(244, 63, 94, 0.6)' : '1px solid transparent',
|
||||
}}
|
||||
onClick={() => toggleEngineClass(batch.id, 'secondary_model', clsName, model2Classes)}
|
||||
>
|
||||
{isActive ? '✓ ' : ''}{clsName}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LibraryPage({ projectId, onProject }) {
|
||||
const [project, setProject] = useState(null)
|
||||
const [dates, setDates] = useState([])
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [videos, setVideos] = useState(null)
|
||||
const [batches, setBatches] = useState([])
|
||||
const [jobs, setJobs] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const loadBatches = useCallback(() => {
|
||||
api.listBatches(projectId).then((payload) => setBatches(payload.batches)).catch(() => {})
|
||||
}, [projectId])
|
||||
|
||||
const loadJobs = useCallback(() => {
|
||||
api.listJobs(projectId).then((payload) => setJobs(payload.jobs)).catch(() => {})
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setError('')
|
||||
Promise.all([api.getProject(projectId), api.listDates(projectId), api.listBatches(projectId), api.listJobs(projectId)])
|
||||
.then(([loadedProject, library, batchPayload, jobPayload]) => {
|
||||
if (cancelled) return
|
||||
setProject(loadedProject)
|
||||
onProject?.(loadedProject)
|
||||
setDates(library.dates)
|
||||
setSelected(library.dates[0]?.date ?? null)
|
||||
setBatches(batchPayload.batches)
|
||||
setJobs(jobPayload.jobs)
|
||||
})
|
||||
.catch((exc) => !cancelled && setError(exc.message))
|
||||
return () => { cancelled = true }
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return
|
||||
let cancelled = false
|
||||
setVideos(null)
|
||||
api.listVideos(projectId, selected)
|
||||
.then((payload) => !cancelled && setVideos(payload.videos))
|
||||
.catch((exc) => !cancelled && setError(exc.message))
|
||||
return () => { cancelled = true }
|
||||
}, [projectId, selected])
|
||||
|
||||
// Poll jobs every 2 seconds if there are active jobs, and refresh batches on completion
|
||||
const activeJobs = jobs.filter((j) => ['queued', 'running'].includes(j.status))
|
||||
const prevActiveCount = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (prevActiveCount.current > 0 && activeJobs.length === 0) {
|
||||
loadBatches()
|
||||
loadJobs()
|
||||
}
|
||||
prevActiveCount.current = activeJobs.length
|
||||
|
||||
if (activeJobs.length === 0) return
|
||||
const timer = setInterval(() => {
|
||||
loadJobs()
|
||||
loadBatches()
|
||||
}, 2000)
|
||||
return () => clearInterval(timer)
|
||||
}, [activeJobs.length, loadJobs, loadBatches])
|
||||
|
||||
const cancelJob = async (jobId) => {
|
||||
try {
|
||||
await api.cancelJob(jobId)
|
||||
loadJobs()
|
||||
} catch (exc) {
|
||||
setError(exc.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className="error-banner"><AlertIcon size={14} /> {error}</p>
|
||||
}
|
||||
if (!project) return <p className="empty">Loading…</p>
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>{project.name}</h1>
|
||||
<p className="muted mono">{project.video_root}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActiveJobsBanner jobs={activeJobs} onCancel={cancelJob} />
|
||||
|
||||
{batches.length > 0 && (
|
||||
<>
|
||||
<h2 style={{ marginBottom: 8 }}>Batches</h2>
|
||||
<BatchList project={project} batches={batches} activeJobs={activeJobs} onChanged={() => { loadBatches(); loadJobs(); }} onError={setError} />
|
||||
<h2 style={{ margin: '24px 0 8px' }}>Archive</h2>
|
||||
</>
|
||||
)}
|
||||
|
||||
{dates.length === 0 ? (
|
||||
<p className="empty">
|
||||
No date folders in this archive yet. Expected layout: <date>/<batch>.mp4
|
||||
</p>
|
||||
) : (
|
||||
<div className="library">
|
||||
<nav className="panel date-list" aria-label="Recording dates">
|
||||
{dates.map((item) => (
|
||||
<button
|
||||
key={item.date}
|
||||
className="date-item"
|
||||
aria-current={item.date === selected}
|
||||
onClick={() => setSelected(item.date)}
|
||||
>
|
||||
{item.date}
|
||||
<span className="count">{item.video_count}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="panel table-wrap">
|
||||
{videos === null ? (
|
||||
<p className="empty">Reading video metadata…</p>
|
||||
) : videos.length === 0 ? (
|
||||
<p className="empty">No videos in {selected}.</p>
|
||||
) : (
|
||||
<table className="video-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Batch</th>
|
||||
<th>Duration</th>
|
||||
<th>Resolution</th>
|
||||
<th>FPS</th>
|
||||
<th>Size</th>
|
||||
<th>Used</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{videos.map((item) => (
|
||||
<tr key={item.rel}>
|
||||
<td>{item.batch_label}</td>
|
||||
<td className="num">{formatDuration(item.duration)}</td>
|
||||
<td className="num">
|
||||
{item.width ? `${item.width}×${item.height}` : <span className="faint">unreadable</span>}
|
||||
</td>
|
||||
<td className="num">{item.fps || '—'}</td>
|
||||
<td className="num">{megabytes(item.size)}</td>
|
||||
<td>
|
||||
{item.used_count > 0
|
||||
? <span className="tag">{item.used_count} batch{item.used_count > 1 ? 'es' : ''}</span>
|
||||
: <span className="faint">—</span>}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!item.duration}
|
||||
title={item.duration ? 'Pick a range and extract frames' : 'ffprobe could not read this file'}
|
||||
onClick={() => navigate(`/projects/${projectId}/trim/${encodeURIComponent(item.rel)}`)}
|
||||
>
|
||||
Trim
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { navigate } from '../App'
|
||||
import { AlertIcon, CheckIcon } from '../components/Icons'
|
||||
|
||||
function Metric({ label, base, next, delta }) {
|
||||
const better = delta != null && delta > 0
|
||||
const worse = delta != null && delta < 0
|
||||
return (
|
||||
<tr>
|
||||
<td>{label}</td>
|
||||
<td className="num">{base == null ? '—' : base.toFixed(4)}</td>
|
||||
<td className="num">{next.toFixed(4)}</td>
|
||||
<td className={`num ${better ? 'better' : worse ? 'worse' : ''}`}>
|
||||
{delta == null ? '—' : `${delta >= 0 ? '+' : ''}${delta.toFixed(4)}`}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function VersionCard({ version, onPromote, onError }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const metrics = version.metrics
|
||||
const base = version.base_metrics
|
||||
|
||||
async function promote() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.promoteModel(version.id)
|
||||
onPromote()
|
||||
} catch (exc) {
|
||||
onError(exc.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel side-panel">
|
||||
<div className="row">
|
||||
<h2>v{version.version}</h2>
|
||||
<span className="spacer" />
|
||||
<span className="faint mono">
|
||||
{new Date(version.created_at * 1000).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{metrics ? (
|
||||
<div className="table-wrap">
|
||||
<table className="video-table metrics">
|
||||
<thead>
|
||||
<tr><th>Metric</th><th>Base</th><th>This version</th><th>Δ</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Metric label="mAP50" base={base?.map50} next={metrics.map50}
|
||||
delta={base ? metrics.map50 - base.map50 : null} />
|
||||
<Metric label="mAP50-95" base={base?.map50_95} next={metrics.map50_95}
|
||||
delta={base ? metrics.map50_95 - base.map50_95 : null} />
|
||||
<Metric label="precision" base={base?.precision} next={metrics.precision}
|
||||
delta={base ? metrics.precision - base.precision : null} />
|
||||
<Metric label="recall" base={base?.recall} next={metrics.recall}
|
||||
delta={base ? metrics.recall - base.recall : null} />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : <p className="hint">No metrics recorded for this version.</p>}
|
||||
|
||||
{!base && (
|
||||
<p className="hint">
|
||||
No base column: the previous model could not be scored on this val set.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="row">
|
||||
<a className="btn" href={api.weightsUrl(version.id)} download>Download best.pt</a>
|
||||
<button className="btn btn-primary" onClick={promote} disabled={busy}>
|
||||
Use as base model
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ModelsPage({ projectId, onProject }) {
|
||||
const [project, setProject] = useState(null)
|
||||
const [summary, setSummary] = useState(null)
|
||||
const [models, setModels] = useState([])
|
||||
const [hardware, setHardware] = useState(null)
|
||||
const [epochs, setEpochs] = useState(50)
|
||||
const [job, setJob] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [selectedBatchIds, setSelectedBatchIds] = useState([])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [loadedProject, loadedSummary, modelPayload, hw, jobsPayload] = await Promise.all([
|
||||
api.getProject(projectId), api.datasetSummary(projectId),
|
||||
api.listModels(projectId), api.hardware(), api.listJobs(projectId),
|
||||
])
|
||||
setProject(loadedProject)
|
||||
onProject?.(loadedProject)
|
||||
setSummary(loadedSummary)
|
||||
setModels(modelPayload.models)
|
||||
setHardware(hw)
|
||||
setSelectedBatchIds(loadedSummary.batches.map((b) => b.id))
|
||||
const activeJob = jobsPayload.jobs?.find((j) => ['running', 'queued'].includes(j.status))
|
||||
if (activeJob) setJob(activeJob)
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load().catch((exc) => setError(exc.message)) }, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || ['done', 'failed', 'cancelled'].includes(job.status)) {
|
||||
if (job?.status === 'done') load().catch(() => {})
|
||||
return
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
api.getJob(job.id).then(setJob).catch(() => {})
|
||||
}, 2000)
|
||||
return () => clearInterval(timer)
|
||||
}, [job?.status, job?.id])
|
||||
|
||||
async function train() {
|
||||
setError('')
|
||||
try {
|
||||
setJob(await api.startTraining(projectId, {
|
||||
epochs: Number(epochs),
|
||||
batch_ids: selectedBatchIds.length > 0 ? selectedBatchIds : null,
|
||||
}))
|
||||
} catch (exc) {
|
||||
setError(exc.message)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleBatchSelect = (id) => {
|
||||
setSelectedBatchIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((bId) => bId !== id) : [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
const toggleSelectAllBatches = () => {
|
||||
if (selectedBatchIds.length === summary.batches.length) {
|
||||
setSelectedBatchIds([])
|
||||
} else {
|
||||
setSelectedBatchIds(summary.batches.map((b) => b.id))
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !project) return <p className="error-banner"><AlertIcon size={14} /> {error}</p>
|
||||
if (!project || !summary) return <p className="empty">Loading…</p>
|
||||
|
||||
const running = job && !['done', 'failed', 'cancelled'].includes(job.status)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>{project.name} · models</h1>
|
||||
<p className="muted">
|
||||
Master dataset: {summary.splits.train} train / {summary.splits.val} val
|
||||
{' '}from {summary.batches.length} merged batch(es)
|
||||
</p>
|
||||
</div>
|
||||
<span className="spacer" />
|
||||
<a className="btn" href={api.datasetDownloadUrl(projectId)} download>Download dataset</a>
|
||||
</div>
|
||||
|
||||
{error && <p className="error-banner" style={{ marginBottom: 12 }}>
|
||||
<AlertIcon size={14} /> {error}
|
||||
</p>}
|
||||
|
||||
<div className="select-engine-section">
|
||||
<h2>Select Engine</h2>
|
||||
<p className="hint">
|
||||
Select how you want to train your model. Configure custom fine-tuning parameters or use SAM3 grounding backbone.
|
||||
</p>
|
||||
|
||||
<div className="engine-grid">
|
||||
<div className="engine-card selected">
|
||||
<div className="engine-card-header">
|
||||
<span className="project-badge">Selected</span>
|
||||
<span className="engine-card-title">Custom Training (YOLO11)</span>
|
||||
</div>
|
||||
<p className="engine-card-desc">
|
||||
Fine-tune on the merged master dataset using pre-configured hardware batch size and image resolution.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="engine-card" title="Neural Architecture Search / Rapid Auto-train">
|
||||
<div className="engine-card-header">
|
||||
<span className="chip-badge info">Rapid NAS</span>
|
||||
<span className="engine-card-title">Neural Architecture Search</span>
|
||||
</div>
|
||||
<p className="engine-card-desc">
|
||||
Automated model selection optimized for latency and accuracy trade-offs on your specific project dataset.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel side-panel" style={{ marginBottom: 20 }}>
|
||||
<h2>Uploaded Models for Training & Auto-Annotation</h2>
|
||||
<p className="hint">Upload up to 2 models to use for fine-tuning baseline or auto-annotating new video batches:</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16, marginTop: 12 }}>
|
||||
<div style={{ padding: 14, background: 'rgba(0,0,0,0.3)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<h3 style={{ fontSize: '0.9rem', color: '#c084fc', margin: '0 0 6px 0' }}>Primary Model 1 (Base Model)</h3>
|
||||
<p className="hint" style={{ fontSize: '0.78rem', margin: '0 0 10px 0' }}>
|
||||
Used as fine-tuning starting point and benchmark baseline.
|
||||
</p>
|
||||
<div style={{ fontSize: '0.8rem', color: '#a1a1aa', marginBottom: 10 }}>
|
||||
Status: <strong style={{ color: '#fff' }}>{project.base_model_path ? 'Custom model.pt loaded' : 'Default yolo11n.pt'}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.78rem', color: '#38bdf8', marginBottom: 10 }}>
|
||||
<strong>Model 1 Classes:</strong> {project.classes?.map((c) => c.name).join(', ')}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pt"
|
||||
id="upload-primary-model"
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await api.uploadBaseModel(projectId, file)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="upload-primary-model" className="btn btn-ghost" style={{ cursor: 'pointer', padding: '4px 10px', fontSize: '0.8rem' }}>
|
||||
Upload Primary Model 1 (.pt)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 14, background: 'rgba(0,0,0,0.3)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<h3 style={{ fontSize: '0.9rem', color: '#38bdf8', margin: '0 0 6px 0' }}>Secondary Model 2 (Auto-Annotate Engine)</h3>
|
||||
<p className="hint" style={{ fontSize: '0.78rem', margin: '0 0 10px 0' }}>
|
||||
Used as an auxiliary engine choice for fast auto-annotation.
|
||||
</p>
|
||||
<div style={{ fontSize: '0.8rem', color: '#a1a1aa', marginBottom: 10 }}>
|
||||
Status: <strong style={{ color: '#fff' }}>{project.secondary_model_path ? (project.secondary_model_name || 'secondary_model.pt') : 'None uploaded'}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.78rem', color: '#c084fc', marginBottom: 10 }}>
|
||||
<strong>Model 2 Native Classes:</strong> {project.secondary_model_classes?.length > 0 ? project.secondary_model_classes.join(', ') : 'Extracted on upload'}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pt"
|
||||
id="upload-secondary-model"
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await api.uploadSecondaryModel(projectId, file)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="upload-secondary-model" className="btn btn-ghost" style={{ cursor: 'pointer', padding: '4px 10px', fontSize: '0.8rem' }}>
|
||||
Upload Secondary Model 2 (.pt)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="review">
|
||||
|
||||
<div className="stack">
|
||||
{models.length === 0 && !running && (
|
||||
<p className="empty">No trained versions yet.</p>
|
||||
)}
|
||||
{models.map((version) => (
|
||||
<VersionCard key={version.id} version={version} onPromote={load}
|
||||
onError={setError} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<aside className="review-side stack">
|
||||
<div className="panel side-panel">
|
||||
<h2>Train</h2>
|
||||
<p className="hint">
|
||||
Fine-tunes{' '}
|
||||
{project.base_model_path
|
||||
? 'this project’s base model'
|
||||
: `${project.base_model_fallback} (no base model uploaded)`}{' '}
|
||||
on the selected dataset batches ({selectedBatchIds.length}/{summary.batches.length} selected).
|
||||
</p>
|
||||
<div>
|
||||
<label htmlFor="epochs">Epochs</label>
|
||||
<input id="epochs" type="number" min="1" max="500" value={epochs}
|
||||
onChange={(e) => setEpochs(e.target.value)} />
|
||||
</div>
|
||||
{hardware && (
|
||||
<p className="hint">
|
||||
{hardware.gpu ?? 'CPU'} — defaults batch {hardware.batch},
|
||||
imgsz {hardware.imgsz}. {hardware.note}
|
||||
</p>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={train}
|
||||
disabled={running || summary.splits.train === 0 || selectedBatchIds.length === 0}>
|
||||
{running ? 'Training…' : 'Start training'}
|
||||
</button>
|
||||
{summary.splits.train === 0 && (
|
||||
<p className="hint">Approve and merge a batch first.</p>
|
||||
)}
|
||||
{selectedBatchIds.length === 0 && summary.splits.train > 0 && (
|
||||
<p className="hint" style={{ color: '#ef4444' }}>Select at least one dataset batch to train.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job && (
|
||||
<div className="panel side-panel">
|
||||
<h2>Job {job.id}</h2>
|
||||
<div className="row">
|
||||
<span className={`dot ${job.status === 'done' ? 'ok' : job.status === 'failed' ? 'bad' : ''}`} />
|
||||
<span>{job.status}</span>
|
||||
<span className="spacer" />
|
||||
<span className="mono">{job.progress}/{job.total}</span>
|
||||
</div>
|
||||
<div className="progress">
|
||||
<span style={{ width: `${job.total ? (job.progress / job.total) * 100 : 0}%` }} />
|
||||
</div>
|
||||
{job.error && <p className="error-banner">{job.error}</p>}
|
||||
<pre className="job-log">{job.log.slice(-8).join('\n')}</pre>
|
||||
{running && (
|
||||
<button className="btn" onClick={() => api.cancelJob(job.id).catch(() => {})}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{job.status === 'done' && (
|
||||
<p className="hint"><CheckIcon size={13} /> Finished — the version is listed
|
||||
on the left.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary.batches.length > 0 && (
|
||||
<div className="panel side-panel">
|
||||
<div className="row" style={{ marginBottom: 8 }}>
|
||||
<h2>Select Dataset Batches</h2>
|
||||
<span className="spacer" />
|
||||
<button className="btn" style={{ fontSize: 11, padding: '2px 8px' }} onClick={toggleSelectAllBatches}>
|
||||
{selectedBatchIds.length === summary.batches.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="hint">Check the approved batches you want to include in this training run:</p>
|
||||
<ul className="shape-list" style={{ marginTop: 8 }}>
|
||||
{summary.batches.map((item) => {
|
||||
const isChecked = selectedBatchIds.includes(item.id)
|
||||
return (
|
||||
<li key={item.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleBatchSelect(item.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<span className="shape-pick" style={{ flex: 1, cursor: 'pointer' }} onClick={() => toggleBatchSelect(item.id)}>
|
||||
{item.date_label} · {item.batch_label}
|
||||
<span className="faint mono" style={{ marginLeft: 6 }}>{item.images} img</span>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, classColor } from '../api'
|
||||
import { navigate } from '../App'
|
||||
import { AlertIcon, PlusIcon, TrashIcon, UploadIcon, XIcon } from '../components/Icons'
|
||||
|
||||
const BLANK_CLASS = { name: '', prompt: '' }
|
||||
const CLASS_PREVIEW = 8
|
||||
|
||||
function NewProjectForm({ defaultRoot, onCreated, onCancel }) {
|
||||
const [name, setName] = useState('')
|
||||
const [labelType, setLabelType] = useState('bbox')
|
||||
const [videoRoot, setVideoRoot] = useState(defaultRoot || '')
|
||||
const [valEvery, setValEvery] = useState(5)
|
||||
const [classes, setClasses] = useState([{ ...BLANK_CLASS }])
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const setClass = (index, patch) =>
|
||||
setClasses((rows) => rows.map((row, i) => (i === index ? { ...row, ...patch } : row)))
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const created = await api.createProject({
|
||||
name,
|
||||
label_type: labelType,
|
||||
video_root: videoRoot,
|
||||
val_every: Number(valEvery),
|
||||
classes: classes
|
||||
.filter((row) => row.name.trim())
|
||||
.map((row) => ({ name: row.name.trim(), prompt: row.prompt.trim() || null })),
|
||||
})
|
||||
onCreated(created)
|
||||
} catch (exc) {
|
||||
setError(exc.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel form-panel" onSubmit={submit}>
|
||||
<h2>New project</h2>
|
||||
|
||||
<div className="field-row">
|
||||
<div>
|
||||
<label htmlFor="np-name">Name</label>
|
||||
<input id="np-name" value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Sack" required autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="np-type">Label type</label>
|
||||
<select id="np-type" value={labelType} onChange={(e) => setLabelType(e.target.value)}>
|
||||
<option value="bbox">Bounding box (YOLO detect)</option>
|
||||
<option value="polygon">Polygon (YOLO segment)</option>
|
||||
</select>
|
||||
<p className="hint">Fixed once the first batch is merged — every label file is written in this format.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="np-val">Val split — every Nth frame</label>
|
||||
<input id="np-val" type="number" min="0" max="50" value={valEvery}
|
||||
onChange={(e) => setValEvery(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="np-root">Video archive root</label>
|
||||
<input id="np-root" value={videoRoot} onChange={(e) => setVideoRoot(e.target.value)}
|
||||
placeholder="/videos" required />
|
||||
<p className="hint">Laid out as <date>/<batch>.mp4. Read-only — nothing is written here.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Classes</label>
|
||||
<div className="class-rows">
|
||||
{classes.map((row, index) => (
|
||||
<div className="class-row" key={index}>
|
||||
<span className="swatch" style={{ background: classColor(index) }} />
|
||||
<input value={row.name} onChange={(e) => setClass(index, { name: e.target.value })}
|
||||
placeholder={`class ${index}`} aria-label={`Class ${index} name`} />
|
||||
<input value={row.prompt} onChange={(e) => setClass(index, { prompt: e.target.value })}
|
||||
placeholder="SAM3 prompt (defaults to the name)"
|
||||
aria-label={`Class ${index} prompt`} />
|
||||
<button type="button" className="btn btn-danger"
|
||||
aria-label={`Remove class ${index}`}
|
||||
disabled={classes.length === 1}
|
||||
onClick={() => setClasses((rows) => rows.filter((_, i) => i !== index))}>
|
||||
<TrashIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="hint">
|
||||
Upload a base model afterwards to replace these with the model's own classes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="error-banner"><AlertIcon size={14} /> {error}</p>}
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="btn btn-primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create project'}
|
||||
</button>
|
||||
<button className="btn btn-ghost" type="button" onClick={onCancel}>Cancel</button>
|
||||
<span className="spacer" />
|
||||
<button className="btn" type="button"
|
||||
onClick={() => setClasses((rows) => [...rows, { ...BLANK_CLASS }])}>
|
||||
<PlusIcon size={14} /> Add class
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectCard({ project, onChanged, onError }) {
|
||||
const fileInputModel1 = useRef(null)
|
||||
const fileInputModel2 = useRef(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [addingClass, setAddingClass] = useState(false)
|
||||
const [newClassName, setNewClassName] = useState('')
|
||||
const [newClassPrompt, setNewClassPrompt] = useState('')
|
||||
|
||||
async function submitAddClass(e) {
|
||||
e.preventDefault()
|
||||
if (!newClassName.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.addClass(project.id, { name: newClassName.trim(), prompt: newClassPrompt.trim() || undefined })
|
||||
setNewClassName('')
|
||||
setNewClassPrompt('')
|
||||
setAddingClass(false)
|
||||
onChanged()
|
||||
} catch (exc) {
|
||||
onError(exc.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeClass(item) {
|
||||
const merged = project.dataset.train + project.dataset.val
|
||||
const consequences = [
|
||||
item.annotation_count > 0 && `${item.annotation_count} shape(s) will be deleted`,
|
||||
merged > 0 && `${merged} label file(s) in the master dataset will be rewritten`,
|
||||
'the classes above it will be renumbered',
|
||||
].filter(Boolean).join(', ')
|
||||
if (!window.confirm(
|
||||
`Delete the class "${item.name}"?\n\n${consequences}.\n\nThis cannot be undone.`
|
||||
)) return
|
||||
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.deleteClass(project.id, item.class_id)
|
||||
onChanged()
|
||||
} catch (exc) {
|
||||
onError(exc.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadModel1(event) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.uploadBaseModel(project.id, file)
|
||||
onChanged()
|
||||
} catch (exc) {
|
||||
onError(exc.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadModel2(event) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.uploadSecondaryModel(project.id, file)
|
||||
onChanged()
|
||||
} catch (exc) {
|
||||
onError(exc.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!window.confirm(`Delete "${project.name}" and everything under it?`)) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.deleteProject(project.id)
|
||||
onChanged()
|
||||
} catch (exc) {
|
||||
onError(exc.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const merged = project.dataset.train + project.dataset.val
|
||||
const insertedCount = [project.base_model_path, project.secondary_model_path].filter(Boolean).length
|
||||
|
||||
return (
|
||||
<div className="panel project-card">
|
||||
<div className="title-row">
|
||||
<h2>{project.name}</h2>
|
||||
<span className="tag" title={project.label_type_locked ? "Locked: batches have already been merged in this format" : undefined}>
|
||||
{project.label_type_locked ? `locked: ${project.label_type}` : project.label_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<dl>
|
||||
<dt>Base models</dt>
|
||||
<dd>
|
||||
<strong>{insertedCount} model(s) inserted</strong>
|
||||
<span className="muted" style={{ fontSize: '0.8rem', display: 'block', marginTop: 2 }}>
|
||||
{insertedCount === 0 && `none — default (${project.base_model_fallback})`}
|
||||
{insertedCount === 1 && (project.base_model_path ? 'Model 1 (Primary)' : 'Model 2 (Secondary)')}
|
||||
{insertedCount === 2 && 'Model 1 (Primary) & Model 2 (Secondary)'}
|
||||
</span>
|
||||
</dd>
|
||||
<dt>Archive</dt>
|
||||
<dd className="mono">{project.video_root}</dd>
|
||||
<dt>Batches</dt>
|
||||
<dd>{project.batch_count}</dd>
|
||||
<dt>Dataset</dt>
|
||||
<dd>{merged ? `${project.dataset.train} train / ${project.dataset.val} val` : 'empty'}</dd>
|
||||
</dl>
|
||||
|
||||
{/* A base model can carry 80 classes; showing them all buries the card,
|
||||
but they all have to be reachable to be deletable. */}
|
||||
<div className="classes">
|
||||
{(expanded ? project.classes : project.classes.slice(0, CLASS_PREVIEW)).map((item) => (
|
||||
<span className="tag class-tag" key={item.class_id}
|
||||
title={`prompt: ${item.prompt}`}>
|
||||
<span className="swatch" style={{ background: classColor(item.class_id) }} />
|
||||
{item.name}
|
||||
{item.annotation_count > 0 && (
|
||||
<span className="faint mono">{item.annotation_count}</span>
|
||||
)}
|
||||
{project.classes.length > 1 && (
|
||||
<button className="chip-x" disabled={busy}
|
||||
aria-label={`Delete class ${item.name}`}
|
||||
onClick={() => removeClass(item)}>
|
||||
<XIcon size={11} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{project.classes.length > CLASS_PREVIEW && (
|
||||
<button className="tag" onClick={() => setExpanded((v) => !v)}>
|
||||
{expanded ? 'show fewer' : `+${project.classes.length - CLASS_PREVIEW} more`}
|
||||
</button>
|
||||
)}
|
||||
<button className="tag" onClick={() => setAddingClass((v) => !v)} title="Add a new class to this project">
|
||||
<PlusIcon size={11} /> {addingClass ? 'cancel' : 'add class'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addingClass && (
|
||||
<form className="add-class-inline" onSubmit={submitAddClass} style={{ display: 'flex', gap: 6, margin: '8px 0', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Class name (e.g. half-sack)"
|
||||
value={newClassName}
|
||||
onChange={(e) => setNewClassName(e.target.value)}
|
||||
required
|
||||
style={{ flex: 1, padding: '4px 8px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="SAM3 Prompt (optional)"
|
||||
value={newClassPrompt}
|
||||
onChange={(e) => setNewClassPrompt(e.target.value)}
|
||||
style={{ flex: 1, padding: '4px 8px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
<button className="btn btn-primary" type="submit" disabled={busy || !newClassName.trim()} style={{ padding: '4px 10px', fontSize: '0.85rem' }}>
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="actions" style={{ flexWrap: 'wrap', gap: 6 }}>
|
||||
<button className="btn btn-primary" onClick={() => navigate(`/projects/${project.id}`)}>
|
||||
Open
|
||||
</button>
|
||||
<button className="btn" onClick={() => fileInputModel1.current?.click()} disabled={busy} title="Upload Primary Base Model 1 (.pt)">
|
||||
<UploadIcon size={14} /> Model 1
|
||||
</button>
|
||||
<input ref={fileInputModel1} type="file" accept=".pt" hidden onChange={uploadModel1} />
|
||||
|
||||
<button className="btn" onClick={() => fileInputModel2.current?.click()} disabled={busy} title="Upload Secondary Auto-Annotate Model 2 (.pt)">
|
||||
<UploadIcon size={14} /> Model 2
|
||||
</button>
|
||||
<input ref={fileInputModel2} type="file" accept=".pt" hidden onChange={uploadModel2} />
|
||||
|
||||
<span className="spacer" />
|
||||
<button className="btn btn-danger" onClick={remove} disabled={busy}
|
||||
aria-label={`Delete ${project.name}`}>
|
||||
<TrashIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [projects, setProjects] = useState(null)
|
||||
const [defaultRoot, setDefaultRoot] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const payload = await api.listProjects()
|
||||
setProjects(payload.projects)
|
||||
setDefaultRoot(payload.video_root_default)
|
||||
} catch (exc) {
|
||||
setError(exc.message)
|
||||
setProjects([])
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Projects</h1>
|
||||
<p className="muted">
|
||||
One project per model you are improving: its base weights, its classes, its dataset.
|
||||
</p>
|
||||
</div>
|
||||
<span className="spacer" />
|
||||
{!creating && (
|
||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
<PlusIcon size={14} /> New project
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="error-banner" style={{ marginBottom: 14 }}><AlertIcon size={14} /> {error}</p>}
|
||||
|
||||
{creating && (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<NewProjectForm
|
||||
defaultRoot={defaultRoot}
|
||||
onCancel={() => setCreating(false)}
|
||||
onCreated={() => { setCreating(false); setError(''); load() }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects === null && <p className="empty">Loading…</p>}
|
||||
{projects?.length === 0 && !creating && (
|
||||
<p className="empty">No projects yet. Create one to start enriching a dataset.</p>
|
||||
)}
|
||||
|
||||
<div className="card-grid">
|
||||
{projects?.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} onChanged={load} onError={setError} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { navigate } from '../App'
|
||||
import AnnotationCanvas from '../components/AnnotationCanvas'
|
||||
import Filmstrip from '../components/Filmstrip'
|
||||
import { AlertIcon, CheckIcon, XIcon } from '../components/Icons'
|
||||
import QuickReclassBar from '../components/QuickReclassBar'
|
||||
import ReviewSidebar from '../components/ReviewSidebar'
|
||||
|
||||
export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }) {
|
||||
const [batch, setBatch] = useState(null)
|
||||
const [project, setProject] = useState(null)
|
||||
const [frames, setFrames] = useState([])
|
||||
const [index, setIndex] = useState(0)
|
||||
const [annotations, setAnnotations] = useState([])
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [activeClass, setActiveClass] = useState(0)
|
||||
const [assistMode, setAssistMode] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [activeJob, setActiveJob] = useState(null)
|
||||
|
||||
const batchId = batch?.id || rawBatchId
|
||||
const stripRef = useRef(null)
|
||||
const hasInitialAutoJump = useRef(false)
|
||||
const frame = frames[index]
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
let targetBatchId = rawBatchId
|
||||
if (!targetBatchId && projectId) {
|
||||
const bList = await api.listBatches(projectId)
|
||||
if (bList?.batches?.length > 0) targetBatchId = bList.batches[0].id
|
||||
}
|
||||
if (!targetBatchId) throw new Error('No batch found to review')
|
||||
|
||||
const loaded = await api.getBatch(targetBatchId)
|
||||
setBatch(loaded)
|
||||
const payload = await api.listFrames(targetBatchId)
|
||||
setFrames(payload.frames)
|
||||
if (!hasInitialAutoJump.current && payload.frames?.length > 0) {
|
||||
hasInitialAutoJump.current = true
|
||||
const firstAnnotated = payload.frames.findIndex((f) => (f.annotation_count ?? 0) > 0)
|
||||
if (firstAnnotated > 0) setIndex(firstAnnotated)
|
||||
}
|
||||
const jobsPayload = await api.listJobs(loaded.project_id).catch(() => ({ jobs: [] }))
|
||||
const currentJob = jobsPayload.jobs?.find(
|
||||
(j) => j.batch_id === Number(targetBatchId) && ['queued', 'running'].includes(j.status)
|
||||
)
|
||||
setActiveJob(currentJob || null)
|
||||
return loaded
|
||||
}, [rawBatchId, projectId])
|
||||
|
||||
useEffect(() => {
|
||||
reload().then((loaded) => api.getProject(loaded.project_id)).then((loadedProject) => {
|
||||
setProject(loadedProject)
|
||||
onProject?.(loadedProject)
|
||||
}).catch((exc) => setError(exc.message))
|
||||
}, [rawBatchId, projectId, reload])
|
||||
|
||||
const prevJobId = useRef(activeJob?.id)
|
||||
useEffect(() => {
|
||||
if (prevJobId.current && !activeJob) {
|
||||
hasInitialAutoJump.current = false
|
||||
reload().catch(() => {})
|
||||
}
|
||||
prevJobId.current = activeJob?.id
|
||||
|
||||
if (!activeJob) return
|
||||
const timer = setInterval(() => {
|
||||
reload().catch(() => {})
|
||||
if (frame) api.frameAnnotations(frame.id).then((p) => setAnnotations(p.annotations)).catch(() => {})
|
||||
}, 2000)
|
||||
return () => clearInterval(timer)
|
||||
}, [activeJob, reload, frame?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!frame) return
|
||||
let cancelled = false
|
||||
api.frameAnnotations(frame.id).then((payload) => !cancelled && setAnnotations(payload.annotations)).catch((exc) => !cancelled && setError(exc.message))
|
||||
setSelectedId(null)
|
||||
return () => { cancelled = true }
|
||||
}, [frame?.id])
|
||||
|
||||
useEffect(() => {
|
||||
const active = stripRef.current?.querySelector('[aria-current="true"]')
|
||||
active?.scrollIntoView({ block: 'nearest', inline: 'center' })
|
||||
}, [index])
|
||||
|
||||
function patchFrameLocally(frameId, patch) {
|
||||
setFrames((rows) => rows.map((row) => (row.id === frameId ? { ...row, ...patch } : row)))
|
||||
}
|
||||
|
||||
const setStatus = useCallback(async (status) => {
|
||||
if (!frame) return
|
||||
patchFrameLocally(frame.id, { review_status: status })
|
||||
try {
|
||||
await api.setFrameStatus(frame.id, status)
|
||||
setBatch(await api.getBatch(batchId))
|
||||
} catch (exc) { setError(exc.message) }
|
||||
setIndex((current) => Math.min(current + 1, frames.length - 1))
|
||||
}, [frame, frames.length, batchId])
|
||||
|
||||
async function createShape(geometry) {
|
||||
try {
|
||||
const created = await api.addAnnotation(frame.id, { class_id: activeClass, geometry })
|
||||
setAnnotations((rows) => [...rows, created])
|
||||
setSelectedId(created.id)
|
||||
patchFrameLocally(frame.id, { annotation_count: (frame.annotation_count ?? 0) + 1 })
|
||||
} catch (exc) { setError(exc.message) }
|
||||
}
|
||||
|
||||
async function assist(box) {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const created = await api.assist(frame.id, { box, class_id: activeClass })
|
||||
setAnnotations((rows) => [...rows, created])
|
||||
setSelectedId(created.id)
|
||||
} catch (exc) { setError(exc.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
async function updateShape(id, geometry, { local, commit } = {}) {
|
||||
if (local && geometry) {
|
||||
setAnnotations((rows) => rows.map((row) => (row.id === id ? { ...row, geometry } : row)))
|
||||
return
|
||||
}
|
||||
if (!commit) return
|
||||
const current = annotations.find((row) => row.id === id)
|
||||
if (!current) return
|
||||
try { await api.patchAnnotation(id, { geometry: current.geometry }) } catch (exc) { setError(exc.message) }
|
||||
}
|
||||
|
||||
const removeSelected = useCallback(async () => {
|
||||
if (selectedId == null || !frame) return
|
||||
const id = selectedId
|
||||
setAnnotations((rows) => rows.filter((row) => row.id !== id))
|
||||
setSelectedId(null)
|
||||
setFrames((rows) => rows.map((row) => (row.id === frame.id ? { ...row, annotation_count: Math.max(0, (row.annotation_count ?? 1) - 1) } : row)))
|
||||
try { await api.deleteAnnotation(id) } catch (exc) { setError(exc.message) }
|
||||
}, [selectedId, frame])
|
||||
|
||||
const reclass = useCallback(async (classId) => {
|
||||
setActiveClass(classId)
|
||||
if (selectedId == null) return
|
||||
try {
|
||||
const updated = await api.patchAnnotation(selectedId, { class_id: classId })
|
||||
setAnnotations((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
|
||||
} catch (exc) { setError(exc.message) }
|
||||
}, [selectedId])
|
||||
|
||||
async function approveBatch() {
|
||||
try { await api.approveBatch(batchId); navigate(`/projects/${batch.project_id}/models`) } catch (exc) { setError(exc.message) }
|
||||
}
|
||||
|
||||
async function jumpToPending() {
|
||||
try {
|
||||
const { frame_id: frameId } = await api.nextPending(batchId, frame?.idx ?? -1)
|
||||
const position = frames.findIndex((row) => row.id === frameId)
|
||||
if (position >= 0) setIndex(position)
|
||||
} catch (exc) { setError(exc.message) }
|
||||
}
|
||||
|
||||
const jumpToNextAnnotated = useCallback(() => {
|
||||
if (!frames?.length) return
|
||||
const nextIdx = frames.findIndex((f, idx) => idx > index && (f.annotation_count ?? 0) > 0)
|
||||
if (nextIdx >= 0) setIndex(nextIdx)
|
||||
else {
|
||||
const firstIdx = frames.findIndex((f) => (f.annotation_count ?? 0) > 0)
|
||||
if (firstIdx >= 0) setIndex(firstIdx)
|
||||
}
|
||||
}, [frames, index])
|
||||
|
||||
const stateRef = useRef({})
|
||||
stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode }
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event) {
|
||||
if (event.target?.matches?.('input, textarea, select, [contenteditable="true"]')) return
|
||||
const { frames, project, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode } = stateRef.current
|
||||
const key = event.key
|
||||
const isShortcutKey = /^[1-9]$/.test(key) || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Delete', 'Backspace', 'a', 'A', 'x', 'X', 'u', 'U', 's', 'S', 'n', 'N'].includes(key)
|
||||
if (isShortcutKey) { event.preventDefault(); event.stopPropagation() }
|
||||
|
||||
if (key === 's' || key === 'S') setAssistMode?.(true)
|
||||
else if (key === 'ArrowLeft') setIndex((i) => Math.max(0, i - 1))
|
||||
else if (key === 'ArrowRight') setIndex((i) => Math.min((frames?.length || 1) - 1, i + 1))
|
||||
else if (key === 'a' || key === 'A') setStatus?.('approved')
|
||||
else if (key === 'x' || key === 'X') setStatus?.('rejected')
|
||||
else if (key === 'u' || key === 'U') jumpToPending?.()
|
||||
else if (key === 'n' || key === 'N') jumpToNextAnnotated?.()
|
||||
else if (key === 'Delete' || key === 'Backspace') removeSelected?.()
|
||||
else if (/^[1-9]$/.test(key)) {
|
||||
const classId = Number(key) - 1
|
||||
if (project && classId < project.classes.length) reclass?.(classId)
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyUp(event) {
|
||||
if (event.target?.matches?.('input, textarea, select, [contenteditable="true"]')) return
|
||||
if (event.key === 's' || event.key === 'S') { event.preventDefault(); event.stopPropagation(); stateRef.current.setAssistMode?.(false) }
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
document.addEventListener('keyup', onKeyUp, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown, true)
|
||||
document.removeEventListener('keyup', onKeyUp, true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (error && !batch) return <p className="error-banner" style={{ margin: 20 }}><AlertIcon size={14} /> {error}</p>
|
||||
if (!batch || !project) return <p className="empty" style={{ margin: 20, color: 'var(--text-muted)' }}>Loading batch data…</p>
|
||||
|
||||
const reviewed = (batch.review?.approved ?? 0) + (batch.review?.rejected ?? 0)
|
||||
const classesList = project.classes ?? []
|
||||
|
||||
async function approveAllFrames() {
|
||||
if (!window.confirm(`Mark all ${batch.review?.pending ?? 0} pending frames as approved?`)) return
|
||||
try { await api.approveAllBatchFrames(batchId); await reload() } catch (exc) { setError(exc.message) }
|
||||
}
|
||||
|
||||
async function clearClassInBatch(item) {
|
||||
if (!window.confirm(`Clear all shapes of class "${item.name}" across ALL frames in this batch?`)) return
|
||||
try {
|
||||
await api.clearBatchClassAnnotations(batchId, item.class_id)
|
||||
await reload()
|
||||
if (frame) {
|
||||
const payload = await api.frameAnnotations(frame.id)
|
||||
setAnnotations(payload.annotations)
|
||||
}
|
||||
} catch (exc) { setError(exc.message) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>{batch.date_label} · {batch.batch_label}</h1>
|
||||
<p className="muted">
|
||||
{batch.frame_count} frames · {reviewed}/{batch.frame_count} reviewed ·
|
||||
{' '}<strong style={{ color: batch.annotation_count > 0 ? '#c084fc' : '#a1a1aa' }}>{batch.annotation_count} shapes</strong> · status {batch.status}
|
||||
</p>
|
||||
</div>
|
||||
<span className="spacer" />
|
||||
{(batch.review?.pending ?? 0) > 0 && batch.status !== 'merged' && (
|
||||
<button className="btn" style={{ marginRight: 8 }} title="Mark all pending frames in this batch as approved" onClick={approveAllFrames}>
|
||||
Approve All Frames ({batch.review?.pending})
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={(batch.review?.pending ?? 0) > 0 || batch.status === 'merged'}
|
||||
title={(batch.review?.pending ?? 0) > 0 ? `${batch.review?.pending} frame(s) still pending` : 'Merge the approved frames into the master dataset'}
|
||||
onClick={approveBatch}
|
||||
>
|
||||
{batch.status === 'merged' ? 'Merged' : 'Approve batch'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeJob && (
|
||||
<div className="panel side-panel" style={{ marginBottom: 12, border: '1px solid rgba(168, 85, 247, 0.4)', background: 'rgba(24, 24, 27, 0.8)' }}>
|
||||
<div className="row" style={{ fontSize: '0.85rem' }}>
|
||||
<span className="dot ok" />
|
||||
<strong style={{ textTransform: 'capitalize' }}>Auto-labeling in progress…</strong>
|
||||
<span className="spacer" />
|
||||
<span className="mono">{activeJob.progress}/{activeJob.total || '—'} frames</span>
|
||||
</div>
|
||||
<div className="progress" style={{ margin: '6px 0' }}>
|
||||
<span style={{ width: `${activeJob.total ? (activeJob.progress / activeJob.total) * 100 : 50}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="error-banner" style={{ marginBottom: 12 }}><AlertIcon size={14} /> {error}</p>}
|
||||
|
||||
<div className="review">
|
||||
<div className="review-main">
|
||||
{frame && (
|
||||
<AnnotationCanvas
|
||||
frame={frame}
|
||||
imageUrl={api.frameUrl(frame.id)}
|
||||
annotations={annotations}
|
||||
selectedId={selectedId}
|
||||
activeClass={activeClass}
|
||||
assistMode={assistMode}
|
||||
classes={classesList}
|
||||
onSelect={setSelectedId}
|
||||
onCreate={createShape}
|
||||
onUpdate={updateShape}
|
||||
onAssist={assist}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="frame-bar">
|
||||
<button className="btn" onClick={() => setIndex((i) => Math.max(0, i - 1))} disabled={index === 0}>←</button>
|
||||
<span className="mono">
|
||||
{index + 1} / {frames.length}
|
||||
{frame && <span className={`status-pill ${frame.review_status}`}>{frame.review_status}</span>}
|
||||
</span>
|
||||
<button className="btn" onClick={() => setIndex((i) => Math.min(frames.length - 1, i + 1))} disabled={index >= frames.length - 1}>→</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ padding: '2px 8px', fontSize: '0.78rem', borderColor: 'rgba(168, 85, 247, 0.5)', color: '#c084fc', background: 'rgba(168, 85, 247, 0.1)' }}
|
||||
onClick={jumpToNextAnnotated}
|
||||
title="Jump to next frame with annotations [N]"
|
||||
>
|
||||
🏷️ Next Shape [N]
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
{busy && <span className="muted">asking SAM3…</span>}
|
||||
<button className="btn btn-danger" onClick={() => setStatus('rejected')}><XIcon size={14} /> Reject [X]</button>
|
||||
<button className="btn btn-primary" onClick={() => setStatus('approved')}><CheckIcon size={14} /> Approve [A]</button>
|
||||
</div>
|
||||
|
||||
{selectedId != null && (
|
||||
<QuickReclassBar classesList={classesList} reclass={reclass} removeSelected={removeSelected} />
|
||||
)}
|
||||
|
||||
<Filmstrip frames={frames} index={index} onSelectIndex={setIndex} stripRef={stripRef} />
|
||||
</div>
|
||||
|
||||
<ReviewSidebar
|
||||
classesList={classesList}
|
||||
activeClass={activeClass}
|
||||
reclass={reclass}
|
||||
clearClassInBatch={clearClassInBatch}
|
||||
annotations={annotations}
|
||||
selectedId={selectedId}
|
||||
setSelectedId={setSelectedId}
|
||||
removeSelected={removeSelected}
|
||||
project={project}
|
||||
jumpToNextAnnotated={jumpToNextAnnotated}
|
||||
batchAnnotationCount={batch?.annotation_count ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, formatDuration } from '../api'
|
||||
import { navigate } from '../App'
|
||||
import { AlertIcon, CheckIcon } from '../components/Icons'
|
||||
|
||||
function timecode(seconds) {
|
||||
const total = Math.max(0, seconds || 0)
|
||||
const m = Math.floor(total / 60)
|
||||
const s = Math.floor(total % 60)
|
||||
const cs = Math.floor((total % 1) * 10)
|
||||
return `${m}:${String(s).padStart(2, '0')}.${cs}`
|
||||
}
|
||||
|
||||
function parseTimecode(text, fallback) {
|
||||
const trimmed = String(text).trim()
|
||||
if (!trimmed) return fallback
|
||||
const parts = trimmed.split(':').map(Number)
|
||||
if (parts.some(Number.isNaN)) return fallback
|
||||
return parts.length === 1 ? parts[0] : parts[0] * 60 + parts[1]
|
||||
}
|
||||
|
||||
export default function TrimPage({ projectId, rel }) {
|
||||
const videoRef = useRef(null)
|
||||
const [info, setInfo] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [start, setStart] = useState(0)
|
||||
const [end, setEnd] = useState(0)
|
||||
const [fps, setFps] = useState(1)
|
||||
const [playhead, setPlayhead] = useState(0)
|
||||
const [job, setJob] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.videoInfo(projectId, rel)
|
||||
.then((payload) => {
|
||||
setInfo(payload)
|
||||
setEnd(Math.min(payload.duration, 60))
|
||||
})
|
||||
.catch((exc) => setError(exc.message))
|
||||
}, [projectId, rel])
|
||||
|
||||
// Poll the extraction job until it stops moving.
|
||||
useEffect(() => {
|
||||
if (!job || ['done', 'failed', 'cancelled'].includes(job.status)) return
|
||||
const timer = setInterval(() => {
|
||||
api.getJob(job.id).then(setJob).catch(() => {})
|
||||
}, 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [job])
|
||||
|
||||
const duration = info?.duration || 0
|
||||
const estimated = Math.max(0, Math.round((end - start) * fps))
|
||||
|
||||
function seek(seconds) {
|
||||
if (videoRef.current) videoRef.current.currentTime = seconds
|
||||
}
|
||||
|
||||
async function extract() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const batch = await api.createBatch(projectId, {
|
||||
rel, start_sec: start, end_sec: end, fps: Number(fps),
|
||||
})
|
||||
const payload = await api.listJobs(projectId)
|
||||
setJob(payload.jobs.find((item) => item.batch_id === batch.id) ?? null)
|
||||
} catch (exc) {
|
||||
setError(exc.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !info) return <p className="error-banner"><AlertIcon size={14} /> {error}</p>
|
||||
if (!info) return <p className="empty">Reading the video…</p>
|
||||
|
||||
const finished = job?.status === 'done'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>{info.date_label} · {info.batch_label}</h1>
|
||||
<p className="muted">
|
||||
{formatDuration(duration)} · {info.width}×{info.height} · {info.fps} fps source
|
||||
</p>
|
||||
</div>
|
||||
<span className="spacer" />
|
||||
<button className="btn btn-ghost" onClick={() => navigate(`/projects/${projectId}`)}>
|
||||
Back to library
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="trim">
|
||||
<div className="panel trim-player">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={api.videoUrl(projectId, rel)}
|
||||
controls
|
||||
preload="metadata"
|
||||
onTimeUpdate={(e) => setPlayhead(e.target.currentTime)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel trim-controls stack">
|
||||
<h2>Range</h2>
|
||||
|
||||
<div className="range-group">
|
||||
<label htmlFor="trim-start">Start — {timecode(start)}</label>
|
||||
<input
|
||||
id="trim-start" type="range" min="0" max={duration} step="0.1" value={start}
|
||||
onChange={(e) => {
|
||||
const value = Math.min(Number(e.target.value), end - 0.1)
|
||||
setStart(value)
|
||||
seek(value)
|
||||
}}
|
||||
/>
|
||||
<div className="row">
|
||||
<input
|
||||
aria-label="Start timecode" value={timecode(start)}
|
||||
onChange={(e) => setStart(Math.min(parseTimecode(e.target.value, start), end - 0.1))}
|
||||
/>
|
||||
<button className="btn" onClick={() => setStart(Math.min(playhead, end - 0.1))}>
|
||||
Use playhead
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="range-group">
|
||||
<label htmlFor="trim-end">End — {timecode(end)}</label>
|
||||
<input
|
||||
id="trim-end" type="range" min="0" max={duration} step="0.1" value={end}
|
||||
onChange={(e) => {
|
||||
const value = Math.max(Number(e.target.value), start + 0.1)
|
||||
setEnd(value)
|
||||
seek(value)
|
||||
}}
|
||||
/>
|
||||
<div className="row">
|
||||
<input
|
||||
aria-label="End timecode" value={timecode(end)}
|
||||
onChange={(e) => setEnd(Math.max(parseTimecode(e.target.value, end), start + 0.1))}
|
||||
/>
|
||||
<button className="btn" onClick={() => setEnd(Math.max(playhead, start + 0.1))}>
|
||||
Use playhead
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="trim-fps">Frames per second</label>
|
||||
<input
|
||||
id="trim-fps" type="number" min="0.1" max="30" step="0.1" value={fps}
|
||||
onChange={(e) => setFps(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="hint">
|
||||
{formatDuration(end - start)} of video → <strong>{estimated}</strong> frame(s)
|
||||
</p>
|
||||
|
||||
{error && <p className="error-banner"><AlertIcon size={14} /> {error}</p>}
|
||||
|
||||
{job && (
|
||||
<div className="job-status">
|
||||
<div className="row">
|
||||
<span className={`dot ${job.status === 'done' ? 'ok' : job.status === 'failed' ? 'bad' : ''}`} />
|
||||
<span>{job.status}</span>
|
||||
<span className="spacer" />
|
||||
<span className="mono">{job.progress}/{job.total || estimated}</span>
|
||||
</div>
|
||||
<div className="progress">
|
||||
<span style={{ width: `${job.total ? (job.progress / job.total) * 100 : 0}%` }} />
|
||||
</div>
|
||||
{job.error && <p className="error-banner">{job.error}</p>}
|
||||
{job.message && <p className="hint">{job.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="btn btn-primary" onClick={extract}
|
||||
disabled={busy || estimated === 0 || (job && !['done', 'failed', 'cancelled'].includes(job.status))}>
|
||||
{finished ? <><CheckIcon size={14} /> Extracted</> : 'Extract frames'}
|
||||
</button>
|
||||
{job && !['done', 'failed', 'cancelled'].includes(job.status) && (
|
||||
<button className="btn" onClick={() => api.cancelJob(job.id).catch(() => {})}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/* frontend/src/roboflow.css */
|
||||
|
||||
.roboflow-layout {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0b0f19);
|
||||
color: var(--text, #f3f4f6);
|
||||
}
|
||||
|
||||
.roboflow-sidebar {
|
||||
width: 260px;
|
||||
background: rgba(17, 24, 39, 0.65);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 0;
|
||||
flex-shrink: 0;
|
||||
transition: width 0.2s ease, padding 0.2s ease;
|
||||
}
|
||||
|
||||
.roboflow-sidebar.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 0 20px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.roboflow-sidebar.collapsed .sidebar-header {
|
||||
padding: 0 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-faint, #9ca3af);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.roboflow-sidebar.collapsed .sidebar-item {
|
||||
padding: 12px 0;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.roboflow-sidebar.collapsed .sidebar-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
padding: 0 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-faint, #9ca3af);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 20px;
|
||||
color: var(--text-muted, #d1d5db);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: var(--text);
|
||||
border-right: 3px solid #a855f7;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-theme-toggle {
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: var(--text-muted);
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sidebar-theme-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sidebar-health {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.health-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.roboflow-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Models page engine cards */
|
||||
.select-engine-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.engine-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.engine-card {
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.engine-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.engine-card.selected {
|
||||
border-color: #a855f7;
|
||||
background: rgba(168, 85, 247, 0.05);
|
||||
}
|
||||
|
||||
.engine-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.engine-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.project-badge, .chip-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-badge {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.chip-badge.info {
|
||||
background: rgba(56, 189, 248, 0.2);
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.engine-card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Light mode overrides */
|
||||
[data-theme='light'] {
|
||||
--bg: #f3f4f6;
|
||||
--text: #111827;
|
||||
--text-muted: #4b5563;
|
||||
--text-faint: #6b7280;
|
||||
}
|
||||
|
||||
[data-theme='light'] .roboflow-sidebar {
|
||||
background: #ffffff;
|
||||
border-right-color: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-theme='light'] .sidebar-item:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
[data-theme='light'] .sidebar-item.active {
|
||||
background: #faf5ff;
|
||||
}
|
||||
|
||||
[data-theme='light'] .engine-card {
|
||||
background: #ffffff;
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-theme='light'] .engine-card:hover {
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
[data-theme='light'] .engine-card.selected {
|
||||
border-color: #a855f7;
|
||||
background: #faf5ff;
|
||||
}
|
||||
|
||||
[data-theme='light'] .sidebar-health {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden !important;
|
||||
background-color: #0b0f19;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--bg: #0b0f19;
|
||||
--panel: rgba(17, 24, 39, 0.5);
|
||||
--panel-raised: rgba(30, 41, 59, 0.7);
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
--border-strong: rgba(168, 85, 247, 0.45);
|
||||
|
||||
--text: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--text-faint: #6b7280;
|
||||
|
||||
--accent: #a855f7;
|
||||
--accent-hover: #c084fc;
|
||||
--accent-contrast: #ffffff;
|
||||
--accent-soft: rgba(168, 85, 247, 0.22);
|
||||
|
||||
--ok: #10b981;
|
||||
--warn: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--danger-soft: rgba(239, 68, 68, 0.2);
|
||||
|
||||
/* Class colours */
|
||||
--class-0: #f59e0b;
|
||||
--class-1: #38bdf8;
|
||||
--class-2: #10b981;
|
||||
--class-3: #facc15;
|
||||
--class-4: #6366f1;
|
||||
--class-5: #f97316;
|
||||
--class-6: #ec4899;
|
||||
--class-7: #9ca3af;
|
||||
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--space: 8px;
|
||||
--font: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
|
||||
--transition: 160ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Custom HUD Scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h1 { font-size: 20px; }
|
||||
h2 { font-size: 16px; }
|
||||
h3 { font-size: 14px; }
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
code, .mono { font-family: var(--mono); font-size: 12px; }
|
||||
|
||||
/* Every interactive element gets a pointer and a visible focus ring — the
|
||||
* review screens have to be usable without a mouse at all. */
|
||||
button, [role="button"], summary, label.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-raised);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
transition: background var(--transition), border-color var(--transition),
|
||||
color var(--transition);
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--border);
|
||||
border-color: var(--text-faint);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--danger);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--danger-soft);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--panel-raised);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
input:hover, select:hover, textarea:hover { border-color: var(--text-faint); }
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
select { cursor: pointer; }
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.muted { color: var(--text-muted); }
|
||||
.faint { color: var(--text-faint); }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--space) * 1.5);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--space) * 1.5);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-raised);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--danger-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// In dev the SPA runs on 5173 and the API on 8000; this proxy keeps every fetch
|
||||
// same-origin, so the code is identical to how nginx serves it in Docker.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.API_URL || 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user