diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fae1519 --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# netfelix-audio-fix — environment configuration +# Copy this file to .env and fill in your values. +# Bun auto-loads .env on every start; .env.development/.env.test/.env.production +# are loaded additionally for the matching NODE_ENV. +# +# When JELLYFIN_URL + JELLYFIN_API_KEY are set, the setup wizard is skipped. +# JELLYFIN_USER_ID is optional — omit it when using an admin API key (uses /Items directly). + +# ── Server ──────────────────────────────────────────────────────────────────── +PORT=3000 +DATA_DIR=./data + +# ── Jellyfin ────────────────────────────────────────────────────────────────── +JELLYFIN_URL=http://jellyfin.local:8096 +JELLYFIN_API_KEY=your-jellyfin-api-key +# JELLYFIN_USER_ID= # optional; omit when using an admin API key + +# ── Radarr (optional) ───────────────────────────────────────────────────────── +RADARR_URL=http://radarr.local:7878 +RADARR_API_KEY=your-radarr-api-key +RADARR_ENABLED=false + +# ── Sonarr (optional) ───────────────────────────────────────────────────────── +SONARR_URL=http://sonarr.local:8989 +SONARR_API_KEY=your-sonarr-api-key +SONARR_ENABLED=false + +# ── Subtitle languages ──────────────────────────────────────────────────────── +# Comma-separated ISO 639-2 codes to keep. Forced and hearing-impaired tracks +# are always kept regardless of this setting. +SUBTITLE_LANGUAGES=eng,deu,spa + +# ── Media paths (optional) ──────────────────────────────────────────────────── +# Host-side paths for your media libraries. Used to generate Docker commands on +# the review page. Jellyfin always exposes libraries at /movies and /series, so +# these are the left-hand sides of the Docker volume mounts: +# -v /mnt/user/storage/Movies:/movies → MOVIES_PATH=/mnt/user/storage/Movies +# -v /mnt/user/storage/Series:/series → SERIES_PATH=/mnt/user/storage/Series +# +# MOVIES_PATH=/mnt/user/storage/Movies +# SERIES_PATH=/mnt/user/storage/Series diff --git a/.gitignore b/.gitignore index 72ed8fd..68ecc02 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ data/*.db-shm data/*.db-wal bun.lockb .env +.env.local +.env.development +.env.test +.env.production +dist/ +src/routeTree.gen.ts diff --git a/.vexp/.gitattributes b/.vexp/.gitattributes new file mode 100644 index 0000000..7ebdf05 --- /dev/null +++ b/.vexp/.gitattributes @@ -0,0 +1,2 @@ +# Generated by vexp — index.db is gitignored, only manifest.json is tracked +manifest.json merge=union diff --git a/.vexp/.gitignore b/.vexp/.gitignore new file mode 100644 index 0000000..7206987 --- /dev/null +++ b/.vexp/.gitignore @@ -0,0 +1,7 @@ +# vexp — index.db is rebuilt from manifest.json on checkout +index.db +index.db-wal +index.db-shm +vexp.log +daemon.sock +daemon.pid diff --git a/.vexp/manifest.json b/.vexp/manifest.json new file mode 100644 index 0000000..f0b473a --- /dev/null +++ b/.vexp/manifest.json @@ -0,0 +1,35 @@ +{ + "file_hashes": { + "src/api/execute.tsx": "1ee30bfe3ecd56ff84db636476c92a857966fe6e97db6494cc52a5ecd5280c2a", + "src/api/nodes.tsx": "a923cdb333d9a1a2b4db79f6d91e1eaa4bbc7b6ad24948eb66dd0c0a58970d0f", + "src/api/review.tsx": "b7fd6515438c7ac9594f6cf64885c11d050fa515596189081749efe669aa9b59", + "src/api/scan.tsx": "473cde4f1091c263abd86ff2a0a8c46a21670e3aed510cc6c4df9a904518dffd", + "src/api/setup.tsx": "34e9c8f008bcdfa1c8cb56be30a82bf84f816b31e9607085eabae02fd2f2d648", + "src/db/index.ts": "0b88bc0cb1c61769ffe3251416461e8ad9dfbb853228b71cf4b67dd97253610a", + "src/db/schema.ts": "27c3d49055815863bad5d6a70e40f16ea634d77b52549c998881b4c1b289586e", + "src/server.tsx": "9b0bc0e7c2f5cd5cf0dc9334a10bd0749c64f637c826f5a3d555e7a4ba41dcdb", + "src/services/analyzer.ts": "7e1579391d78c1b2897707168cb656a260ba6739b118b2e1438b583b2521d234", + "src/services/ffmpeg.ts": "7aeac554c4771377c9fc3e146c509f85eba332303fffff5f83b1b2ac18576dc3", + "src/services/jellyfin.ts": "6f56b5dcecc83359f004de0ed6f4cd0760b36c90003e05c02c024a0fd173a993", + "src/services/radarr.ts": "7d50f66fc15e74a0547a0ee6ffbd0ba455601d1fc4df816a42eb5247d810b630", + "src/services/sonarr.ts": "d451b6050cd8caaeab6fabcc8b11098086a4946b1f001774f07280a4c75a82a7", + "src/services/ssh.ts": "5c46b5691c3a8535e8686a8a9ba5ffb0edbd13c917654de4361e702a51a14970", + "src/types.ts": "1ad38653639a73786aacdffb7472def1fb8257f91f1d0b9f333ae400e570d07b", + "src/views/dashboard.tsx": "807a1e443c8eb86be24d385ed91abe9b63f019ec79cf084440560ea6b490819c", + "src/views/execute.tsx": "0d7f3963bd24bd187c1231c79678f1ef599c72270cf85fbec63a1a4b486f8196", + "src/views/layout.tsx": "ef88d6b6045a0fafe48f64c1a63b00b627547f2b75e41dd3e94a3c74ff27bdd9", + "src/views/nodes.tsx": "99d30836ec7510db460779b2864b44ba4878bc8b135ae1c09add8aeff29363bf", + "src/views/review.tsx": "da7d2ad22626760529b1fac62958826b4a66c125556f9c3823bd96dd344c2a05", + "src/views/scan.tsx": "623e2b95587d00fef9de1ec0acf2d4e5fc30b0b4a0f2bd9b0055108190f812dc", + "src/views/setup.tsx": "7c9ba96481f5e10e3ce21a588bd454d727550de73abeeab6692bfb94aee76fac" + }, + "indexed_at_commit": "e7c5d0c218b8a1750f7203309750c31ab45ddc52", + "indexed_at_timestamp": "2026-02-28T07:01:39.999707200+00:00", + "schema_version": 3, + "stats": { + "total_edges": 119, + "total_files": 22, + "total_nodes": 117 + }, + "vexp_version": "1.2.14" +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8c97f48 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,269 @@ +# AGENTS.md — PWA Stack Reference + +This file is the authoritative guide for AI agents (Claude Code, Cursor, Copilot, etc.) working on any app in this repository. **Always follow this stack. Never introduce dependencies outside of it without explicit approval.** + +--- + +## Stack Overview + +| Layer | Tool | Version | +|---|---|---| +| Runtime | Bun | latest | +| Build | Vite | latest | +| UI Framework | React | 19+ | +| Component Library | shadcn/ui + Tailwind CSS v4 | latest | +| Routing | TanStack Router | latest | +| State Management | Zustand | latest | +| Local Database | PGlite (PostgreSQL in WASM) | latest | +| Forms | TanStack Form + Zod | latest | +| Animations | CSS View Transitions API | native | +| PWA | vite-plugin-pwa + Workbox | latest | +| Testing (unit) | Vitest + Testing Library | latest | +| Testing (e2e) | Playwright | latest | +| Code Quality | Biome + lint-staged + simple-git-hooks | latest | + +--- + +## Project Structure + +``` +src/ +├── features/ # One folder per domain feature +│ ├── auth/ +│ │ ├── components/ # UI components for this feature +│ │ ├── hooks/ # Custom hooks +│ │ ├── store.ts # Zustand store (if needed) +│ │ ├── schema.ts # Zod schemas for this feature +│ │ └── index.ts # Public API of the feature +│ └── [feature-name]/ +│ └── ... +├── shared/ +│ ├── components/ # Generic reusable UI (Button, Modal, etc.) +│ ├── hooks/ # Generic hooks (useMediaQuery, etc.) +│ ├── db/ +│ │ ├── client.ts # PGlite instance (singleton) +│ │ ├── migrations/ # SQL migration files (001_init.sql, etc.) +│ │ └── schema.ts # Zod schemas mirroring DB tables +│ └── lib/ # Pure utility functions +├── routes/ # TanStack Router route files +│ ├── __root.tsx # Root layout +│ ├── index.tsx # / route +│ └── [feature]/ +│ └── index.tsx +├── app.tsx # App entry, router provider +└── main.tsx # Bun/Vite entry point +public/ +├── manifest.webmanifest # PWA manifest +└── icons/ # PWA icons (all sizes) +``` + +--- + +## Rules by Layer + +### React & TypeScript +- Use **React 19** features: use(), Suspense, transitions. +- All files are `.tsx` or `.ts`. No `.js` or `.jsx`. +- Prefer named exports over default exports (except route files — TanStack Router requires default exports). +- No `any`. Use `unknown` and narrow with Zod when type is uncertain. +- Use `satisfies` operator for type-safe object literals. + +### Styling (Tailwind + shadcn/ui) +- **Never write custom CSS files.** Use Tailwind utility classes exclusively. +- Use `cn()` (from `src/shared/lib/utils.ts`) for conditional class merging. +- shadcn/ui components live in `src/shared/components/ui/` — **never modify them directly**. Wrap them instead. +- Dark mode via Tailwind's `dark:` variant. The `class` strategy is used (not `media`). +- Responsive design: mobile-first. `sm:`, `md:`, `lg:` for larger screens. +- For "native app" feel on mobile: use `touch-action: manipulation` on interactive elements, remove tap highlight with `[-webkit-tap-highlight-color:transparent]`. + +### Routing (TanStack Router) +- Use **file-based routing** via `@tanstack/router-plugin/vite`. +- Route files live in `src/routes/`. Each file exports a `Route` created with `createFileRoute`. +- **Always use `Link` and `useNavigate`** from TanStack Router. Never `` for internal navigation. +- Search params are typed via Zod — define `validateSearch` on every route that uses search params. +- Loaders (`loader` option on route) are the correct place for data fetching that should block navigation. + +```ts +// Example route with typed search params +export const Route = createFileRoute('/runs/')({ + validateSearch: z.object({ + page: z.number().default(1), + filter: z.enum(['all', 'recent']).default('all'), + }), + component: RunsPage, +}) +``` + +### Database (PGlite) +- The PGlite instance is a **singleton** exported from `src/shared/db/client.ts`. +- **Never import PGlite directly in components.** Always go through a hook or a feature's data layer. +- Use **numbered SQL migration files**: `src/shared/db/migrations/001_init.sql`, `002_add_segments.sql`, etc. Run them in order on app start. +- Zod schemas in `src/shared/db/schema.ts` mirror every DB table. Use them to validate data coming out of PGlite. +- For reactive UI updates from DB changes, use a thin wrapper with Zustand's `subscribeWithSelector` or re-query on relevant user actions. + +```ts +// src/shared/db/client.ts +import { PGlite } from '@electric-sql/pglite' + +let _db: PGlite | null = null + +export async function getDb(): Promise { + if (!_db) { + _db = new PGlite('idb://myapp') + await runMigrations(_db) + } + return _db +} +``` + +### State Management (Zustand) +- Zustand is for **ephemeral UI state only**: modals, active tabs, current user session, UI preferences. +- **Persistent data lives in PGlite, not Zustand.** Do not use `zustand/middleware` persist for app data. +- One store file per feature: `src/features/auth/store.ts`. +- Use `subscribeWithSelector` middleware when components need to subscribe to slices. +- Keep stores flat. Avoid deeply nested state. + +```ts +// Example store +import { create } from 'zustand' + +interface UIStore { + activeTab: string + setActiveTab: (tab: string) => void +} + +export const useUIStore = create((set) => ({ + activeTab: 'home', + setActiveTab: (tab) => set({ activeTab: tab }), +})) +``` + +### Forms (TanStack Form + Zod) +- All forms use `useForm` from `@tanstack/react-form`. +- Validation is always done with a Zod schema via the `validators` option. +- **Reuse Zod schemas** from `src/shared/db/schema.ts` or `src/features/[x]/schema.ts` — do not duplicate validation logic. +- Error messages are shown inline below the field, never in a toast. + +```ts +const form = useForm({ + defaultValues: { name: '' }, + validators: { onChange: myZodSchema }, + onSubmit: async ({ value }) => { /* ... */ }, +}) +``` + +### Animations (CSS View Transitions API) +- Use the native **View Transitions API** for page transitions. No animation libraries. +- Wrap navigation calls with `document.startViewTransition()`. +- TanStack Router's `RouterDevtools` and upcoming native support handle this — check the TanStack Router docs for the current recommended integration. +- Use `view-transition-name` CSS property on elements that should animate between routes. +- Provide a `@media (prefers-reduced-motion: reduce)` fallback that disables transitions. + +```css +/* Disable transitions for users who prefer it */ +@media (prefers-reduced-motion: reduce) { + ::view-transition-old(*), + ::view-transition-new(*) { + animation: none; + } +} +``` + +### PWA (vite-plugin-pwa) +- Config lives in `vite.config.ts` under the `VitePWA()` plugin. +- Strategy: `generateSW` (not `injectManifest`) unless custom SW logic is needed. +- **Always precache** the PGlite WASM files — without this the app won't work offline. +- `manifest.webmanifest` must include: `name`, `short_name`, `start_url: "/"`, `display: "standalone"`, `theme_color`, icons at 192×192 and 512×512. +- Register the SW with `registerType: 'autoUpdate'` and show a "New version available" toast. + +### Testing +**Unit/Component Tests (Vitest)** +- Test files co-located with source: `src/features/auth/auth.test.ts`. +- Use Testing Library for component tests. Query by role, label, or text — never by class or id. +- Mock PGlite with an in-memory instance (no `idb://` prefix) in tests. +- Run with: `bun test` + +**E2E Tests (Playwright)** +- Test files in `e2e/`. +- Focus on critical user flows: onboarding, data entry, offline behavior, install prompt. +- Use `page.evaluate()` to inspect IndexedDB/PGlite state when needed. +- Run with: `bun e2e` + +### Code Quality (Biome) +- Biome handles both **formatting and linting** — no Prettier, no ESLint. +- Config in `biome.json` at project root. +- `lint-staged` + `simple-git-hooks` run Biome on staged files before every commit. +- CI also runs `biome check --apply` — commits that fail Biome are rejected. +- **Never disable Biome rules with inline comments** without a code comment explaining why. + +--- + +## Commands + +```bash +bun install # Install dependencies +bun dev # Start dev server +bun build # Production build +bun preview # Preview production build locally +bun test # Run Vitest unit tests +bun e2e # Run Playwright E2E tests +bun lint # Run Biome linter +bun format # Run Biome formatter +``` + +--- + +## Do Not + +- ❌ Do not use `npm` or `yarn` — always use `bun` +- ❌ Do not add ESLint, Prettier, or Husky — Biome + simple-git-hooks covers this +- ❌ Do not use `react-query` or `swr` — data comes from PGlite, not a remote API +- ❌ Do not store persistent app data in Zustand — use PGlite +- ❌ Do not use Framer Motion / React Spring — use CSS View Transitions +- ❌ Do not use `` for internal links — use TanStack Router's `` +- ❌ Do not write raw CSS files — use Tailwind utilities +- ❌ Do not modify files in `src/shared/components/ui/` — wrap shadcn components instead +- ❌ Do not introduce a new dependency without checking if the existing stack already covers the need + +--- + +## Adding a New Feature Checklist + +1. Create `src/features/[name]/` folder +2. Add Zod schema in `src/features/[name]/schema.ts` +3. Add DB migration in `src/shared/db/migrations/` if needed +4. Add route file in `src/routes/[name]/index.tsx` +5. Add Zustand store in `src/features/[name]/store.ts` if UI state is needed +6. Write unit tests co-located with the feature +7. Write at least one Playwright E2E test for the happy path +8. Run `bun lint` and `bun test` before committing + + +## vexp + +**MANDATORY: use `run_pipeline` — do NOT grep, glob, or Read files.** +vexp returns pre-indexed, graph-ranked context in a single call. + +### Workflow +1. `run_pipeline` with your task description — ALWAYS FIRST (replaces all other tools) +2. Make targeted changes based on the context returned +3. `run_pipeline` again only if you need more context + +### Available MCP tools +- `run_pipeline` — **PRIMARY TOOL**. Runs capsule + impact + memory in 1 call. + Auto-detects intent. Includes file content. Example: `run_pipeline({ "task": "fix auth bug" })` +- `get_context_capsule` — lightweight, for simple questions only +- `get_impact_graph` — impact analysis of a specific symbol +- `search_logic_flow` — execution paths between functions +- `get_skeleton` — compact file structure +- `index_status` — indexing status +- `get_session_context` — recall observations from sessions +- `search_memory` — cross-session search +- `save_observation` — persist insights (prefer run_pipeline's observation param) + +### Smart Features +Intent auto-detection, hybrid ranking, session memory, auto-expanding budget. + +### Multi-Repo +`run_pipeline` auto-queries all indexed repos. Use `repos: ["alias"]` to scope. Run `index_status` to see aliases. + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 296ac2a..ca3573e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ COPY package.json bun.lock* ./ RUN bun install --frozen-lockfile COPY . . +RUN bun run build EXPOSE 3000 ENV DATA_DIR=/data @@ -12,4 +13,4 @@ ENV PORT=3000 VOLUME ["/data"] -CMD ["bun", "run", "src/server.tsx"] +CMD ["bun", "run", "server/index.tsx"] diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..1c40799 --- /dev/null +++ b/biome.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "organizeImports": { "enabled": true }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "indentWidth": 1, + "lineWidth": 120 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { "noExplicitAny": "off" }, + "style": { "noNonNullAssertion": "off" } + } + }, + "files": { + "ignore": ["node_modules", "dist", "src/routeTree.gen.ts"] + } +} diff --git a/bun.lock b/bun.lock index 758c010..ac7822b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,44 +5,577 @@ "": { "name": "netfelix-audio-fix", "dependencies": { + "@tanstack/react-form": "^1.28.3", + "@tanstack/react-router": "^1.163.3", + "clsx": "^2.1.1", "hono": "^4", + "react": "19", + "react-dom": "19", "ssh2": "^1", + "tailwind-merge": "^3.5.0", + "zod": "^4.3.6", + "zustand": "^5.0.11", }, "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@tailwindcss/vite": "^4.2.1", + "@tanstack/router-plugin": "^1.163.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/ssh2": "^1", + "@vitejs/plugin-react-swc": "^4.2.3", "bun-types": "latest", + "concurrently": "^9.2.1", + "tailwindcss": "^4.2.1", + "vite": "^7.3.1", }, }, }, "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@swc/core": ["@swc/core@1.15.17", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.17", "@swc/core-darwin-x64": "1.15.17", "@swc/core-linux-arm-gnueabihf": "1.15.17", "@swc/core-linux-arm64-gnu": "1.15.17", "@swc/core-linux-arm64-musl": "1.15.17", "@swc/core-linux-x64-gnu": "1.15.17", "@swc/core-linux-x64-musl": "1.15.17", "@swc/core-win32-arm64-msvc": "1.15.17", "@swc/core-win32-ia32-msvc": "1.15.17", "@swc/core-win32-x64-msvc": "1.15.17" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-Mu3eOrYlkdQPl7yqotNckitTr6FZ0yd7mlWIBEzK+EGIyybgMENJHmbS2DeA7BMleJiBElP6ke+Nz93pkKmKJw=="], + + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eB9qdyt4E60323IS0rgV/rd79DJ+YWSyIKi+sT1dlIgR3ns4xlBiunREM3lVH0FKcUbhttiBvdVubT4QoOuZ+w=="], + + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1TZARYs8947jJpSioqcPrusz+wEeABF4iiSdwcSyQh2rIUdIEk5FOyaqJASFPJ6dZfx7ZVOyjtDATVAegs2/Q=="], + + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.17", "", { "os": "linux", "cpu": "arm" }, "sha512-p6282NQZo5bzx0wphz1ETGjhcRB9CN+/XUAjQwApyoyX9iCloI5IT/RC3vjbflo42g8RPTxUTaItAO0hlLSesQ=="], + + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-TGnDS4ejy8y9jqxXqZCyA+DvFc64nXUHS9rxdyeJ9B9uyIdtKVhBrA2xfghYRS/sSPSyHZ0yu89NxBICvONH+A=="], + + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-D0/6Hj4CkgSTTahtlGxv9IDsLTuvQz30mkZEMDp8TqwYhCL8AomznkibwlQU8HtY4q/dqd1OGRPH+FmNb4BBEA=="], + + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.17", "", { "os": "linux", "cpu": "x64" }, "sha512-1s2OFsg6DeRkWU7c+PIfIHZsFCbiZ34akXFHrg7KjpF8zIvpHZNoUUZimoWEwcB6GquXSkAO+1b5KpG5nusTeQ=="], + + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.17", "", { "os": "linux", "cpu": "x64" }, "sha512-gtxGMGYtRWWmCcgx6xM2Yos43uiE/j8kZwkeL/LNGG9zM0tatd23NsfL9PnQJ45hY7QZ+dx2rM68e4ArgG4kJg=="], + + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gxi+/Miytez/O9vJ/QiheIivA3oWZjPp9nJu3VmAfLMWUzcZORMwgaI1ygtDTLjz7CzcwlGMJz/Ab66Y5DfNpg=="], + + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.17", "", { "os": "win32", "cpu": "ia32" }, "sha512-KUsRqNbTp7SpNK0T9m4+i8GlngzNjwb69a3ttKA6XJ5r6Pewm+NSYji93pNkawXIivbWY2jhvceGMAyd+4hWaQ=="], + + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.17", "", { "os": "win32", "cpu": "x64" }, "sha512-zqtEGE0/rTKvEC5sOtpANLHeWEPjsTD4/rwpUxo6ymztcLI/Z+L9Wi9xQvIGmLTUih1gvNZcAwROqdfRP3oAWQ=="], + + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + + "@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], + + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="], + + "@tanstack/form-core": ["@tanstack/form-core@1.28.3", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.8.1" } }, "sha512-DBhnu1d5VfACAYOAZJO8tsEUHjWczZMJY8v/YrtAJNWpwvL/3ogDuz8e6yUB2m/iVTNq6K8yrnVN2nrX0/BX/w=="], + + "@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="], + + "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], + + "@tanstack/react-form": ["@tanstack/react-form@1.28.3", "", { "dependencies": { "@tanstack/form-core": "1.28.3", "@tanstack/react-store": "^0.8.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-84yd0swZRcyC3Q46dYBH6bHf1tlIY1flchbdG3VwArg/wLVW5RdBenIrJhleHjk2OxXuF+9HoKQbHglJyWIXQA=="], + + "@tanstack/react-router": ["@tanstack/react-router@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.163.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q=="], + + "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="], + + "@tanstack/router-core": ["@tanstack/router-core@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA=="], + + "@tanstack/router-generator": ["@tanstack/router-generator@1.163.3", "", { "dependencies": { "@tanstack/router-core": "1.163.3", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-i2rWRtqY/yCYUDXva1li4zeDP20oFjMt/wh9RnGJCrKSLWrvEGnxAOSyXgiOsoJnU96TTQ0mUDbGfXsSTupeZQ=="], + + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.163.3", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.163.3", "@tanstack/router-generator": "1.163.3", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.163.3", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-JOUYuUX2N9ZHnmkmvmiGzXGbkvrur/5BfW/+vpiZzuifSyvdc0XsfwkTpjvwWx9ymp4ZshSVKiQQKQi09YweIw=="], + + "@tanstack/router-utils": ["@tanstack/router-utils@1.161.4", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw=="], + + "@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], + + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.4", "", {}, "sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], + "@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@4.2.3", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2", "@swc/core": "^1.15.11" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-QIluDil2prhY1gdA3GGwxZzTAmLdi8cQ2CcuMW4PB/Wu4e/1pzqrwhYWVd09LInCRlDUidQjd0B70QWbjWtLxA=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], + "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nan": ["nan@2.25.0", "", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], + + "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], + + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + + "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@tanstack/form-core/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + + "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.1", "", { "dependencies": { "@tanstack/store": "0.9.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA=="], + + "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + + "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "bun-types/@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], } } diff --git a/index.html b/index.html new file mode 100644 index 0000000..e806e0c --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + netfelix-audio-fix + + +
+ + + diff --git a/package.json b/package.json index ba91f25..78c8542 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,38 @@ "name": "netfelix-audio-fix", "version": "2026.02.26", "scripts": { - "dev": "bun --hot src/server.tsx", - "start": "bun src/server.tsx" + "dev:server": "NODE_ENV=development bun --hot server/index.tsx", + "dev:client": "vite", + "dev": "concurrently \"bun run dev:server\" \"bun run dev:client\"", + "build": "vite build", + "start": "bun server/index.tsx", + "lint": "biome check .", + "format": "biome format . --write", + "test": "echo 'No tests yet'" }, "dependencies": { + "@tanstack/react-form": "^1.28.3", + "@tanstack/react-router": "^1.163.3", + "clsx": "^2.1.1", "hono": "^4", - "ssh2": "^1" + "react": "19", + "react-dom": "19", + "ssh2": "^1", + "tailwind-merge": "^3.5.0", + "zod": "^4.3.6", + "zustand": "^5.0.11" }, "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@tailwindcss/vite": "^4.2.1", + "@tanstack/router-plugin": "^1.163.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/ssh2": "^1", - "bun-types": "latest" + "@vitejs/plugin-react-swc": "^4.2.3", + "bun-types": "latest", + "concurrently": "^9.2.1", + "tailwindcss": "^4.2.1", + "vite": "^7.3.1" } } diff --git a/public/app.css b/public/app.css deleted file mode 100644 index 0115bb8..0000000 --- a/public/app.css +++ /dev/null @@ -1,389 +0,0 @@ -/* ─── Base overrides ──────────────────────────────────────────────────────── */ -:root { - --nav-height: 3.5rem; - --color-keep: #2d9a5f; - --color-remove: #c0392b; - --color-pending: #888; - --color-approved: #2d9a5f; - --color-skipped: #888; - --color-done: #2d9a5f; - --color-error: #c0392b; - --color-noop: #555; - --font-mono: 'JetBrains Mono', 'Fira Mono', 'Cascadia Code', monospace; -} - -body { - margin: 0; -} - -/* ─── Nav ─────────────────────────────────────────────────────────────────── */ -.app-nav { - display: flex; - align-items: center; - gap: 0.25rem; - padding: 0 1.5rem; - height: var(--nav-height); - background: var(--pico-background-color); - border-bottom: 1px solid var(--pico-muted-border-color); - position: sticky; - top: 0; - z-index: 100; -} - -.app-nav .brand { - font-weight: 700; - font-size: 1.05rem; - margin-right: 1.5rem; - text-decoration: none; - color: var(--pico-color); -} - -.app-nav a { - padding: 0.35rem 0.75rem; - border-radius: 6px; - text-decoration: none; - font-size: 0.9rem; - color: var(--pico-muted-color); - transition: background 0.15s, color 0.15s; -} - -.app-nav a:hover, -.app-nav a.active { - background: var(--pico-secondary-background); - color: var(--pico-color); -} - -.app-nav .spacer { flex: 1; } - -/* ─── Layout ──────────────────────────────────────────────────────────────── */ -.page { - max-width: 1400px; - margin: 0 auto; - padding: 1.5rem 1.5rem 3rem; -} - -.page-header { - display: flex; - align-items: baseline; - gap: 1rem; - margin-bottom: 1.5rem; -} - -.page-header h1 { - margin: 0; - font-size: 1.5rem; -} - -/* ─── Stat cards ──────────────────────────────────────────────────────────── */ -.stat-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 1rem; - margin-bottom: 2rem; -} - -.stat-card { - border: 1px solid var(--pico-muted-border-color); - border-radius: 8px; - padding: 1rem 1.25rem; - text-align: center; -} - -.stat-card .num { - font-size: 2rem; - font-weight: 700; - line-height: 1; -} - -.stat-card .label { - font-size: 0.78rem; - color: var(--pico-muted-color); - margin-top: 0.25rem; -} - -/* ─── Badges / status ─────────────────────────────────────────────────────── */ -.badge { - display: inline-block; - font-size: 0.72rem; - font-weight: 600; - padding: 0.15em 0.55em; - border-radius: 999px; - text-transform: uppercase; - letter-spacing: 0.04em; - background: var(--pico-secondary-background); - color: var(--pico-muted-color); -} - -.badge-keep { background: #d4edda; color: #155724; } -.badge-remove { background: #f8d7da; color: #721c24; } -.badge-pending { background: #e2e3e5; color: #383d41; } -.badge-approved{ background: #d4edda; color: #155724; } -.badge-skipped { background: #e2e3e5; color: #383d41; } -.badge-done { background: #d1ecf1; color: #0c5460; } -.badge-error { background: #f8d7da; color: #721c24; } -.badge-noop { background: #e2e3e5; color: #383d41; } -.badge-running { background: #fff3cd; color: #856404; } -.badge-manual { background: #fde8c8; color: #7d4400; } - -/* ─── Filter tabs ─────────────────────────────────────────────────────────── */ -.filter-tabs { - display: flex; - gap: 0.25rem; - flex-wrap: wrap; - margin-bottom: 1rem; -} - -.filter-tabs a, -.filter-tabs button { - padding: 0.35rem 0.9rem; - border-radius: 6px; - font-size: 0.85rem; - border: 1px solid var(--pico-muted-border-color); - background: transparent; - cursor: pointer; - text-decoration: none; - color: var(--pico-muted-color); -} - -.filter-tabs a.active, -.filter-tabs button.active { - background: var(--pico-primary); - border-color: var(--pico-primary); - color: #fff; -} - -/* ─── Tables ──────────────────────────────────────────────────────────────── */ -.data-table { - width: 100%; - border-collapse: collapse; - font-size: 0.875rem; -} - -.data-table th { - text-align: left; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--pico-muted-color); - padding: 0.5rem 0.75rem; - border-bottom: 2px solid var(--pico-muted-border-color); - white-space: nowrap; -} - -.data-table td { - padding: 0.6rem 0.75rem; - border-bottom: 1px solid var(--pico-muted-border-color); - vertical-align: middle; -} - -.data-table tr:hover td { - background: var(--pico-secondary-background); -} - -.data-table tr.expanded td { - background: var(--pico-secondary-background); -} - -.data-table td.mono { - font-family: var(--font-mono); - font-size: 0.8rem; -} - -/* ─── Stream decision table ───────────────────────────────────────────────── */ -.stream-table { - width: 100%; - border-collapse: collapse; - font-size: 0.82rem; - margin-top: 0.5rem; -} - -.stream-table th { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--pico-muted-color); - padding: 0.3rem 0.6rem; - border-bottom: 1px solid var(--pico-muted-border-color); -} - -.stream-table td { - padding: 0.4rem 0.6rem; - border-bottom: 1px solid var(--pico-muted-border-color); - vertical-align: middle; -} - -.stream-row-keep { background: #f0fff4; } -.stream-row-remove { background: #fff5f5; } - -/* ─── Action toggle buttons ───────────────────────────────────────────────── */ -.toggle-keep, -.toggle-remove { - border: none; - border-radius: 4px; - padding: 0.2em 0.6em; - font-size: 0.75rem; - font-weight: 600; - cursor: pointer; - min-width: 5rem; -} - -.toggle-keep { background: var(--color-keep); color: #fff; } -.toggle-remove { background: var(--color-remove); color: #fff; } - -/* ─── Progress bar ────────────────────────────────────────────────────────── */ -.progress-wrap { - background: var(--pico-muted-border-color); - border-radius: 999px; - height: 0.5rem; - overflow: hidden; - margin: 0.75rem 0; -} - -.progress-bar { - height: 100%; - background: var(--pico-primary); - border-radius: 999px; - transition: width 0.3s ease; -} - -/* ─── Log output ──────────────────────────────────────────────────────────── */ -.log-output { - font-family: var(--font-mono); - font-size: 0.78rem; - background: #1a1a1a; - color: #d4d4d4; - padding: 0.75rem 1rem; - border-radius: 6px; - max-height: 300px; - overflow-y: auto; - white-space: pre-wrap; - word-break: break-all; -} - -/* ─── Command preview ─────────────────────────────────────────────────────── */ -.command-preview { - font-family: var(--font-mono); - font-size: 0.78rem; - background: #1a1a1a; - color: #9cdcfe; - padding: 0.75rem 1rem; - border-radius: 6px; - white-space: pre-wrap; - word-break: break-all; - border: none; - width: 100%; - resize: vertical; - min-height: 3rem; -} - -/* ─── Detail panel ────────────────────────────────────────────────────────── */ -.detail-panel { - border: 1px solid var(--pico-muted-border-color); - border-radius: 8px; - padding: 1.25rem; - margin-top: 0.25rem; - margin-bottom: 1rem; -} - -.detail-meta { - display: flex; - flex-wrap: wrap; - gap: 1.5rem; - margin-bottom: 1rem; - font-size: 0.85rem; -} - -.detail-meta dt { - color: var(--pico-muted-color); - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 0.1rem; -} - -.detail-meta dd { - margin: 0; - font-weight: 500; -} - -/* ─── Setup wizard ────────────────────────────────────────────────────────── */ -.wizard-steps { - display: flex; - gap: 0; - margin-bottom: 2rem; - border-bottom: 2px solid var(--pico-muted-border-color); -} - -.wizard-step { - padding: 0.6rem 1.25rem; - font-size: 0.85rem; - color: var(--pico-muted-color); - border-bottom: 2px solid transparent; - margin-bottom: -2px; -} - -.wizard-step.active { - color: var(--pico-primary); - border-bottom-color: var(--pico-primary); - font-weight: 600; -} - -.wizard-step.done { - color: var(--color-keep); -} - -/* ─── Connection status ───────────────────────────────────────────────────── */ -.conn-status { - display: inline-flex; - align-items: center; - gap: 0.4rem; - font-size: 0.82rem; - padding: 0.3em 0.7em; - border-radius: 5px; -} - -.conn-status.ok { background: #d4edda; color: #155724; } -.conn-status.error { background: #f8d7da; color: #721c24; } -.conn-status.checking { background: #fff3cd; color: #856404; } - -/* ─── Inline lang select ──────────────────────────────────────────────────── */ -.lang-select { - font-size: 0.82rem; - padding: 0.2em 0.5em; - border-radius: 4px; - border: 1px solid var(--pico-muted-border-color); - background: var(--pico-background-color); - cursor: pointer; -} - -/* ─── Alerts ──────────────────────────────────────────────────────────────── */ -.alert { - padding: 0.75rem 1rem; - border-radius: 6px; - margin-bottom: 1rem; - font-size: 0.875rem; -} - -.alert-warning { background: #fff3cd; color: #856404; border: 1px solid #ffc107; } -.alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } -.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; } -.alert-info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; } - -/* ─── HTMX loading indicator ─────────────────────────────────────────────── */ -.htmx-indicator { display: none; } -.htmx-request .htmx-indicator { display: inline; } -.htmx-request.htmx-indicator { display: inline; } - -/* ─── Utility ─────────────────────────────────────────────────────────────── */ -.muted { color: var(--pico-muted-color); } -.mono { font-family: var(--font-mono); font-size: 0.8rem; } -.truncate { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.flex-row { display: flex; align-items: center; gap: 0.5rem; } -.actions-col { white-space: nowrap; display: flex; gap: 0.4rem; align-items: center; } - -button[data-size="sm"], -a[data-size="sm"] { - padding: 0.25rem 0.65rem; - font-size: 0.8rem; -} diff --git a/src/api/execute.tsx b/src/api/execute.tsx deleted file mode 100644 index 80691bb..0000000 --- a/src/api/execute.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { Hono } from 'hono'; -import { stream } from 'hono/streaming'; -import { getDb } from '../db/index'; -import { execStream, execOnce } from '../services/ssh'; -import type { Job, Node, MediaItem } from '../types'; -import { ExecutePage } from '../views/execute'; - -const app = new Hono(); - -// ─── SSE state ──────────────────────────────────────────────────────────────── - -const jobListeners = new Set<(data: string) => void>(); - -function emitJobUpdate(jobId: number, status: string, output?: string): void { - const line = `event: job_update\ndata: ${JSON.stringify({ id: jobId, status, output })}\n\n`; - for (const l of jobListeners) l(line); -} - -// ─── List page ──────────────────────────────────────────────────────────────── - -app.get('/', (c) => { - const db = getDb(); - const jobRows = db.prepare(` - SELECT j.*, mi.name, mi.type, mi.series_name, mi.season_number, mi.episode_number, - mi.file_path, - n.name as node_name, n.host, n.port, n.username, - n.private_key, n.ffmpeg_path, n.work_dir, n.status as node_status - FROM jobs j - LEFT JOIN media_items mi ON mi.id = j.item_id - LEFT JOIN nodes n ON n.id = j.node_id - ORDER BY j.created_at DESC - LIMIT 200 - `).all() as (Job & { - name: string; - type: string; - series_name: string | null; - season_number: number | null; - episode_number: number | null; - file_path: string; - node_name: string | null; - host: string | null; - port: number | null; - username: string | null; - private_key: string | null; - ffmpeg_path: string | null; - work_dir: string | null; - node_status: string | null; - })[]; - - const jobs = jobRows.map((r) => ({ - job: r as unknown as Job, - item: r.name ? { - id: r.item_id, - name: r.name, - type: r.type, - series_name: r.series_name, - season_number: r.season_number, - episode_number: r.episode_number, - file_path: r.file_path, - } as unknown as MediaItem : null, - node: r.node_name ? { - id: r.node_id!, - name: r.node_name, - host: r.host!, - port: r.port!, - username: r.username!, - private_key: r.private_key!, - ffmpeg_path: r.ffmpeg_path!, - work_dir: r.work_dir!, - status: r.node_status!, - } as unknown as Node : null, - })); - - const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[]; - - return c.html(); -}); - -// ─── Start all pending ──────────────────────────────────────────────────────── - -app.post('/start', (c) => { - const db = getDb(); - const pending = db.prepare( - "SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at" - ).all() as Job[]; - - for (const job of pending) { - runJob(job).catch((err) => console.error(`Job ${job.id} failed:`, err)); - } - - return c.redirect('/execute'); -}); - -// ─── Assign node ────────────────────────────────────────────────────────────── - -app.post('/job/:id/assign', async (c) => { - const db = getDb(); - const jobId = Number(c.req.param('id')); - const body = await c.req.formData(); - const nodeId = body.get('node_id') ? Number(body.get('node_id')) : null; - - db.prepare('UPDATE jobs SET node_id = ? WHERE id = ?').run(nodeId, jobId); - return c.redirect('/execute'); -}); - -// ─── Run single job ─────────────────────────────────────────────────────────── - -app.post('/job/:id/run', async (c) => { - const db = getDb(); - const jobId = Number(c.req.param('id')); - const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId) as Job | undefined; - - if (!job || job.status !== 'pending') { - return c.redirect('/execute'); - } - - runJob(job).catch((err) => console.error(`Job ${job.id} failed:`, err)); - return c.redirect('/execute'); -}); - -// ─── Cancel job ─────────────────────────────────────────────────────────────── - -app.post('/job/:id/cancel', (c) => { - const db = getDb(); - const jobId = Number(c.req.param('id')); - db.prepare("DELETE FROM jobs WHERE id = ? AND status = 'pending'").run(jobId); - return c.redirect('/execute'); -}); - -// ─── SSE ────────────────────────────────────────────────────────────────────── - -app.get('/events', (c) => { - return stream(c, async (s) => { - c.header('Content-Type', 'text/event-stream'); - c.header('Cache-Control', 'no-cache'); - - const queue: string[] = []; - let resolve: (() => void) | null = null; - - const listener = (data: string) => { - queue.push(data); - resolve?.(); - }; - - jobListeners.add(listener); - s.onAbort(() => { jobListeners.delete(listener); }); - - try { - while (!s.closed) { - if (queue.length > 0) { - await s.write(queue.shift()!); - } else { - await new Promise((res) => { - resolve = res; - setTimeout(res, 15_000); - }); - resolve = null; - if (queue.length === 0) await s.write(': keepalive\n\n'); - } - } - } finally { - jobListeners.delete(listener); - } - }); -}); - -// ─── Job execution ──────────────────────────────────────────────────────────── - -async function runJob(job: Job): Promise { - const db = getDb(); - - db.prepare( - "UPDATE jobs SET status = 'running', started_at = datetime('now'), output = '' WHERE id = ?" - ).run(job.id); - emitJobUpdate(job.id, 'running'); - - let outputLines: string[] = []; - - try { - if (job.node_id) { - // Remote execution - const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(job.node_id) as Node | undefined; - if (!node) throw new Error(`Node ${job.node_id} not found`); - - for await (const line of execStream(node, job.command)) { - outputLines.push(line); - // Flush to DB every 20 lines - if (outputLines.length % 20 === 0) { - db.prepare('UPDATE jobs SET output = ? WHERE id = ?').run(outputLines.join('\n'), job.id); - emitJobUpdate(job.id, 'running', outputLines.join('\n')); - } - } - } else { - // Local execution — spawn ffmpeg directly - const proc = Bun.spawn(['sh', '-c', job.command], { - stdout: 'pipe', - stderr: 'pipe', - }); - - const readStream = async (readable: ReadableStream, prefix = '') => { - const reader = readable.getReader(); - const decoder = new TextDecoder(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const text = decoder.decode(value); - const lines = text.split('\n').filter((l) => l.trim()); - for (const line of lines) { - outputLines.push(prefix + line); - } - if (outputLines.length % 20 === 0) { - db.prepare('UPDATE jobs SET output = ? WHERE id = ?').run(outputLines.join('\n'), job.id); - emitJobUpdate(job.id, 'running', outputLines.join('\n')); - } - } - } catch { /* ignore */ } - }; - - await Promise.all([ - readStream(proc.stdout), - readStream(proc.stderr, '[stderr] '), - proc.exited, - ]); - - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new Error(`FFmpeg exited with code ${exitCode}`); - } - } - - const fullOutput = outputLines.join('\n'); - db.prepare( - "UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?" - ).run(fullOutput, job.id); - emitJobUpdate(job.id, 'done', fullOutput); - - // Mark plan as done - db.prepare( - "UPDATE review_plans SET status = 'done' WHERE item_id = ?" - ).run(job.item_id); - } catch (err) { - const fullOutput = outputLines.join('\n') + '\n' + String(err); - db.prepare( - "UPDATE jobs SET status = 'error', exit_code = 1, output = ?, completed_at = datetime('now') WHERE id = ?" - ).run(fullOutput, job.id); - emitJobUpdate(job.id, 'error', fullOutput); - - db.prepare( - "UPDATE review_plans SET status = 'error' WHERE item_id = ?" - ).run(job.item_id); - } -} - -export default app; diff --git a/src/api/nodes.tsx b/src/api/nodes.tsx deleted file mode 100644 index f0fef26..0000000 --- a/src/api/nodes.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Hono } from 'hono'; -import { getDb } from '../db/index'; -import { testConnection } from '../services/ssh'; -import type { Node } from '../types'; -import { NodesPage, NodesList, NodeStatusBadge } from '../views/nodes'; - -const app = new Hono(); - -app.get('/', (c) => { - const db = getDb(); - const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[]; - return c.html(); -}); - -app.post('/', async (c) => { - const db = getDb(); - const body = await c.req.formData(); - - const name = body.get('name') as string; - const host = body.get('host') as string; - const port = Number(body.get('port') ?? '22'); - const username = body.get('username') as string; - const ffmpegPath = (body.get('ffmpeg_path') as string) || 'ffmpeg'; - const workDir = (body.get('work_dir') as string) || '/tmp'; - const keyFile = body.get('private_key') as File | null; - - if (!name || !host || !username || !keyFile) { - return c.html(
All fields are required.
); - } - - const privateKey = await keyFile.text(); - - try { - db.prepare(` - INSERT INTO nodes (name, host, port, username, private_key, ffmpeg_path, work_dir) - VALUES (?, ?, ?, ?, ?, ?, ?) - `).run(name, host, port, username, privateKey, ffmpegPath, workDir); - } catch (e) { - if (String(e).includes('UNIQUE')) { - return c.html(
A node named "{name}" already exists.
); - } - throw e; - } - - const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[]; - return c.html(); -}); - -app.post('/:id/delete', (c) => { - const db = getDb(); - const id = Number(c.req.param('id')); - db.prepare('DELETE FROM nodes WHERE id = ?').run(id); - return c.redirect('/nodes'); -}); - -app.post('/:id/test', async (c) => { - const db = getDb(); - const id = Number(c.req.param('id')); - const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id) as Node | undefined; - - if (!node) return c.notFound(); - - const result = await testConnection(node); - const status = result.ok ? 'ok' : 'error'; - - db.prepare( - "UPDATE nodes SET status = ?, last_checked_at = datetime('now') WHERE id = ?" - ).run(status, id); - - return c.html(); -}); - -export default app; diff --git a/src/api/review.tsx b/src/api/review.tsx deleted file mode 100644 index e73244a..0000000 --- a/src/api/review.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import { Hono } from 'hono'; -import { getDb, getConfig } from '../db/index'; -import { analyzeItem } from '../services/analyzer'; -import { buildCommand } from '../services/ffmpeg'; -import { normalizeLanguage } from '../services/jellyfin'; -import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types'; -import { - ReviewListPage, - ReviewDetailPage, - ReviewDetailFragment, -} from '../views/review'; - -const app = new Hono(); - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function getSubtitleLanguages(): string[] { - return JSON.parse(getConfig('subtitle_languages') ?? '["eng","deu","spa"]'); -} - -function computeCommand( - item: MediaItem, - streams: MediaStream[], - decisions: StreamDecision[] -): string | null { - if (decisions.every((d) => d.action === 'keep')) return null; - return buildCommand(item, streams, decisions); -} - -function countsByFilter(db: ReturnType): Record { - const total = (db.prepare('SELECT COUNT(*) as n FROM review_plans').get() as { n: number }).n; - const noops = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n; - const pending = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n; - const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n; - const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number }).n; - const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n; - const error = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n; - const manual = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE needs_review = 1 AND original_language IS NULL").get() as { n: number }).n; - - return { all: total, needs_action: pending, noop: noops, approved, skipped, done, error, manual }; -} - -// ─── List view ──────────────────────────────────────────────────────────────── - -app.get('/', (c) => { - const db = getDb(); - const filter = c.req.query('filter') ?? 'all'; - - let whereClause = '1=1'; - switch (filter) { - case 'needs_action': whereClause = "rp.status = 'pending' AND rp.is_noop = 0"; break; - case 'noop': whereClause = 'rp.is_noop = 1'; break; - case 'manual': whereClause = 'mi.needs_review = 1 AND mi.original_language IS NULL'; break; - case 'approved': whereClause = "rp.status = 'approved'"; break; - case 'skipped': whereClause = "rp.status = 'skipped'"; break; - case 'done': whereClause = "rp.status = 'done'"; break; - case 'error': whereClause = "rp.status = 'error'"; break; - } - - const rows = db.prepare(` - SELECT - mi.*, - rp.id as plan_id, - rp.status as plan_status, - rp.is_noop, - rp.notes as plan_notes, - rp.reviewed_at, - rp.created_at as plan_created_at, - COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count, - COUNT(CASE WHEN sd.action = 'keep' THEN 1 END) as keep_count - FROM media_items mi - LEFT JOIN review_plans rp ON rp.item_id = mi.id - LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id - WHERE ${whereClause} - GROUP BY mi.id - ORDER BY mi.series_name NULLS LAST, mi.name, mi.season_number, mi.episode_number - LIMIT 500 - `).all() as (MediaItem & { - plan_id: number | null; - plan_status: string | null; - is_noop: number | null; - plan_notes: string | null; - reviewed_at: string | null; - plan_created_at: string | null; - remove_count: number; - keep_count: number; - })[]; - - const items = rows.map((r) => ({ - item: r as unknown as MediaItem, - plan: r.plan_id != null ? { - id: r.plan_id, - item_id: r.id, - status: r.plan_status ?? 'pending', - is_noop: r.is_noop ?? 0, - notes: r.plan_notes, - reviewed_at: r.reviewed_at, - created_at: r.plan_created_at ?? '', - } as ReviewPlan : null, - removeCount: r.remove_count, - keepCount: r.keep_count, - })); - - const totalCounts = countsByFilter(db); - - return c.html(); -}); - -// ─── Detail view ────────────────────────────────────────────────────────────── - -app.get('/:id', (c) => { - const db = getDb(); - const id = Number(c.req.param('id')); - const { item, streams, plan, decisions, command } = loadItemDetail(db, id); - - if (!item) return c.notFound(); - - // Inline HTMX expansion vs full page - const isHtmx = c.req.header('HX-Request') === 'true'; - if (isHtmx) { - return c.html( - - ); - } - - return c.html( - - ); -}); - -// ─── Override original language ─────────────────────────────────────────────── - -app.patch('/:id/language', async (c) => { - const db = getDb(); - const id = Number(c.req.param('id')); - const body = await c.req.formData(); - const lang = (body.get('language') as string) || null; - - db.prepare( - "UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?" - ).run(lang ? normalizeLanguage(lang) : null, id); - - // Re-analyze with new language - reanalyze(db, id); - - const { item, streams, plan, decisions, command } = loadItemDetail(db, id); - if (!item) return c.notFound(); - - return c.html( - - ); -}); - -// ─── Toggle stream action ───────────────────────────────────────────────────── - -app.patch('/:id/stream/:streamId', async (c) => { - const db = getDb(); - const itemId = Number(c.req.param('id')); - const streamId = Number(c.req.param('streamId')); - const body = await c.req.formData(); - const action = body.get('action') as 'keep' | 'remove'; - - // Get plan - const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined; - if (!plan) return c.notFound(); - - db.prepare( - 'UPDATE stream_decisions SET action = ? WHERE plan_id = ? AND stream_id = ?' - ).run(action, plan.id, streamId); - - // Recompute is_noop - const allKeep = (db.prepare( - "SELECT COUNT(*) as n FROM stream_decisions WHERE plan_id = ? AND action = 'remove'" - ).get(plan.id) as { n: number }).n === 0; - db.prepare('UPDATE review_plans SET is_noop = ? WHERE id = ?').run(allKeep ? 1 : 0, plan.id); - - const { item, streams, decisions, command } = loadItemDetail(db, itemId); - if (!item) return c.notFound(); - const planFull = db.prepare('SELECT * FROM review_plans WHERE id = ?').get(plan.id) as ReviewPlan; - - return c.html( - - ); -}); - -// ─── Approve ────────────────────────────────────────────────────────────────── - -app.post('/:id/approve', (c) => { - const db = getDb(); - const id = Number(c.req.param('id')); - - const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined; - if (!plan) return c.notFound(); - - db.prepare( - "UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?" - ).run(plan.id); - - // Create job - if (!plan.is_noop) { - const { item, streams, decisions } = loadItemDetail(db, id); - if (item) { - const command = buildCommand(item, streams, decisions); - db.prepare( - "INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')" - ).run(id, command); - } - } - - const isHtmx = c.req.header('HX-Request') === 'true'; - return isHtmx ? c.redirect('/review', 303) : c.redirect('/review'); -}); - -// ─── Skip ───────────────────────────────────────────────────────────────────── - -app.post('/:id/skip', (c) => { - const db = getDb(); - const id = Number(c.req.param('id')); - - db.prepare( - "UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE item_id = ?" - ).run(id); - - return c.redirect('/review'); -}); - -// ─── Approve all ────────────────────────────────────────────────────────────── - -app.post('/approve-all', (c) => { - const db = getDb(); - - const pending = db.prepare( - "SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE rp.status = 'pending' AND rp.is_noop = 0" - ).all() as (ReviewPlan & { item_id: number })[]; - - for (const plan of pending) { - db.prepare( - "UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?" - ).run(plan.id); - - const { item, streams, decisions } = loadItemDetail(db, plan.item_id); - if (item) { - const command = buildCommand(item, streams, decisions); - db.prepare( - "INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')" - ).run(plan.item_id, command); - } - } - - return c.redirect('/review'); -}); - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function loadItemDetail(db: ReturnType, itemId: number) { - const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined; - if (!item) return { item: null, streams: [], plan: null, decisions: [], command: null }; - - const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[]; - const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined | null; - const decisions = plan - ? db.prepare('SELECT * FROM stream_decisions WHERE plan_id = ?').all(plan.id) as StreamDecision[] - : []; - - const command = plan && !plan.is_noop && decisions.some((d) => d.action === 'remove') - ? buildCommand(item, streams, decisions) - : null; - - return { item, streams, plan: plan ?? null, decisions, command }; -} - -function reanalyze(db: ReturnType, itemId: number): void { - const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem; - if (!item) return; - - const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[]; - const subtitleLanguages = getSubtitleLanguages(); - const analysis = analyzeItem( - { original_language: item.original_language, needs_review: item.needs_review }, - streams, - { subtitleLanguages } - ); - - // Upsert plan - db.prepare(` - INSERT INTO review_plans (item_id, status, is_noop, notes) - VALUES (?, 'pending', ?, ?) - ON CONFLICT(item_id) DO UPDATE SET - status = 'pending', - is_noop = excluded.is_noop, - notes = excluded.notes - `).run(itemId, analysis.is_noop ? 1 : 0, analysis.notes); - - const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number }; - - // Replace decisions - db.prepare('DELETE FROM stream_decisions WHERE plan_id = ?').run(plan.id); - for (const dec of analysis.decisions) { - db.prepare( - 'INSERT INTO stream_decisions (plan_id, stream_id, action, target_index) VALUES (?, ?, ?, ?)' - ).run(plan.id, dec.stream_id, dec.action, dec.target_index); - } -} - -export default app; diff --git a/src/api/scan.tsx b/src/api/scan.tsx deleted file mode 100644 index 0bd099f..0000000 --- a/src/api/scan.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import { Hono } from 'hono'; -import { stream } from 'hono/streaming'; -import { getDb, getConfig, setConfig, getAllConfig } from '../db/index'; -import { getAllItems, extractOriginalLanguage, mapStream, normalizeLanguage } from '../services/jellyfin'; -import { getOriginalLanguage as radarrLang } from '../services/radarr'; -import { getOriginalLanguage as sonarrLang } from '../services/sonarr'; -import { analyzeItem } from '../services/analyzer'; -import type { MediaItem, MediaStream } from '../types'; -import { ScanPage } from '../views/scan'; - -const app = new Hono(); - -// ─── State: single in-process scan ─────────────────────────────────────────── - -let scanAbort: AbortController | null = null; -const scanListeners = new Set<(data: string) => void>(); - -function emitSse(type: string, data: unknown): void { - const line = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; - for (const listener of scanListeners) listener(line); -} - -function currentScanLimit(): number | null { - const v = getConfig('scan_limit'); - return v ? Number(v) : null; -} - -// ─── Pages ──────────────────────────────────────────────────────────────────── - -app.get('/', (c) => { - const db = getDb(); - const running = getConfig('scan_running') === '1'; - const total = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n; - const scanned = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }).n; - const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number }).n; - const recentItems = db.prepare( - 'SELECT name, type, scan_status FROM media_items ORDER BY last_scanned_at DESC LIMIT 50' - ).all() as { name: string; type: string; scan_status: string }[]; - - return c.html( - - ); -}); - -// ─── Start scan ─────────────────────────────────────────────────────────────── - -app.post('/start', async (c) => { - if (getConfig('scan_running') === '1') { - return c.redirect('/scan'); - } - - // Accept limit from form field or env var - const body = await c.req.formData().catch(() => new FormData()); - const formLimit = body.get('limit') ? Number(body.get('limit')) : null; - const envLimit = process.env.SCAN_LIMIT ? Number(process.env.SCAN_LIMIT) : null; - const limit = formLimit ?? envLimit ?? null; - setConfig('scan_limit', limit != null ? String(limit) : ''); - - setConfig('scan_running', '1'); - - runScan(limit).catch((err) => { - console.error('Scan error:', err); - setConfig('scan_running', '0'); - emitSse('error', { message: String(err) }); - }); - - return c.redirect('/scan'); -}); - -// ─── Stop scan ──────────────────────────────────────────────────────────────── - -app.post('/stop', (c) => { - scanAbort?.abort(); - setConfig('scan_running', '0'); - return c.redirect('/scan'); -}); - -// ─── SSE stream ─────────────────────────────────────────────────────────────── - -app.get('/events', (c) => { - return stream(c, async (s) => { - c.header('Content-Type', 'text/event-stream'); - c.header('Cache-Control', 'no-cache'); - c.header('Connection', 'keep-alive'); - - const queue: string[] = []; - let resolve: (() => void) | null = null; - - const listener = (data: string) => { - queue.push(data); - resolve?.(); - }; - - scanListeners.add(listener); - s.onAbort(() => { scanListeners.delete(listener); }); - - try { - while (!s.closed) { - if (queue.length > 0) { - await s.write(queue.shift()!); - } else { - await new Promise((res) => { - resolve = res; - setTimeout(res, 25_000); // keepalive every 25s - }); - resolve = null; - if (queue.length === 0) { - await s.write(': keepalive\n\n'); - } - } - } - } finally { - scanListeners.delete(listener); - } - }); -}); - -// ─── Core scan logic ────────────────────────────────────────────────────────── - -async function runScan(limit: number | null = null): Promise { - scanAbort = new AbortController(); - const { signal } = scanAbort; - const db = getDb(); - const cfg = getAllConfig(); - - const jellyfinCfg = { - url: cfg.jellyfin_url, - apiKey: cfg.jellyfin_api_key, - userId: cfg.jellyfin_user_id, - }; - const subtitleLanguages: string[] = JSON.parse(cfg.subtitle_languages ?? '["eng","deu","spa"]'); - const radarrEnabled = cfg.radarr_enabled === '1'; - const sonarrEnabled = cfg.sonarr_enabled === '1'; - - let processed = 0; - let errors = 0; - let total = 0; - - // Count total items first (rough estimate; cap at limit if set) - try { - const countUrl = new URL(`${jellyfinCfg.url}/Users/${jellyfinCfg.userId}/Items`); - countUrl.searchParams.set('Recursive', 'true'); - countUrl.searchParams.set('IncludeItemTypes', 'Movie,Episode'); - countUrl.searchParams.set('Limit', '1'); - const countRes = await fetch(countUrl.toString(), { - headers: { 'X-Emby-Token': jellyfinCfg.apiKey }, - }); - if (countRes.ok) { - const body = (await countRes.json()) as { TotalRecordCount: number }; - total = limit != null ? Math.min(limit, body.TotalRecordCount) : body.TotalRecordCount; - } - } catch { /* ignore */ } - - // ─── Prepared statements ────────────────────────────────────────────────── - - const upsertItem = db.prepare(` - INSERT INTO media_items ( - jellyfin_id, type, name, series_name, series_jellyfin_id, - season_number, episode_number, year, file_path, file_size, container, - original_language, orig_lang_source, needs_review, - imdb_id, tmdb_id, tvdb_id, - scan_status, last_scanned_at - ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - 'scanned', datetime('now') - ) - ON CONFLICT(jellyfin_id) DO UPDATE SET - type = excluded.type, - name = excluded.name, - series_name = excluded.series_name, - series_jellyfin_id = excluded.series_jellyfin_id, - season_number = excluded.season_number, - episode_number = excluded.episode_number, - year = excluded.year, - file_path = excluded.file_path, - file_size = excluded.file_size, - container = excluded.container, - original_language = excluded.original_language, - orig_lang_source = excluded.orig_lang_source, - needs_review = excluded.needs_review, - imdb_id = excluded.imdb_id, - tmdb_id = excluded.tmdb_id, - tvdb_id = excluded.tvdb_id, - scan_status = 'scanned', - last_scanned_at = datetime('now') - `); - - const deleteStreams = db.prepare('DELETE FROM media_streams WHERE item_id = ?'); - const insertStream = db.prepare(` - INSERT INTO media_streams ( - item_id, stream_index, type, codec, language, language_display, - title, is_default, is_forced, is_hearing_impaired, - channels, channel_layout, bit_rate, sample_rate - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - - const upsertPlan = db.prepare(` - INSERT INTO review_plans (item_id, status, is_noop, notes) - VALUES (?, 'pending', ?, ?) - ON CONFLICT(item_id) DO UPDATE SET - is_noop = excluded.is_noop, - notes = excluded.notes - `); - - const upsertDecision = db.prepare(` - INSERT INTO stream_decisions (plan_id, stream_id, action, target_index) - VALUES (?, ?, ?, ?) - ON CONFLICT(plan_id, stream_id) DO UPDATE SET - action = excluded.action, - target_index = excluded.target_index - `); - - const getItemByJellyfinId = db.prepare('SELECT id FROM media_items WHERE jellyfin_id = ?'); - const getPlanByItemId = db.prepare('SELECT id FROM review_plans WHERE item_id = ?'); - const getStreamsByItemId = db.prepare('SELECT * FROM media_streams WHERE item_id = ?'); - - // ─── Main loop ──────────────────────────────────────────────────────────── - - for await (const jellyfinItem of getAllItems(jellyfinCfg)) { - if (signal.aborted) break; - if (limit != null && processed >= limit) break; - - // Skip items that Jellyfin returns without a usable name or path - // (extras, virtual folders, items still being indexed, etc.) - if (!jellyfinItem.Name || !jellyfinItem.Path) { - console.warn(`Skipping item without name/path: id=${jellyfinItem.Id}, type=${jellyfinItem.Type}`); - continue; - } - - processed++; - emitSse('progress', { - scanned: processed, - total, - current_item: jellyfinItem.Name, - errors, - running: true, - }); - - try { - const providerIds = jellyfinItem.ProviderIds ?? {}; - const imdbId = providerIds['Imdb'] ?? null; - const tmdbId = providerIds['Tmdb'] ?? null; - const tvdbId = providerIds['Tvdb'] ?? null; - - // Determine original language - let origLang: string | null = extractOriginalLanguage(jellyfinItem); - let origLangSource = 'jellyfin'; - let needsReview = origLang ? 0 : 1; - - // Cross-check with Radarr (movies) - if (jellyfinItem.Type === 'Movie' && radarrEnabled && (tmdbId || imdbId)) { - const radarrLanguage = await radarrLang( - { url: cfg.radarr_url, apiKey: cfg.radarr_api_key }, - { tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined } - ); - if (radarrLanguage) { - if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(radarrLanguage)) { - needsReview = 1; - } - origLang = radarrLanguage; - origLangSource = 'radarr'; - } - } - - // Cross-check with Sonarr (episodes) - if (jellyfinItem.Type === 'Episode' && sonarrEnabled && tvdbId) { - const sonarrLanguage = await sonarrLang( - { url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key }, - tvdbId - ); - if (sonarrLanguage) { - if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(sonarrLanguage)) { - needsReview = 1; - } - origLang = sonarrLanguage; - origLangSource = 'sonarr'; - } - } - - // Upsert item - upsertItem.run( - jellyfinItem.Id, - jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie', - jellyfinItem.Name, - jellyfinItem.SeriesName ?? null, - jellyfinItem.SeriesId ?? null, - jellyfinItem.ParentIndexNumber ?? null, - jellyfinItem.IndexNumber ?? null, - jellyfinItem.ProductionYear ?? null, - jellyfinItem.Path, - jellyfinItem.Size ?? null, - jellyfinItem.Container ?? null, - origLang, - origLangSource, - needsReview, - imdbId, - tmdbId, - tvdbId - ); - - const itemRow = getItemByJellyfinId.get(jellyfinItem.Id) as { id: number }; - const itemId = itemRow.id; - - // Replace streams - deleteStreams.run(itemId); - for (const jStream of jellyfinItem.MediaStreams ?? []) { - const s = mapStream(jStream); - insertStream.run( - itemId, - s.stream_index, - s.type, - s.codec, - s.language, - s.language_display, - s.title, - s.is_default, - s.is_forced, - s.is_hearing_impaired, - s.channels, - s.channel_layout, - s.bit_rate, - s.sample_rate - ); - } - - // Run analyzer - const streams = getStreamsByItemId.all(itemId) as MediaStream[]; - const analysis = analyzeItem( - { original_language: origLang, needs_review: needsReview }, - streams, - { subtitleLanguages } - ); - - upsertPlan.run(itemId, analysis.is_noop ? 1 : 0, analysis.notes); - - const planRow = getPlanByItemId.get(itemId) as { id: number }; - const planId = planRow.id; - - for (const dec of analysis.decisions) { - upsertDecision.run(planId, dec.stream_id, dec.action, dec.target_index); - } - - emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'scanned' }); - } catch (err) { - errors++; - console.error(`Error scanning ${jellyfinItem.Name}:`, err); - try { - db.prepare( - "UPDATE media_items SET scan_status = 'error', scan_error = ? WHERE jellyfin_id = ?" - ).run(String(err), jellyfinItem.Id); - } catch { /* ignore */ } - emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'error' }); - } - } - - setConfig('scan_running', '0'); - emitSse('complete', { scanned: processed, total, errors }); -} - -export default app; diff --git a/src/api/setup.tsx b/src/api/setup.tsx deleted file mode 100644 index 47df1d8..0000000 --- a/src/api/setup.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { Hono } from 'hono'; -import { getConfig, setConfig, getAllConfig } from '../db/index'; -import { testConnection as testJellyfin, getUsers } from '../services/jellyfin'; -import { testConnection as testRadarr } from '../services/radarr'; -import { testConnection as testSonarr } from '../services/sonarr'; -import { SetupPage, ConnStatusFragment } from '../views/setup'; - -const app = new Hono(); - -app.get('/', async (c) => { - const setupComplete = getConfig('setup_complete') === '1'; - if (setupComplete) return c.redirect('/'); - - const step = Number(c.req.query('step') ?? '1') as 1 | 2 | 3 | 4; - const config = getAllConfig(); - return c.html(); -}); - -app.post('/jellyfin', async (c) => { - const body = await c.req.formData(); - const url = (body.get('url') as string)?.replace(/\/$/, ''); - const apiKey = body.get('api_key') as string; - - if (!url || !apiKey) { - return c.html(); - } - - const result = await testJellyfin({ url, apiKey, userId: '' }); - if (!result.ok) { - return c.html(); - } - - // Auto-discover user ID - let userId = ''; - try { - const users = await getUsers({ url, apiKey }); - const admin = users.find((u) => u.Name === 'admin') ?? users[0]; - userId = admin?.Id ?? ''; - } catch { - // Non-fatal; user can enter manually later - } - - setConfig('jellyfin_url', url); - setConfig('jellyfin_api_key', apiKey); - if (userId) setConfig('jellyfin_user_id', userId); - - return c.html(); -}); - -app.post('/radarr', async (c) => { - const body = await c.req.formData(); - const url = (body.get('url') as string)?.replace(/\/$/, ''); - const apiKey = body.get('api_key') as string; - - if (!url || !apiKey) { - // Skip was clicked with empty fields — go to next step - return c.redirect('/setup?step=3'); - } - - const result = await testRadarr({ url, apiKey }); - if (!result.ok) { - return c.html(); - } - - setConfig('radarr_url', url); - setConfig('radarr_api_key', apiKey); - setConfig('radarr_enabled', '1'); - - return c.html(); -}); - -app.post('/sonarr', async (c) => { - const body = await c.req.formData(); - const url = (body.get('url') as string)?.replace(/\/$/, ''); - const apiKey = body.get('api_key') as string; - - if (!url || !apiKey) { - return c.redirect('/setup?step=4'); - } - - const result = await testSonarr({ url, apiKey }); - if (!result.ok) { - return c.html(); - } - - setConfig('sonarr_url', url); - setConfig('sonarr_api_key', apiKey); - setConfig('sonarr_enabled', '1'); - - return c.html(); -}); - -app.post('/complete', async (c) => { - const body = await c.req.formData(); - const langs = body.getAll('subtitle_lang') as string[]; - if (langs.length > 0) { - setConfig('subtitle_languages', JSON.stringify(langs)); - } - setConfig('setup_complete', '1'); - return c.redirect('/'); -}); - -export default app; diff --git a/src/db/index.ts b/src/db/index.ts deleted file mode 100644 index 7a8da30..0000000 --- a/src/db/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Database } from 'bun:sqlite'; -import { join } from 'node:path'; -import { mkdirSync } from 'node:fs'; -import { SCHEMA, DEFAULT_CONFIG } from './schema'; - -const dataDir = process.env.DATA_DIR ?? './data'; -mkdirSync(dataDir, { recursive: true }); - -const dbPath = join(dataDir, 'netfelix.db'); - -let _db: Database | null = null; - -export function getDb(): Database { - if (_db) return _db; - _db = new Database(dbPath, { create: true }); - _db.exec(SCHEMA); - seedDefaults(_db); - return _db; -} - -function seedDefaults(db: Database): void { - const insert = db.prepare( - 'INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)' - ); - for (const [key, value] of Object.entries(DEFAULT_CONFIG)) { - insert.run(key, value); - } -} - -export function getConfig(key: string): string | null { - const row = getDb() - .prepare('SELECT value FROM config WHERE key = ?') - .get(key) as { value: string } | undefined; - return row?.value ?? null; -} - -export function setConfig(key: string, value: string): void { - getDb() - .prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)') - .run(key, value); -} - -export function getAllConfig(): Record { - const rows = getDb() - .prepare('SELECT key, value FROM config') - .all() as { key: string; value: string }[]; - return Object.fromEntries(rows.map((r) => [r.key, r.value ?? ''])); -} diff --git a/src/db/schema.ts b/src/db/schema.ts deleted file mode 100644 index ea969a8..0000000 --- a/src/db/schema.ts +++ /dev/null @@ -1,114 +0,0 @@ -export const SCHEMA = ` -PRAGMA journal_mode = WAL; -PRAGMA foreign_keys = ON; - -CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY NOT NULL, - value TEXT -); - -CREATE TABLE IF NOT EXISTS nodes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - host TEXT NOT NULL, - port INTEGER NOT NULL DEFAULT 22, - username TEXT NOT NULL, - private_key TEXT NOT NULL, - ffmpeg_path TEXT NOT NULL DEFAULT 'ffmpeg', - work_dir TEXT NOT NULL DEFAULT '/tmp', - status TEXT NOT NULL DEFAULT 'unknown', - last_checked_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS media_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - jellyfin_id TEXT NOT NULL UNIQUE, - type TEXT NOT NULL, - name TEXT NOT NULL, - series_name TEXT, - series_jellyfin_id TEXT, - season_number INTEGER, - episode_number INTEGER, - year INTEGER, - file_path TEXT NOT NULL, - file_size INTEGER, - container TEXT, - original_language TEXT, - orig_lang_source TEXT, - needs_review INTEGER NOT NULL DEFAULT 1, - imdb_id TEXT, - tmdb_id TEXT, - tvdb_id TEXT, - scan_status TEXT NOT NULL DEFAULT 'pending', - scan_error TEXT, - last_scanned_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS media_streams ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - stream_index INTEGER NOT NULL, - type TEXT NOT NULL, - codec TEXT, - language TEXT, - language_display TEXT, - title TEXT, - is_default INTEGER NOT NULL DEFAULT 0, - is_forced INTEGER NOT NULL DEFAULT 0, - is_hearing_impaired INTEGER NOT NULL DEFAULT 0, - channels INTEGER, - channel_layout TEXT, - bit_rate INTEGER, - sample_rate INTEGER, - UNIQUE(item_id, stream_index) -); - -CREATE TABLE IF NOT EXISTS review_plans ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - item_id INTEGER NOT NULL UNIQUE REFERENCES media_items(id) ON DELETE CASCADE, - status TEXT NOT NULL DEFAULT 'pending', - is_noop INTEGER NOT NULL DEFAULT 0, - notes TEXT, - reviewed_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS stream_decisions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES review_plans(id) ON DELETE CASCADE, - stream_id INTEGER NOT NULL REFERENCES media_streams(id) ON DELETE CASCADE, - action TEXT NOT NULL, - target_index INTEGER, - UNIQUE(plan_id, stream_id) -); - -CREATE TABLE IF NOT EXISTS jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - command TEXT NOT NULL, - node_id INTEGER REFERENCES nodes(id) ON DELETE SET NULL, - status TEXT NOT NULL DEFAULT 'pending', - output TEXT, - exit_code INTEGER, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - started_at TEXT, - completed_at TEXT -); -`; - -export const DEFAULT_CONFIG: Record = { - setup_complete: '0', - jellyfin_url: '', - jellyfin_api_key: '', - jellyfin_user_id: '', - radarr_url: '', - radarr_api_key: '', - radarr_enabled: '0', - sonarr_url: '', - sonarr_api_key: '', - sonarr_enabled: '0', - subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']), - scan_running: '0', -}; diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx new file mode 100644 index 0000000..4233a49 --- /dev/null +++ b/src/features/dashboard/DashboardPage.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate } from '@tanstack/react-router'; +import { api } from '~/shared/lib/api'; +import { Button } from '~/shared/components/ui/button'; +import { Alert } from '~/shared/components/ui/alert'; + +interface Stats { + totalItems: number; scanned: number; needsAction: number; + approved: number; done: number; errors: number; noChange: number; +} + +interface DashboardData { stats: Stats; scanRunning: boolean; setupComplete: boolean; } + +function StatCard({ label, value, danger }: { label: string; value: number; danger?: boolean }) { + return ( +
+
+ {value.toLocaleString()} +
+
{label}
+
+ ); +} + +export function DashboardPage() { + const navigate = useNavigate(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [starting, setStarting] = useState(false); + + useEffect(() => { + api.get('/api/dashboard').then((d) => { + setData(d); + setLoading(false); + if (!d.setupComplete) navigate({ to: '/setup' }); + }).catch(() => setLoading(false)); + }, [navigate]); + + const startScan = async () => { + setStarting(true); + await api.post('/api/scan/start', {}).catch(() => {}); + navigate({ to: '/scan' }); + }; + + if (loading) return
Loading…
; + if (!data) return Failed to load dashboard.; + + const { stats, scanRunning } = data; + + return ( +
+
+

Dashboard

+
+ +
+ + + + + + + {stats.errors > 0 && } +
+ +
+ {scanRunning ? ( + + ⏳ Scan running… + + ) : ( + + )} + + Review changes + + + Execute jobs + +
+ + {stats.scanned === 0 && ( + + Library not scanned yet. Click Start Scan to begin. + + )} +
+ ); +} diff --git a/src/features/execute/ExecutePage.tsx b/src/features/execute/ExecutePage.tsx new file mode 100644 index 0000000..709ec0c --- /dev/null +++ b/src/features/execute/ExecutePage.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link } from '@tanstack/react-router'; +import { api } from '~/shared/lib/api'; +import { Badge } from '~/shared/components/ui/badge'; +import { Button } from '~/shared/components/ui/button'; +import { Select } from '~/shared/components/ui/select'; +import type { Job, Node, MediaItem } from '~/shared/lib/types'; + +interface JobEntry { job: Job; item: MediaItem | null; node: Node | null; } +interface ExecuteData { jobs: JobEntry[]; nodes: Node[]; } + +function itemName(job: Job, item: MediaItem | null): string { + if (!item) return `Item #${job.item_id}`; + if (item.type === 'Episode' && item.series_name) { + return `${item.series_name} S${String(item.season_number ?? 0).padStart(2, '0')}E${String(item.episode_number ?? 0).padStart(2, '0')}`; + } + return item.name; +} + +export function ExecutePage() { + const [data, setData] = useState(null); + const [logs, setLogs] = useState>(new Map()); + const [logVisible, setLogVisible] = useState>(new Set()); + const esRef = useRef(null); + + const load = () => api.get('/api/execute').then(setData); + + useEffect(() => { load(); }, []); + + // SSE for live job updates + useEffect(() => { + const es = new EventSource('/api/execute/events'); + esRef.current = es; + es.addEventListener('job_update', (e) => { + const d = JSON.parse(e.data) as { id: number; status: string; output?: string }; + setData((prev) => { + if (!prev) return prev; + return { + ...prev, + jobs: prev.jobs.map((j) => + j.job.id === d.id ? { ...j, job: { ...j.job, status: d.status as Job['status'] } } : j + ), + }; + }); + if (d.output !== undefined) { + setLogs((prev) => { const m = new Map(prev); m.set(d.id, d.output!); return m; }); + } + // Reload row on terminal state to get accurate data + if (d.status === 'done' || d.status === 'error') { + api.get<{ job: Job; item: MediaItem | null; node: Node | null; nodes: Node[] }>(`/api/execute/job/${d.id}/run`).catch(() => {}); + } + }); + return () => es.close(); + }, []); + + const startAll = async () => { await api.post('/api/execute/start'); load(); }; + const runJob = async (id: number) => { await api.post(`/api/execute/job/${id}/run`); load(); }; + const cancelJob = async (id: number) => { await api.post(`/api/execute/job/${id}/cancel`); load(); }; + const assignNode = async (jobId: number, nodeId: number | null) => { + await api.post(`/api/execute/job/${jobId}/assign`, { node_id: nodeId }); + load(); + }; + + const toggleLog = (id: number) => setLogVisible((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; }); + + if (!data) return
Loading…
; + + const { jobs, nodes } = data; + const pending = jobs.filter((j) => j.job.status === 'pending').length; + const running = jobs.filter((j) => j.job.status === 'running').length; + const done = jobs.filter((j) => j.job.status === 'done').length; + const errors = jobs.filter((j) => j.job.status === 'error').length; + + return ( +
+
+

Execute Jobs

+
+ +
+ {pending} pending + {running > 0 && {running} running} + {done > 0 && {done} done} + {errors > 0 && {errors} error(s)} +
+ +
+ {pending > 0 && } + {jobs.length === 0 && ( +

+ No jobs yet. Go to Review and approve items first. +

+ )} +
+ + {jobs.length > 0 && ( + + + + {['#', 'Item', 'Command', 'Node', 'Status', 'Actions'].map((h) => ( + + ))} + + + + {jobs.map(({ job, item, node }) => { + const name = itemName(job, item); + const cmdShort = job.command.length > 80 ? job.command.slice(0, 77) + '…' : job.command; + const jobLog = logs.get(job.id) ?? job.output ?? ''; + const showLog = logVisible.has(job.id) || job.status === 'running' || job.status === 'error'; + + return ( + <> + + + + + + + + + {jobLog && ( + + + + )} + + ); + })} + +
{h}
{job.id} +
{name}
+ {item && ( +
+ ← Details +
+ )} + {item?.file_path && ( +
+ {item.file_path.split('/').pop()} +
+ )} +
+ {cmdShort} + + {job.status === 'pending' ? ( + + ) : ( + {node?.name ?? 'Local'} + )} + + {job.status} + {job.exit_code != null && job.exit_code !== 0 && exit {job.exit_code}} + +
+ {job.status === 'pending' && ( + <> + + + + )} + {(job.status === 'done' || job.status === 'error') && jobLog && ( + + )} +
+
+ {showLog && ( +
+ {jobLog} +
+ )} +
+ )} +
+ ); +} diff --git a/src/features/nodes/NodesPage.tsx b/src/features/nodes/NodesPage.tsx new file mode 100644 index 0000000..64f2481 --- /dev/null +++ b/src/features/nodes/NodesPage.tsx @@ -0,0 +1,146 @@ +import { useEffect, useRef, useState } from 'react'; +import { api } from '~/shared/lib/api'; +import { Badge } from '~/shared/components/ui/badge'; +import { Button } from '~/shared/components/ui/button'; +import { Input } from '~/shared/components/ui/input'; +import { Alert } from '~/shared/components/ui/alert'; +import type { Node } from '~/shared/lib/types'; + +interface NodesData { nodes: Node[]; } + +function nodeStatusVariant(status: string): 'done' | 'error' | 'pending' { + if (status === 'ok') return 'done'; + if (status.startsWith('error')) return 'error'; + return 'pending'; +} + +export function NodesPage() { + const [nodes, setNodes] = useState([]); + const [error, setError] = useState(''); + const [testing, setTesting] = useState>(new Set()); + const fileRef = useRef(null); + + const load = () => api.get('/api/nodes').then((d) => setNodes(d.nodes)); + useEffect(() => { load(); }, []); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + const form = e.currentTarget; + const fd = new FormData(form); + const result = await api.postForm('/api/nodes', fd).catch((err) => { setError(String(err)); return null; }); + if (result) { setNodes(result.nodes); form.reset(); if (fileRef.current) fileRef.current.value = ''; } + }; + + const deleteNode = async (id: number) => { + if (!confirm('Remove node?')) return; + await api.post(`/api/nodes/${id}/delete`); + load(); + }; + + const testNode = async (id: number) => { + setTesting((s) => { const n = new Set(s); n.add(id); return n; }); + await api.post<{ ok: boolean; status: string }>(`/api/nodes/${id}/test`); + setTesting((s) => { const n = new Set(s); n.delete(id); return n; }); + load(); + }; + + return ( +
+
+

Remote Nodes

+
+ +

+ Remote nodes run FFmpeg over SSH on shared storage. The path to the media file must be + identical on both this server and the remote node. +

+ + {/* Add form */} +
+
Add Node
+ {error && {error}} +
+
+ + + + + + +
+ + +
+
+ + {/* Node list */} + {nodes.length === 0 ? ( +

No nodes configured. Add one above.

+ ) : ( + + + + {['Name', 'Host', 'Port', 'User', 'FFmpeg', 'Status', 'Actions'].map((h) => ( + + ))} + + + + {nodes.map((node) => ( + + + + + + + + + + ))} + +
{h}
{node.name}{node.host}{node.port}{node.username}{node.ffmpeg_path} + {node.status} + +
+ + +
+
+ )} +
+ ); +} + +import type React from 'react'; diff --git a/src/features/review/AudioDetailPage.tsx b/src/features/review/AudioDetailPage.tsx new file mode 100644 index 0000000..27bb6c2 --- /dev/null +++ b/src/features/review/AudioDetailPage.tsx @@ -0,0 +1,334 @@ +import { useEffect, useState } from 'react'; +import { Link, useParams } from '@tanstack/react-router'; +import { api } from '~/shared/lib/api'; +import { Badge } from '~/shared/components/ui/badge'; +import { Button } from '~/shared/components/ui/button'; +import { Alert } from '~/shared/components/ui/alert'; +import { Select } from '~/shared/components/ui/select'; +import { langName, LANG_NAMES } from '~/shared/lib/lang'; +import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '~/shared/lib/types'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface DetailData { + item: MediaItem; streams: MediaStream[]; + plan: ReviewPlan | null; decisions: StreamDecision[]; + command: string | null; dockerCommand: string | null; dockerMountDir: string | null; +} + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; + return `${(bytes / 1024 ** 3).toFixed(2)} GB`; +} + +function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string { + if (dec?.custom_title) return dec.custom_title; + if (s.type === 'Subtitle') { + if (!s.language) return ''; + const base = langName(s.language); + if (s.is_forced) return `${base} (Forced)`; + if (s.is_hearing_impaired) return `${base} (CC)`; + return base; + } + if (s.title) return s.title; + if (s.type === 'Audio' && s.channels) return `${s.channels}ch ${s.channel_layout ?? ''}`.trim(); + return s.language ? langName(s.language) : ''; +} + +// ─── Stream table ───────────────────────────────────────────────────────────── + +const STREAM_SECTIONS = [ + { type: 'Video', label: 'Video' }, + { type: 'Audio', label: 'Audio' }, + { type: 'Subtitle', label: 'Subtitles — all extracted to sidecar files' }, + { type: 'Data', label: 'Data' }, + { type: 'EmbeddedImage', label: 'Embedded Images' }, +]; + +const TYPE_ORDER: Record = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 }; + +function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map { + const mappedKept = streams + .filter((s) => ['Video', 'Audio'].includes(s.type)) + .filter((s) => { + const action = decisions.find((d) => d.stream_id === s.id)?.action; + return action === 'keep'; + }) + .sort((a, b) => { + const ta = TYPE_ORDER[a.type] ?? 9; + const tb = TYPE_ORDER[b.type] ?? 9; + if (ta !== tb) return ta - tb; + const da = decisions.find((d) => d.stream_id === a.id); + const db = decisions.find((d) => d.stream_id === b.id); + return (da?.target_index ?? 0) - (db?.target_index ?? 0); + }); + const m = new Map(); + mappedKept.forEach((s, i) => m.set(s.id, i)); + return m; +} + +interface StreamTableProps { data: DetailData; onUpdate: (d: DetailData) => void; } + +function StreamTable({ data, onUpdate }: StreamTableProps) { + const { item, streams, plan, decisions } = data; + const outIdx = computeOutIdx(streams, decisions); + + const toggleStream = async (streamId: number, currentAction: 'keep' | 'remove') => { + const d = await api.patch(`/api/review/${item.id}/stream/${streamId}`, { action: currentAction === 'keep' ? 'remove' : 'keep' }); + onUpdate(d); + }; + + const updateTitle = async (streamId: number, title: string) => { + const d = await api.patch(`/api/review/${item.id}/stream/${streamId}/title`, { title }); + onUpdate(d); + }; + + return ( + + + + {['Out', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => ( + + ))} + + + + {STREAM_SECTIONS.flatMap(({ type, label }) => { + const group = streams.filter((s) => s.type === type); + if (group.length === 0) return []; + return [ + + + , + ...group.map((s) => { + const dec = decisions.find((d) => d.stream_id === s.id); + const action = dec?.action ?? 'keep'; + const isSub = s.type === 'Subtitle'; + const isAudio = s.type === 'Audio'; + + const outputNum = outIdx.get(s.id); + const lbl = effectiveLabel(s, dec); + const origTitle = s.title; + const lang = langName(s.language); + // Only audio streams can be edited; subtitles are always extracted + const isEditable = plan?.status === 'pending' && isAudio; + const rowBg = isSub ? 'bg-sky-50' : action === 'keep' ? 'bg-green-50' : 'bg-red-50'; + + return ( + + + + + + + + + ); + }), + ]; + })} + +
{h}
+ {label} +
+ {isSub ? : outputNum !== undefined ? outputNum : } + {s.codec ?? '—'} + {lang} {s.language ? ({s.language}) : null} + + {isEditable ? ( + updateTitle(s.id, v)} + /> + ) : ( + {lbl || '—'} + )} + {isEditable && origTitle && origTitle !== lbl && ( +
orig: {origTitle}
+ )} +
+ + {s.is_default ? default : null} + {s.is_forced ? forced : null} + {s.is_hearing_impaired ? CC : null} + + + {isSub ? ( + + ↑ Extract + + ) : plan?.status === 'pending' && (isAudio) ? ( + + ) : ( + {action} + )} +
+ ); +} + +function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) { + const [localVal, setLocalVal] = useState(value); + useEffect(() => { setLocalVal(value); }, [value]); + return ( + setLocalVal(e.target.value)} + onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }} + onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} + placeholder="—" + className="border border-transparent bg-transparent w-full text-[inherit] font-[inherit] text-inherit px-[0.2em] py-[0.05em] rounded outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-blue-300 focus:bg-white min-w-16" + /> + ); +} + +// ─── Detail page ────────────────────────────────────────────────────────────── + +export function AudioDetailPage() { + const { id } = useParams({ from: '/review/audio/$id' }); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [rescanning, setRescanning] = useState(false); + + const load = () => api.get(`/api/review/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false)); + useEffect(() => { load(); }, [id]); + + const setLanguage = async (lang: string) => { + const d = await api.patch(`/api/review/${id}/language`, { language: lang || null }); + setData(d); + }; + + const approve = async () => { await api.post(`/api/review/${id}/approve`); load(); }; + const skip = async () => { await api.post(`/api/review/${id}/skip`); load(); }; + const unskip = async () => { await api.post(`/api/review/${id}/unskip`); load(); }; + const rescan = async () => { + setRescanning(true); + try { const d = await api.post(`/api/review/${id}/rescan`); setData(d); } + finally { setRescanning(false); } + }; + + if (loading) return
Loading…
; + if (!data) return Item not found.; + + const { item, plan, command, dockerCommand, dockerMountDir } = data; + const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending'); + + return ( +
+
+

+ ← Audio + {item.name} +

+
+ +
+ {/* Meta */} +
+ {[ + { label: 'Type', value: item.type }, + ...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []), + ...(item.year ? [{ label: 'Year', value: String(item.year) }] : []), + { label: 'Container', value: item.container ?? '—' }, + { label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' }, + { label: 'Status', value: {statusKey} }, + ].map((entry, i) => ( +
+
{entry.label}
+
{entry.value}
+
+ ))} +
+ +
{item.file_path}
+ + {/* Warnings */} + {plan?.notes && {plan.notes}} + {item.needs_review && !item.original_language && ( + + Original language unknown — audio tracks will NOT be filtered until you set it below. + + )} + + {/* Language override */} +
+ + + {item.orig_lang_source && {item.orig_lang_source}} +
+ + {/* Stream table */} + + + {/* FFmpeg command */} + {command && ( +
+
FFmpeg command (audio + subtitle extraction)
+ -
- )} - - {/* Approve / skip */} - {plan?.status === 'pending' && !plan.is_noop && ( -
-
- -
-
- -
-
- )} - {plan?.is_noop ? ( -
- This file is already clean — no changes needed. -
- ) : null} -
- ); -}; - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; - return `${(bytes / 1024 ** 3).toFixed(2)} GB`; -} diff --git a/src/views/scan.tsx b/src/views/scan.tsx deleted file mode 100644 index 79b5f20..0000000 --- a/src/views/scan.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import type { FC } from 'hono/jsx'; -import { Layout } from './layout'; - -interface ScanPageProps { - running: boolean; - progress: { scanned: number; total: number; errors: number }; - recentItems: Array<{ name: string; type: string; scan_status: string }>; - scanLimit: number | null; -} - -const SSE_SCRIPT = ` -(function() { - var es = new EventSource('/scan/events'); - es.addEventListener('progress', function(e) { - var d = JSON.parse(e.data); - var bar = document.getElementById('progress-bar'); - var txt = document.getElementById('progress-text'); - var cur = document.getElementById('current-item'); - var errc = document.getElementById('error-count'); - if (bar) bar.style.width = (d.total > 0 ? Math.round(d.scanned / d.total * 100) : 0) + '%'; - if (txt) txt.textContent = d.scanned + ' / ' + d.total; - if (cur) cur.textContent = d.current_item || ''; - if (errc && d.errors > 0) { errc.textContent = d.errors + ' error(s)'; errc.style.display = ''; } - }); - es.addEventListener('log', function(e) { - var d = JSON.parse(e.data); - var log = document.getElementById('scan-log-body'); - if (!log) return; - var tr = document.createElement('tr'); - tr.innerHTML = '' + d.type + '' + escHtml(d.name) + '' + d.status + ''; - log.prepend(tr); - while (log.children.length > 100) log.removeChild(log.lastChild); - }); - es.addEventListener('complete', function(e) { - es.close(); - var d = JSON.parse(e.data || '{}'); - var hdr = document.getElementById('scan-status-label'); - if (hdr) hdr.textContent = 'Scan complete — ' + (d.scanned || '?') + ' items, ' + (d.errors || 0) + ' errors'; - var stopBtn = document.getElementById('scan-stop-btn'); - if (stopBtn) stopBtn.outerHTML = '
Go to Review \u2192'; - }); - es.addEventListener('error', function() { - // Connection dropped — do NOT reload. Let the user navigate freely. - es.close(); - var hdr = document.getElementById('scan-status-label'); - if (hdr) hdr.textContent = 'Scan connection lost — refresh this page to see current status'; - }); - function escHtml(s) { - if (!s) return ''; - return ('' + s).replace(/&/g,'&').replace(//g,'>'); - } -})(); -`; - -export const ScanPage: FC = ({ running, progress, recentItems, scanLimit }) => ( - - - -
- -
- - {running &&