From 7dbd00e537abe445681a88339e0f81b9592c6d03 Mon Sep 17 00:00:00 2001 From: Christopher Mayor Date: Fri, 24 Apr 2026 10:45:19 -0700 Subject: [PATCH] Sync from /srv/compose/unified-media-manager --- .gitignore | 19 + AGENTS.md | 326 ++ backend/Dockerfile | 19 + cmd/migrate/main.go | 73 + docker-compose.yml | 49 + docs/UX-FLOWS.md | 301 ++ frontend/Dockerfile | 12 + frontend/index.html | 12 + frontend/nginx.conf | 20 + frontend/package-lock.json | 2746 +++++++++++++++++ frontend/package.json | 27 + frontend/src/App.tsx | 76 + frontend/src/api/client.ts | 64 + frontend/src/api/queryClient.tsx | 22 + frontend/src/components/ConfirmModal.tsx | 85 + frontend/src/components/ErrorBanner.tsx | 12 + frontend/src/components/Loading.tsx | 7 + frontend/src/components/Pagination.tsx | 52 + .../src/components/ReleaseSearchResults.tsx | 218 ++ frontend/src/components/StatusBadge.tsx | 23 + frontend/src/components/Toast.tsx | 32 + frontend/src/index.css | 22 + frontend/src/main.tsx | 15 + frontend/src/pages/Activity.tsx | 160 + frontend/src/pages/Blocklist.tsx | 243 ++ frontend/src/pages/Calendar.tsx | 233 ++ frontend/src/pages/Dashboard.tsx | 90 + frontend/src/pages/Discover.tsx | 207 ++ frontend/src/pages/Library.tsx | 225 ++ frontend/src/pages/MediaDetail.tsx | 757 +++++ frontend/src/pages/Queue.tsx | 333 ++ frontend/src/pages/Requests.tsx | 454 +++ frontend/src/pages/Search.tsx | 427 +++ frontend/src/pages/Settings.tsx | 1317 ++++++++ frontend/tailwind.config.js | 6 + frontend/tsconfig.json | 23 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 8 + go.mod | 35 + go.sum | 135 + internal/api/activity.go | 48 + internal/api/blocklist.go | 107 + internal/api/calendar.go | 45 + internal/api/dashboard.go | 24 + internal/api/discover.go | 130 + internal/api/download_clients.go | 119 + internal/api/health.go | 77 + internal/api/import.go | 83 + internal/api/indexers.go | 218 ++ internal/api/media.go | 214 ++ internal/api/media_detail.go | 43 + internal/api/metadata.go | 69 + internal/api/notifications.go | 212 ++ internal/api/quality.go | 114 + internal/api/queue.go | 129 + internal/api/requests.go | 161 + internal/api/root_folder.go | 67 + internal/api/router.go | 220 ++ internal/api/search.go | 137 + internal/api/subtitle.go | 159 + internal/api/tag.go | 67 + internal/api/workers.go | 92 + internal/cardigann/definition.go | 287 ++ internal/cardigann/engine.go | 614 ++++ internal/cardigann/filters.go | 296 ++ internal/cardigann/parser.go | 48 + internal/cardigann/security.go | 165 + internal/cardigann/selector.go | 84 + internal/config/config.go | 119 + internal/db/db.go | 129 + internal/db/migrations/001_init.sql | 253 ++ .../db/migrations/002_quality_upgrade_fix.sql | 5 + .../db/migrations/003_download_clients.sql | 14 + .../db/migrations/004_naming_templates.sql | 15 + internal/db/migrations/005_metadata_cache.sql | 12 + .../db/migrations/006_queue_download_id.sql | 1 + internal/db/migrations/007_users_requests.sql | 32 + .../db/migrations/008_activity_events.sql | 24 + internal/db/migrations/009_notifications.sql | 45 + .../db/migrations/010_media_release_date.sql | 7 + .../db/migrations/011_performance_indexes.sql | 15 + internal/db/migrations/012_subtitle_cache.sql | 14 + .../013_fix_download_clients_schema.sql | 21 + internal/download/client.go | 30 + internal/download/qbittorrent.go | 285 ++ internal/download/sabnzbd.go | 230 ++ internal/migrate/import.go | 1036 +++++++ internal/migrate/migrator.go | 172 ++ internal/migrate/reader.go | 637 ++++ internal/service/activity.go | 153 + internal/service/activity_test.go | 25 + internal/service/blocklist.go | 182 ++ internal/service/calendar.go | 103 + internal/service/dashboard.go | 97 + internal/service/discover.go | 328 ++ internal/service/download_client.go | 353 +++ internal/service/import.go | 427 +++ internal/service/indexer.go | 557 ++++ internal/service/matcher.go | 221 ++ internal/service/media.go | 621 ++++ internal/service/media_detail.go | 337 ++ internal/service/metadata.go | 335 ++ internal/service/musicbrainz.go | 320 ++ internal/service/naming.go | 118 + internal/service/notification.go | 673 ++++ internal/service/notification_test.go | 216 ++ internal/service/openlibrary.go | 199 ++ internal/service/quality.go | 282 ++ internal/service/query.go | 41 + internal/service/queue.go | 226 ++ internal/service/release.go | 195 ++ internal/service/request.go | 266 ++ internal/service/root_folder.go | 78 + internal/service/safety.go | 56 + internal/service/safety_test.go | 92 + internal/service/search.go | 426 +++ internal/service/subtitle.go | 378 +++ internal/service/tag.go | 76 + internal/service/tmdb.go | 403 +++ internal/service/tvdb.go | 308 ++ internal/service/user.go | 121 + internal/worker/cleanup.go | 87 + internal/worker/disk.go | 69 + internal/worker/health.go | 69 + internal/worker/library_scanner.go | 182 ++ internal/worker/metadata_refresh.go | 39 + internal/worker/queue.go | 131 + internal/worker/rss_sync.go | 197 ++ internal/worker/scheduler.go | 236 ++ internal/worker/subtitle_search.go | 112 + migrate | Bin 0 -> 15699112 bytes scripts/migrate-arrs.sh | 39 + 132 files changed, 25394 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 backend/Dockerfile create mode 100644 cmd/migrate/main.go create mode 100644 docker-compose.yml create mode 100644 docs/UX-FLOWS.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/queryClient.tsx create mode 100644 frontend/src/components/ConfirmModal.tsx create mode 100644 frontend/src/components/ErrorBanner.tsx create mode 100644 frontend/src/components/Loading.tsx create mode 100644 frontend/src/components/Pagination.tsx create mode 100644 frontend/src/components/ReleaseSearchResults.tsx create mode 100644 frontend/src/components/StatusBadge.tsx create mode 100644 frontend/src/components/Toast.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Activity.tsx create mode 100644 frontend/src/pages/Blocklist.tsx create mode 100644 frontend/src/pages/Calendar.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Discover.tsx create mode 100644 frontend/src/pages/Library.tsx create mode 100644 frontend/src/pages/MediaDetail.tsx create mode 100644 frontend/src/pages/Queue.tsx create mode 100644 frontend/src/pages/Requests.tsx create mode 100644 frontend/src/pages/Search.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/activity.go create mode 100644 internal/api/blocklist.go create mode 100644 internal/api/calendar.go create mode 100644 internal/api/dashboard.go create mode 100644 internal/api/discover.go create mode 100644 internal/api/download_clients.go create mode 100644 internal/api/health.go create mode 100644 internal/api/import.go create mode 100644 internal/api/indexers.go create mode 100644 internal/api/media.go create mode 100644 internal/api/media_detail.go create mode 100644 internal/api/metadata.go create mode 100644 internal/api/notifications.go create mode 100644 internal/api/quality.go create mode 100644 internal/api/queue.go create mode 100644 internal/api/requests.go create mode 100644 internal/api/root_folder.go create mode 100644 internal/api/router.go create mode 100644 internal/api/search.go create mode 100644 internal/api/subtitle.go create mode 100644 internal/api/tag.go create mode 100644 internal/api/workers.go create mode 100644 internal/cardigann/definition.go create mode 100644 internal/cardigann/engine.go create mode 100644 internal/cardigann/filters.go create mode 100644 internal/cardigann/parser.go create mode 100644 internal/cardigann/security.go create mode 100644 internal/cardigann/selector.go create mode 100644 internal/config/config.go create mode 100644 internal/db/db.go create mode 100644 internal/db/migrations/001_init.sql create mode 100644 internal/db/migrations/002_quality_upgrade_fix.sql create mode 100644 internal/db/migrations/003_download_clients.sql create mode 100644 internal/db/migrations/004_naming_templates.sql create mode 100644 internal/db/migrations/005_metadata_cache.sql create mode 100644 internal/db/migrations/006_queue_download_id.sql create mode 100644 internal/db/migrations/007_users_requests.sql create mode 100644 internal/db/migrations/008_activity_events.sql create mode 100644 internal/db/migrations/009_notifications.sql create mode 100644 internal/db/migrations/010_media_release_date.sql create mode 100644 internal/db/migrations/011_performance_indexes.sql create mode 100644 internal/db/migrations/012_subtitle_cache.sql create mode 100644 internal/db/migrations/013_fix_download_clients_schema.sql create mode 100644 internal/download/client.go create mode 100644 internal/download/qbittorrent.go create mode 100644 internal/download/sabnzbd.go create mode 100644 internal/migrate/import.go create mode 100644 internal/migrate/migrator.go create mode 100644 internal/migrate/reader.go create mode 100644 internal/service/activity.go create mode 100644 internal/service/activity_test.go create mode 100644 internal/service/blocklist.go create mode 100644 internal/service/calendar.go create mode 100644 internal/service/dashboard.go create mode 100644 internal/service/discover.go create mode 100644 internal/service/download_client.go create mode 100644 internal/service/import.go create mode 100644 internal/service/indexer.go create mode 100644 internal/service/matcher.go create mode 100644 internal/service/media.go create mode 100644 internal/service/media_detail.go create mode 100644 internal/service/metadata.go create mode 100644 internal/service/musicbrainz.go create mode 100644 internal/service/naming.go create mode 100644 internal/service/notification.go create mode 100644 internal/service/notification_test.go create mode 100644 internal/service/openlibrary.go create mode 100644 internal/service/quality.go create mode 100644 internal/service/query.go create mode 100644 internal/service/queue.go create mode 100644 internal/service/release.go create mode 100644 internal/service/request.go create mode 100644 internal/service/root_folder.go create mode 100644 internal/service/safety.go create mode 100644 internal/service/safety_test.go create mode 100644 internal/service/search.go create mode 100644 internal/service/subtitle.go create mode 100644 internal/service/tag.go create mode 100644 internal/service/tmdb.go create mode 100644 internal/service/tvdb.go create mode 100644 internal/service/user.go create mode 100644 internal/worker/cleanup.go create mode 100644 internal/worker/disk.go create mode 100644 internal/worker/health.go create mode 100644 internal/worker/library_scanner.go create mode 100644 internal/worker/metadata_refresh.go create mode 100644 internal/worker/queue.go create mode 100644 internal/worker/rss_sync.go create mode 100644 internal/worker/scheduler.go create mode 100644 internal/worker/subtitle_search.go create mode 100755 migrate create mode 100755 scripts/migrate-arrs.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7cc31d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Dependencies +frontend/node_modules/ + +# Build output +frontend/dist/ +server + +# TypeScript compiled output (Vite handles compilation) +frontend/src/**/*.js +frontend/src/**/*.js.map +frontend/src/**/*.d.ts +frontend/src/**/*.d.ts.map +frontend/vite.config.js +frontend/vite.config.d.ts +frontend/vite.config.d.ts.map +frontend/vite.config.js.map + +# Config files +SPEC.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0172e46 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,326 @@ + +## Project + +**Unified Media Manager (UMM)** + +A single Go + React service that replaces the entire \*arr stack (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Jellyseerr, Bazarr, Recyclarr, Analyzarr — 12 containers) with one unified media management platform. UMM handles media acquisition, download management, quality tracking, and library organization for movies, TV series, music, books, audiobooks, and podcasts. + +**Core Value:** Media downloads and imports correctly — searching indexers, grabbing releases, downloading via SABnzbd/qBittorrent, and importing completed files to the library with proper naming and hardlinks. If this works, the arr stack is replaceable. + +### Constraints + +- **Tech stack**: Go 1.22+ (Echo v4), PostgreSQL 16, React 18 + TypeScript + Tailwind — already chosen and in use +- **Network**: Backend must share Gluetun's network namespace for VPN-routed indexer/download traffic +- **Filesystem**: Hardlinking requires download dir and media library on same filesystem (TrueNAS NFS) +- **Compatibility**: File naming conventions must match existing library structure so Jellyfin/Navidrome/Calibre continue working +- **Protocol**: Must support Torznab/Newznab XML APIs and Cardigann YAML definitions for indexer compatibility +- **Existing services preserved**: Gluetun, SABnzbd, qBittorrent, FlareSolverr, Tdarr — UMM replaces acquisition/management layer only + + + +## Technology Stack + +## Languages +- Go 1.22 — Backend API server, all `internal/` and `cmd/` packages +- TypeScript 5.4+ — Frontend React application in `frontend/src/` +- SQL (PostgreSQL dialect) — Database schema in `internal/db/migrations/` +- YAML — Docker Compose config (`docker-compose.yml`) +- Nginx config — Frontend reverse proxy (`frontend/nginx.conf`) +## Runtime +- Backend: Linux container (Alpine 3.19), compiled with `CGO_ENABLED=0` +- Frontend: Node.js 20-alpine (build), Nginx Alpine (serve) +- Docker + Docker Compose v3.8 +- Go Modules — `go.mod` + `go.sum` present +- npm — `frontend/package-lock.json` present (lockfile committed) +## Frameworks +- Echo v4.12.0 — Go HTTP framework for REST API (`github.com/labstack/echo/v4`) +- React 18.3.1 — Frontend UI library +- React Router DOM 6.23.1 — Client-side routing +- Tailwind CSS 3.4.4 — Utility-first CSS framework +- No testing framework detected — no test files, no test config +- Vite 5.3.1 — Frontend build tool and dev server +- @vitejs/plugin-react 4.3.0 — Vite React plugin +- PostCSS 8.4.38 + Autoprefixer 10.4.19 — CSS processing pipeline +- Multi-stage Docker builds — `backend/Dockerfile` and `frontend/Dockerfile` +## Key Dependencies +- `github.com/labstack/echo/v4` v4.12.0 — HTTP routing, middleware, JSON binding +- `github.com/jackc/pgx/v5` v5.5.5 — PostgreSQL driver and connection pool +- `github.com/jackc/pgx/v5/pgxpool` — Connection pool (used in `internal/db/db.go`) +- `log/slog` (stdlib) — Structured logging +- `react` ^18.3.1 + `react-dom` ^18.3.1 — UI rendering +- `react-router-dom` ^6.23.1 — SPA routing +- PostgreSQL 16 (external) — Primary database, shared instance at `postgres-shared:5432` +- Nginx (Alpine) — Frontend static file server + API reverse proxy +- Traefik (external) — TLS termination and routing at `umm.local.tophermayor.com` +## Configuration +- Config loaded from env vars in `internal/config/config.go` +- `DATABASE_URL` — PostgreSQL connection string (default: `postgres://localhost:5432/unified_media_manager?sslmode=disable`) +- `QDRANT_URL` — Qdrant vector DB URL (default: `http://localhost:6333`) +- `OLLAMA_URL` — Ollama AI service URL (default: `http://localhost:11434`) +- `PORT` — Backend HTTP port (default: `8084`) +- `VITE_API_URL` — Frontend API base URL (build-time, default: `http://umm.local.tophermayor.com`) +- Backend: `go build -o /umm ./cmd/server/` via multi-stage Dockerfile +- Frontend: `tsc && vite build` → output to `frontend/dist/` +- Backend currently copies `frontend/dist/` into binary container (see `backend/Dockerfile:13`) +## Platform Requirements +- Go 1.22+ SDK +- Node.js 20+ +- Docker + Docker Compose +- PostgreSQL 16 accessible (or use Docker default) +- Docker host with external network `proxy-net` +- PostgreSQL instance (shared, `postgres-shared:5432`) +- Traefik reverse proxy on same Docker network +- Gluetun VPN container (planned: backend will use `network_mode: service:gluetun`) +- NFS mount from TrueNAS for media files (`/mnt/truenas/mediadata:/data`) +## Database Schema +- 3 custom enum types: `MEDIA_TYPE`, `MEDIA_STATUS`, `QUEUE_STATUS` +- 24 tables total across single `umm` database +- Partitioned `media` table (9 partitions by media type) +- Partitioned `download_history` (time-range partitioning, 90-day retention) +- Full-text search via GIN index on `media.title` +- JSONB columns for flexible metadata (`external_ids`, `metadata`, `images`, `quality`) +- Migration system: embedded SQL files in `internal/db/migrations/`, tracked via `schema_migrations` table + + + +## Conventions + +## Project Overview +## Naming Patterns +### Go (Backend) +- Use short, lowercase, single-word package names: `api`, `db`, `config` +- Package `internal/api/` contains all HTTP handlers in a single package (no sub-packages) +- Package `internal/db/` contains database pool, migrations, and embedded SQL files +- Package `internal/config/` contains environment config loading +- One domain entity per file: `media.go`, `queue.go`, `blocklist.go`, `indexers.go`, `dashboard.go`, `health.go` +- File names are lowercase, singular: `router.go`, not `routers.go` +- Use `_test.go` suffix for tests (none exist yet) +- Handler factory functions use the pattern `func (database *db.DB) echo.HandlerFunc` — e.g., `listMedia`, `createMedia`, `updateMedia`, `deleteMedia` +- Private helper functions use camelCase: `parsePagination`, `buildMediaWhere`, `scanMedia`, `scanMediaRows` +- Scanner functions accept an interface: `func scanQueueItem(scanner interface{ Scan(...interface{}) error })` +- Constants for column lists: `const queueColumns = \`id, media_id, ...\`` +- Local variables: camelCase — `pageSize`, `totalPages`, `setClauses` +- Struct fields: PascalCase — `TotalPages`, `PageSize`, `ReleaseTitle` +- Abbreviations stay uppercase: `ID`, `URL`, `API` — e.g., `MediaID`, `ReleaseURL`, `APIKey` +- Exported structs: PascalCase — `Media`, `QueueItem`, `BlocklistItem`, `Indexer`, `DashboardStats` +- Request DTOs: PascalCase with `Request` suffix — `createMediaRequest`, `updateMediaRequest` +- Response DTOs: PascalCase with `Response` suffix — `mediaDetailResponse`, `paginatedResponse` +- Use unexported structs for request/response types that don't leave the package +- JSON tags use snake_case: `` `json:"media_type"` ``, `` `json:"release_title"` `` +- Use `omitempty` on nullable/optional fields: `` `json:"original_title,omitempty"` `` +- Required fields never have `omitempty` +### TypeScript (Frontend) +- PascalCase for components: `Dashboard.tsx`, `Library.tsx`, `Queue.tsx` +- camelCase for utilities: `client.ts` +- Directories: lowercase — `pages/`, `api/`, `components/` +- Default exports for page components: `export default function Dashboard()` +- Functional components only (no class components) +- One component per file +- camelCase: `search`, `typeFilter`, `fetchAPI` +- PascalCase for types/interfaces: `DashboardData` +- Generic type parameter: `` in API functions +- Generic fetch wrappers in `frontend/src/api/client.ts` +- Three functions: `fetchAPI`, `postAPI`, `deleteAPI` +## Code Style +### Go Formatting & Linting +- Use `gofmt` / `goimports` — standard Go formatting +- Tabs for indentation (Go standard) +- No linter config detected (no `.golangci.yml`) +- Follow standard Go conventions +- Grouped imports: stdlib first, then project imports, then third-party +- Error wrapping with `fmt.Errorf("verb: %w", err)` — never raw error return +- Struct literal field alignment not enforced but generally consistent +### TypeScript Formatting & Linting +- No Prettier config detected +- 2-space indentation +- Single quotes for strings (JSX attributes use double quotes in some places) +- Trailing commas not enforced +- No ESLint config detected in the project (only in node_modules) +- `strict: true` enabled +- `noUncheckedIndexedAccess: true` — must check array/object access +- `exactOptionalPropertyTypes: true` — optional properties must be explicitly `undefined` +- `verbatimModuleSyntax: true` — use `import type` for type-only imports +- `jsx: "react-jsx"` — automatic JSX runtime (no need to import React in every file) +- `isolatedModules: true` — Vite compatibility +## Import Organization +### Go +### TypeScript +- None configured — use relative paths: `'../api/client'`, `'./pages/Dashboard'` +## Error Handling +### Go Backend +- Always return `map[string]string{"error": "message"}` for error JSON +- Use appropriate HTTP status codes: +- Wrap errors with context using `fmt.Errorf("verb: %w", err)` +- Log with `slog.Error("message", "key", value, "error", err)` before returning HTTP error +- Check `tag.RowsAffected()` after UPDATE/DELETE for not-found detection +- Every handler creates a context with timeout: `context.WithTimeout(c.Request().Context(), )` +- Read operations: 10-second timeout +- Write operations: 5-second timeout +- Indexer test: 15-second timeout +- Always `defer cancel()` immediately after creating context +### Frontend +- `fetchAPI`/`postAPI`/`deleteAPI` throw `Error` on non-OK responses: `throw new Error(\`API error: ${res.status}\`)` +- Currently swallowing errors in components: `.catch(() => {})` — this needs improvement +- No global error boundary or toast notification system +## Logging +- Use `slog.Info` for startup, shutdown, migration events +- Use `slog.Error` for all failures before returning HTTP errors +- Use `slog.Warn` for recoverable issues +- Key-value pairs use string keys: `"error"`, `"id"`, `"port"`, `"query"` +- Never use `fmt.Println` or `log.Println` — always use `slog` +## Comments +- Public functions: no doc comments in current codebase (should be added) +- Inline comments for SQL query construction: `// addCol helper for dynamic UPDATE` +- Column constant definitions explain the SQL: `const mediaColumns = \`...\`` +- No file-level comments +- No Go doc comments on exported types or functions — this is a gap +- No JSDoc/TSDoc on frontend functions +- No inline documentation for complex SQL queries +## Function Design +### Go Handlers +- Always return `c.JSON(statusCode, body)` — never return raw values +- Success responses use typed structs or `map[string]string{"status": "updated"}` +- Creation responses return `map[string]int64{"id": id}` with `http.StatusCreated` +### Frontend Components +## Database Query Conventions +### Query Building +- Define column list constants at file scope: `const queueColumns = \`id, media_id, ...\`` +- Use `fmt.Sprintf("SELECT %s FROM table", columns)` to compose queries +- Use `deleted_at IS NULL` in WHERE clauses for soft-deleted tables (`media`, `media_files`) +- Hard delete for `blocklist`, `download_queue` (use status changes instead) +- Use `parsePagination(c)` to extract `page` and `page_size` from query params +- Default: page 1, page_size 50, max 100 +- Return `paginatedResponse` struct with `data`, `total`, `page`, `page_size`, `total_pages` +### NULL Handling +- Use `sql.NullString`, `sql.NullInt64`, `sql.NullTime` for nullable columns +- Check `.Valid` then dereference: `if origTitle.Valid { m.OriginalTitle = &origTitle.String }` +- For `json.RawMessage` nullable fields, use `[]byte` scanner then convert +## CSS/Styling Conventions +- Background: `bg-gray-950` (page), `bg-gray-900` (nav/containers), `bg-gray-800` (inputs) +- Text: `text-white` (primary), `text-gray-400` (secondary), `text-gray-500` (placeholder) +- Accent: `text-indigo-400`, `focus:border-indigo-500` +- Status colors: `bg-blue-600`, `bg-green-600`, `bg-red-600`, `bg-orange-600`, etc. +## Module Design +### Go Exports +- `internal/config` → `Config` struct, `Load()` function +- `internal/db` → `DB` struct, `New()` constructor, `MigrationsFS` embedded FS +- `internal/api` → `NewRouter()` function (the only entry point into the API layer) +### Frontend Exports +## Configuration Conventions +- Read from environment variables via `os.Getenv()` +- Provide sensible defaults when env vars are unset +- Return a `*Config` struct — no global state, passed explicitly +- Use Vite env: `import.meta.env.VITE_API_URL` +- Empty string fallback means same-origin API calls + + + +## Architecture + +## Pattern Overview +- Backend serves both API (`/api/*`) and static frontend assets from the same Echo server +- Frontend is a separate Docker container with nginx that proxies `/api/*` to backend — but backend also has `e.Static("/", "frontend/dist")` as a fallback +- Handler-per-function pattern: each API endpoint is a standalone function returning `echo.HandlerFunc` via closure over `*db.DB` +- No service/repository layer — handlers directly execute SQL against `pgxpool.Pool` +- Database migrations embedded at compile time via `go:embed` +- PostgreSQL partitioned tables used for multi-tenant media types and time-bounded history +## Layers +- Purpose: Route registration, middleware, request parsing, response serialization +- Location: `internal/api/router.go` +- Contains: Echo route group definitions, CORS config, pagination helpers +- Depends on: Echo v4 framework, `internal/config`, `internal/db` +- Used by: `cmd/server/main.go` which calls `api.NewRouter()` +- Purpose: Per-resource CRUD handlers — business logic and SQL queries are inline +- Location: `internal/api/*.go` (one file per domain: `media.go`, `queue.go`, `blocklist.go`, `indexers.go`, `dashboard.go`, `health.go`) +- Contains: Type structs (request/response), scan helpers, handler functions +- Depends on: `internal/db` (for `*db.DB` pool access), `pgx/v5` for scanning +- Used by: Router layer via closure: `g.GET("/media", listMedia(database))` +- Purpose: Connection pool management, migration runner +- Location: `internal/db/db.go` +- Contains: `DB` struct wrapping `pgxpool.Pool`, `New()` constructor, `RunMigrations()` method +- Depends on: `pgx/v5/pgxpool`, embedded `migrations/*.sql` files +- Used by: All handler functions, `cmd/server/main.go` +- Purpose: Environment variable loading with defaults +- Location: `internal/config/config.go` +- Contains: `Config` struct with `DatabaseURL`, `QdrantURL`, `OllamaURL`, `Port` +- Depends on: `os` standard library only +- Used by: `cmd/server/main.go`, `internal/api/health.go` +- Purpose: User interface for media management +- Location: `frontend/src/` +- Contains: React components, API client wrapper, Tailwind-styled pages +- Depends on: React 18, react-router-dom 6, Tailwind CSS +- Used by: End users via browser; served by nginx container or backend static fallback +## Data Flow +- Backend: Stateless — all state in PostgreSQL. No in-memory caching, no sessions. +- Frontend: Local `useState` per component. No global state store (no Redux/Zustand). Each page fetches data independently via `useEffect`. +## Key Abstractions +- Purpose: Inject `*db.DB` dependency into handlers without global state +- Examples: `listMedia(database *db.DB) echo.HandlerFunc`, `dashboard(database *db.DB) echo.HandlerFunc` +- Pattern: Every handler is a function that takes `*db.DB` and returns a closure +```go +``` +- Purpose: Standardized envelope for all list endpoints +- Examples: Used in `listMedia`, `searchMedia`, `searchMissing`, `searchUpgrades`, `listQueue`, `listBlocklist` +- Pattern: `{data: [], total: N, page: N, page_size: N, total_pages: N}` +- Purpose: Reusable row scanning for types that appear in both single and multi-row queries +- Examples: `scanBlocklistItem(scanner interface{ Scan(...interface{}) error })`, `scanQueueItem(scanner)` +- Pattern: Accept `interface{ Scan(...interface{}) error }` so both `pgx.Row` and `pgx.Rows` can be passed +- Purpose: Construct parameterized SQL with variable WHERE clauses +- Examples: `buildMediaWhere()` in `media.go`, inline in `searchMedia`, `listQueue` +- Pattern: Maintain `idx` counter, build `[]string` conditions, `[]interface{}` args, format with `$N` placeholders +```go +``` +- Purpose: Ship SQL migrations inside the compiled binary +- Example: `internal/db/db.go:17` — `//go:embed migrations/*.sql` +- Pattern: `embed.FS` read at runtime, sorted lexicographically, applied transactionally +## Entry Points +- Location: `cmd/server/main.go` +- Triggers: Docker container start (`CMD ["./umm"]`) +- Responsibilities: +- Location: `frontend/vite.config.ts` +- Triggers: `npm run dev` (development only) +- Responsibilities: Vite dev server on port 3000 with HMR +- Location: `frontend/Dockerfile` +- Triggers: Docker container start (nginx) +- Responsibilities: Serves built SPA from `/usr/share/nginx/html`, proxies `/api/*` to backend +## Error Handling +- Handlers return `c.JSON(http.StatusN, map[string]string{"error": "message"})` for errors +- Database errors logged via `slog.Error()` with context fields then returned as HTTP 500 +- Validation errors return HTTP 400 with descriptive message +- Not-found returns HTTP 404 after checking `tag.RowsAffected() == 0` +- Scan errors in row iterators are logged and skipped (`continue`) rather than failing the whole request +- Migration failures are logged as warnings and don't stop server startup (`main.go:33`) +- All handlers use `context.WithTimeout` (5–15s) with deferred cancel +- No centralized error middleware +- No structured error types or error codes +- No request-level error logging correlation (no request IDs) +## Cross-Cutting Concerns + + + +## Project Skills + +No project skills found. Add skills to any of: `.OpenCode/skills/`, `.agents/skills/`, `.cursor/skills/`, or `.github/skills/` with a `SKILL.md` index file. + + + +## GSD Workflow Enforcement + +Before using edit, write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync. + +Use these entry points: +- `/gsd-quick` for small fixes, doc updates, and ad-hoc tasks +- `/gsd-debug` for investigation and bug fixing +- `/gsd-execute-phase` for planned phase work + +Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it. + + + + + +## Developer Profile + +> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile. +> This section is managed by `generate-OpenCode-profile` -- do not edit manually. + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e2608c7 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.25-alpine AS builder +RUN apk add --no-cache gcc musl-dev +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go get github.com/jackc/pgx/v5@v5.5.5 && go mod tidy +RUN CGO_ENABLED=0 GOOS=linux go build -o /umm ./cmd/server/ +RUN CGO_ENABLED=1 GOOS=linux go build -o /umm-migrate ./cmd/migrate/ + +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates wget ffmpeg +WORKDIR /app +COPY --from=builder /umm . +COPY --from=builder /umm-migrate . +COPY internal/db/migrations/ ./internal/db/migrations/ +EXPOSE 8084 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -qO- http://localhost:8084/health/live || exit 1 +CMD ["./umm"] diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..634f2af --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "time" + + "github.com/TopherMayor/unified-media-manager/internal/db" + "github.com/TopherMayor/unified-media-manager/internal/migrate" +) + +func main() { + // Flags for each arr database path + sonarr := flag.String("sonarr", "", "Path to Sonarr SQLite database") + radarr := flag.String("radarr", "", "Path to Radarr SQLite database") + sonarrAnime := flag.String("sonarr-anime", "", "Path to Sonarr-anime SQLite database") + radarrAnime := flag.String("radarr-anime", "", "Path to Radarr-anime SQLite database") + lidarr := flag.String("lidarr", "", "Path to Lidarr SQLite database") + readarr := flag.String("readarr", "", "Path to Readarr SQLite database") + prowlarr := flag.String("prowlarr", "", "Path to Prowlarr SQLite database") + databaseURL := flag.String("database-url", "", "PostgreSQL connection string (or set DATABASE_URL env)") + flag.Parse() + + if *databaseURL == "" { + *databaseURL = os.Getenv("DATABASE_URL") + } + if *databaseURL == "" { + *databaseURL = "postgres://bear:bear123@postgres-shared:5432/umm?sslmode=disable" + } + + slog.Info("starting UMM arr data migration tool") + + // Connect to PostgreSQL + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + database, err := db.New(ctx, *databaseURL) + cancel() + if err != nil { + slog.Error("failed to connect to database", "error", err) + os.Exit(1) + } + defer database.Close() + + // Run schema migrations to ensure tables exist + if err := database.RunMigrations(context.Background(), db.MigrationsFS); err != nil { + slog.Error("failed to run migrations", "error", err) + os.Exit(1) + } + + sources := migrate.ArrSources{ + Sonarr: *sonarr, + Radarr: *radarr, + SonarrAnime: *sonarrAnime, + RadarrAnime: *radarrAnime, + Lidarr: *lidarr, + Readarr: *readarr, + Prowlarr: *prowlarr, + } + + m := migrate.NewMigrator(database, sources) + report, err := m.Run(context.Background()) + if err != nil { + slog.Error("migration failed", "error", err) + fmt.Println() + fmt.Println(report.String()) + os.Exit(1) + } + + fmt.Println() + fmt.Println(report.String()) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9fffa4a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +version: "3.8" + +services: + backend: + build: + context: . + dockerfile: backend/Dockerfile + network_mode: "container:gluetun" + environment: + PORT: "8084" + DATABASE_URL: ${DATABASE_URL:-postgres://bear:bear123@postgres-shared:5432/umm?sslmode=disable} + QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333} + OLLAMA_URL: ${OLLAMA_URL:-http://192.168.50.84:11434} + FRONTEND_URL: ${FRONTEND_URL:-http://umm.local.tophermayor.com} + ADMIN_API_KEY: ${ADMIN_API_KEY:-03136aebde4bd783ae6ada1abdc8debbd11e3fe89380d87d} + TMDB_API_KEY: ${TMDB_API_KEY:-} + DOWNLOAD_DIR: /data/completed + volumes: + - /mnt/truenas/mediadata:/data + # Arr SQLite databases for migration (read-only) + - /home/bear/homelab/ubuntu/media-stack/sonarr:/data/arr-configs/sonarr:ro + - /home/bear/homelab/ubuntu/media-stack/radarr:/data/arr-configs/radarr:ro + - /home/bear/homelab/ubuntu/media-stack/sonarr-anime:/data/arr-configs/sonarr-anime:ro + - /home/bear/homelab/ubuntu/media-stack/radarr-anime:/data/arr-configs/radarr-anime:ro + - /home/bear/homelab/ubuntu/media-stack/lidarr:/data/arr-configs/lidarr:ro + - /home/bear/homelab/ubuntu/media-stack/readarr:/data/arr-configs/readarr:ro + - /home/bear/homelab/ubuntu/media-stack/prowlarr:/data/arr-configs/prowlarr:ro + restart: unless-stopped + depends_on: + - frontend + + frontend: + build: + context: frontend + dockerfile: Dockerfile + environment: + VITE_API_URL: ${VITE_API_URL:-http://umm.local.tophermayor.com} + networks: + - proxy-net + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.umm.rule=Host(`umm.local.tophermayor.com`)" + - "traefik.http.routers.umm.entrypoints=websecure" + - "traefik.http.services.umm.loadbalancer.server.port=3000" + +networks: + proxy-net: + external: true diff --git a/docs/UX-FLOWS.md b/docs/UX-FLOWS.md new file mode 100644 index 0000000..14d84ef --- /dev/null +++ b/docs/UX-FLOWS.md @@ -0,0 +1,301 @@ +# UMM UX Flows — Implemented & Tested + +**Last verified:** 2026-04-22 (deployed build, backend healthy) + +## Navigation + +| Route | Page | Nav Label | +|-------|------|-----------| +| `/` | Dashboard | Dashboard | +| `/library` | Library | Library | +| `/library/:type/:id` | Media Detail | (via Library click) | +| `/discover` | Discover | Discover | +| `/calendar` | Calendar | Calendar | +| `/queue` | Download Queue | Queue | +| `/search` | Search Indexers | Search | +| `/activity` | Activity | Activity | +| `/requests` | Requests | Requests | +| `/blocklist` | Blocklist | Blocklist | +| `/settings` | Settings | Settings | + +--- + +## Page-by-Page Flows + +### Dashboard (`/`) + +**API:** `GET /api/dashboard` (200 OK) + +- 8 stat cards: Movies, TV Series, Music, Books, Active Downloads, Missing, Upgrades Available, Total Storage +- Auto-fetches on mount +- Skeleton loading while fetching +- Error banner with retry on failure + +### Library (`/library`) + +**API:** `GET /api/media?page=1&page_size=50` (200 OK) + +- Search input with 300ms debounce +- Type filter: All, Movie, Series, Music, Book, Audiobook, Podcast, Photo, Other +- Status filter: All, Available, Unavailable, Downloading, Failed +- Paginated table (50 per page) +- Each row: Title (click → detail), Type, Status badge, Quality, Monitor toggle +- Monitor toggle: optimistic update, rollback on error with toast +- Empty state messages for no results vs empty library + +### Media Detail (`/library/:type/:id`) + +**API:** `GET /api/media/:type/:id/detail` (200 OK) + +5 tabs (4 for non-series): + +**Overview tab:** +- Poster image (or letter placeholder) +- Title, year, original title +- Status badge, monitored indicator +- TMDB rating, genres +- Quality profile name +- Current quality / desired quality +- Synopsis text + +**Search tab:** +- Auto-searches indexers for media title +- Results via `ReleaseSearchResults` component +- Sortable by quality, size, seeders, age + +**Files tab:** +- File table: name, quality, size, source, codec, subtitles +- Per-file subtitle badges (language code, SDH, Forced, source) +- "Search" button per file → subtitle search modal +- "Download" button per subtitle result +- "Extract All Subtitles" button (extracts embedded subs) + +**Episodes tab (series only):** +- Episodes grouped by season +- Columns: episode #, title, status, monitored, has file, quality + +**History tab:** +- Timeline with color-coded badges: Grab, Import, Download, Failed, Upgrade, Blocked, Error +- Relative timestamps (Xm ago, Xh ago, Xd ago) + +**Header actions:** +- "Refresh Metadata" button → `POST /api/media/:type/:id/refresh-metadata` +- "Delete" button → confirmation modal → `DELETE /api/media/:type/:id` → redirect to Library + +### Discover (`/discover`) + +**API:** `GET /api/discover/trending?type=movie&page=1` (401 — requires API key auth) +**API:** `GET /api/discover/popular?type=movie&page=1` (401 — requires API key auth) + +- Trending / Popular tab toggle +- Movie / Series type filter +- Poster grid with hover overlay (title, year, rating, "Add to Library" button) +- Green checkmark badge for items already in library +- "Add to Library" → `POST /api/discover/add` → toast feedback +- Paginated (prev/next, up to page 10) + +**Note:** Requires API key authentication. Frontend does not currently send API keys, so this flow will 401 in production. + +### Calendar (`/calendar`) + +**API:** `GET /api/calendar?month=2026-04` (200 OK) + +- Monthly grid (6 rows × 7 cols) +- Navigation: prev/next month, "Today" button +- Events color-coded by type: movie (indigo), series (emerald), music (amber), audiobook (rose), podcast (violet) +- Click event → navigate to media detail +- "Today" date highlighted with indigo ring +- "+N more" for days with >3 events + +### Download Queue (`/queue`) + +**API:** `GET /api/queue` (200 OK) + +- Tabs: All, Downloading, Pending, Failed, Imported, History +- Auto-refresh every 5 seconds +- Per-item: release title, status badge, download client, quality, size +- Progress bar for downloading items +- Error message display for failed items +- Actions: Cancel (with confirmation modal), Retry, Retry All Failed, Clear Completed +- "Check for Completed Downloads" → `POST /api/imports/trigger` +- Import History tab with pagination (`GET /api/imports/history`) + +### Search Indexers (`/search`) + +**API:** `GET /api/releases/search?query=...` (500 — "no enabled indexers available" when none configured) + +- Free-text search across all enabled indexers +- Sortable columns: Quality, Title, Size, Indexer, Seeders, Age +- "Select & Grab" button per result → opens media selector modal + - Modal: debounced library search + - Select media item → `POST /api/releases/grab` → toast +- Empty state for no query, no results, loading skeleton + +**Note:** Returns 500 when no indexers are configured. This is expected behavior. + +### Activity (`/activity`) + +**API:** `GET /api/activity?page=1&page_size=50` (200 OK) + +- Paginated event log (50 per page) +- Type filter dropdown: All, Grabs, Imports, Downloads, Failures, Upgrades, Safety Blocks, Errors +- Color-coded badges: Grab (blue), Import/Download (green), Failed/Error (red), Upgrade (purple), Blocked (orange) +- Relative timestamps + +### Requests (`/requests`) + +**API:** `GET /api/requests?page=1&page_size=20` (401 — requires API key auth) + +- Filter tabs: All, Pending, Approved, Fulfilled, Rejected +- "+ New Request" modal: + - Title (required), Type (7 options), Year, Quality Profile dropdown, Root Folder dropdown + - Submit → `POST /api/requests` +- Per-request card: title, year, status badge, requester, time ago, quality profile, root folder +- Actions for pending: Approve (green) → `PUT /api/requests/:id/approve`, Reject (red, with confirmation) → `PUT /api/requests/:id/reject` +- Withdraw link for pending/approved → `DELETE /api/requests/:id` + +**Note:** Requires API key authentication. Frontend does not currently send API keys. + +### Blocklist (`/blocklist`) + +**API:** `GET /api/blocklist?page=1&page_size=50` (200 OK) + +- Paginated table (50 per page) +- Checkboxes for bulk selection (select all toggle) +- Per-row: release title, indexer, quality, reason, date, delete button +- Bulk "Delete Selected" → `DELETE /api/blocklist/:id` per item +- "Clear Expired" → `DELETE /api/blocklist/expired` +- "Clear All" → confirmation modal → `DELETE /api/blocklist` + +### Settings (`/settings`) + +8 sections in 2-column grid layout: + +#### Notifications +**API:** `GET /api/notifications/channels` (200 OK) + +- List channels with enable/disable dot, name, type badge (webhook/telegram), config preview +- Inline edit: name, URL/bot token/chat ID, event type checkboxes (8 types), save/cancel/test/delete +- Add channel: name, type selector, URL or bot token + chat ID, event type checkboxes +- Test button → `POST /api/notifications/channels/:id/test` + +#### Indexers +**API:** `GET /api/indexers` (200 OK) + +- List with enable/disable dot, name, implementation badge (torznab/newznab/cardigann), URL +- Inline edit: name, URL, API key (masked), enable toggle, save/cancel/test/delete +- Add indexer: name, implementation selector (Newznab, Torznab, Cardigann) + - Cardigann: YAML textarea, validate button → `POST /api/indexers/validate-cardigann`, settings fields from definition +- Test → `POST /api/indexers/:id/test` + +#### Download Clients +**API:** `GET /api/download-clients` (200 OK) + +- List with enable/disable dot, name, implementation, URL +- Inline edit: name, URL, API key, category, priority, enable toggle, save/cancel/test/delete +- Add: name, implementation (SABnzbd, qBittorrent), URL, API key +- Test → `POST /api/download-clients/:id/test` + +#### Quality Profiles +**API:** `GET /api/quality-profiles` (200 OK) + +- List with name, media type badges +- Inline edit: name, cutoff quality, allowed qualities (comma-sep), save/cancel/delete +- Add: name, media types (comma-sep), cutoff quality, allowed qualities + +#### Root Folders +**API:** `GET /api/root-folders` (200 OK) + +- List with path, media type, free space +- Inline edit: path, media type (delete + re-create pattern), save/cancel/delete +- Add: path, media type + +#### Tags +**API:** `GET /api/tags` (200 OK — fixed) + +- List with name, color swatch +- Inline edit: name, color, save/cancel/delete +- Add: name, color (hex) + +#### Tasks +**API:** `GET /api/workers` (401 — requires API key auth, different from `/api/tasks`) + +- Scheduled tasks list with name, cron expression, enabled toggle, last run, next run +- Enable/disable toggle per task + +**Note:** Frontend calls `/api/tasks` but router registers `/api/workers`. May result in 404. + +#### Metadata +**API:** `POST /api/media/refresh-all` (200 OK, no auth required) + +- Description text about refresh scope +- "Refresh All Metadata" button → confirmation modal → POST +- Loading state while refreshing + +--- + +## Shared Components + +| Component | Used By | Purpose | +|-----------|---------|---------| +| `Toast` | All pages | Success/error feedback (top-right, auto-dismiss) | +| `ConfirmModal` | Queue, Blocklist, Media Detail, Settings, Requests | Confirmation for destructive/batch actions | +| `Pagination` | Library, Queue, Activity, Blocklist, Requests | Page navigation with total/pages display | +| `StatusBadge` | Library, Queue, Media Detail, Requests | Color-coded status labels | +| `ErrorBanner` | All pages | Error display with retry button | +| `Loading` | Dashboard, Queue, Activity, Blocklist, Settings | Skeleton/spinner loading states | +| `ReleaseSearchResults` | Media Detail, Search | Release result table with grab actions | + +--- + +## API Test Results (2026-04-22) + +### Working (200 OK) + +| Endpoint | Status | Notes | +|----------|--------|-------| +| `GET /health/live` | 200 | `{"status":"alive"}` | +| `GET /api/dashboard` | 200 | Stats returned, all zeros (empty DB) | +| `GET /api/media` | 200 | Paginated, empty | +| `GET /api/queue` | 200 | Empty | +| `GET /api/blocklist` | 200 | Paginated, empty | +| `GET /api/activity` | 200 | Paginated, empty | +| `GET /api/indexers` | 200 | Empty | +| `GET /api/download-clients` | 200 | Empty | +| `GET /api/quality-profiles` | 200 | Empty | +| `GET /api/root-folders` | 200 | Empty | +| `GET /api/tags` | 200 | Empty (fixed: removed `created_at` column ref) | +| `GET /api/notifications/channels` | 200 | Empty | +| `GET /api/calendar` | 200 | Empty events | +| `GET /api/search` | 200 | Empty results | +| `GET /api/imports/history` | 200 | Empty | +| `POST /api/media/refresh-all` | 200 | Triggers refresh | + +### Auth-Required (401 Unauthorized) + +| Endpoint | Notes | +|----------|-------| +| `GET /api/requests` | API key auth middleware | +| `GET /api/discover/trending` | API key auth middleware | +| `GET /api/discover/popular` | API key auth middleware | +| `GET /api/workers` | API key auth middleware | + +**Root cause:** Frontend `client.ts` does not send `X-API-Key` header or `api_key` query param. These flows (Requests, Discover, Tasks) will 401 in production unless auth is configured or the frontend is updated to pass API keys. + +### Expected Errors (500) + +| Endpoint | Error | Cause | +|----------|-------|-------| +| `GET /api/releases/search` | "no enabled indexers available" | No indexers configured yet | + +--- + +## Known Issues + +1. **Auth-protected endpoints unreachable from frontend** — Requests, Discover, and Tasks pages will show errors because the API client doesn't pass API keys. The `newAPIKeyAuth` middleware requires `X-API-Key` header or `api_key` query param. + +2. **Tasks page route mismatch** — Frontend calls `/api/tasks` but router registers workers at `/api/workers`. Needs alignment. + +3. **No indexers configured** — Release search (used by Search page and Media Detail Search tab) returns 500. Expected until indexers are added via Settings. + +4. **Frontend CORS** — CORS is configured to allow `cfg.FrontendURL` only. Requests from other origins will be rejected. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ee20d02 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 3000 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..35bae25 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Unified Media Manager + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..8b214c8 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 3000; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + gzip on; + gzip_types application/json; + gzip_min_length 256; + gzip_vary on; + + location /api/ { + proxy_pass http://gluetun:8084/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f870fdb --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2746 @@ +{ + "name": "umm-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "umm-frontend", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.99.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "vite": "^5.3.1" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.99.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", + "integrity": "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.99.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz", + "integrity": "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.99.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.338", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.338.tgz", + "integrity": "sha512-KVQQ3xko9/coDX3qXLUEEbqkKT8L+1DyAovrtu0Khtrt9wjSZ+7CZV4GVzxFy9Oe1NbrIU1oVXCwHJruIA1PNg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..45c1d10 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "umm-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.99.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "vite": "^5.3.1" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..61362eb --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,76 @@ +import { Routes, Route, NavLink } from 'react-router-dom' +import { lazy, Suspense } from 'react' +import { QueryProvider } from './api/queryClient' +import { ToastProvider } from './components/Toast' +import Loading from './components/Loading' + +const Dashboard = lazy(() => import('./pages/Dashboard')) +const Library = lazy(() => import('./pages/Library')) +const Discover = lazy(() => import('./pages/Discover')) +const Calendar = lazy(() => import('./pages/Calendar')) +const MediaDetail = lazy(() => import('./pages/MediaDetail')) +const Queue = lazy(() => import('./pages/Queue')) +const Requests = lazy(() => import('./pages/Requests')) +const Activity = lazy(() => import('./pages/Activity')) +const Blocklist = lazy(() => import('./pages/Blocklist')) +const Settings = lazy(() => import('./pages/Settings')) +const Search = lazy(() => import('./pages/Search')) + +const navItems = [ + { to: '/', label: 'Dashboard' }, + { to: '/library', label: 'Library' }, + { to: '/discover', label: 'Discover' }, + { to: '/calendar', label: 'Calendar' }, + { to: '/queue', label: 'Queue' }, + { to: '/search', label: 'Search' }, + { to: '/activity', label: 'Activity' }, + { to: '/requests', label: 'Requests' }, + { to: '/blocklist', label: 'Blocklist' }, + { to: '/settings', label: 'Settings' }, +] + +export default function App() { + return ( + + +
+ + Skip to content + + +
+ }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +
+
+
+
+ ) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..7148144 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,64 @@ +const API = import.meta.env.VITE_API_URL || '' + +function getAPIKey(): string | null { + return localStorage.getItem('umm_api_key') +} + +function authHeaders(): Record { + const key = getAPIKey() + if (key) return { 'X-API-Key': key } + return {} +} + +function jsonHeaders(): Record { + return { 'Content-Type': 'application/json', ...authHeaders() } +} + +export function setAPIKey(key: string) { + localStorage.setItem('umm_api_key', key) +} + +export function clearAPIKey() { + localStorage.removeItem('umm_api_key') +} + +export function hasAPIKey(): boolean { + return !!getAPIKey() +} + +export async function fetchAPI(path: string, init?: RequestInit): Promise { + const res = await fetch(`${API}${path}`, { ...init, headers: { ...authHeaders(), ...(init?.headers || {}) } }) + if (res.status === 401 && path.startsWith('/api/requests')) { + throw new Error('Authentication required. Please set your API key in Settings.') + } + if (!res.ok) throw new Error(`API error: ${res.status}`) + return res.json() +} + +export async function postAPI(path: string, body: unknown, init?: RequestInit): Promise { + const res = await fetch(`${API}${path}`, { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify(body), + ...init, + }) + if (!res.ok) throw new Error(`API error: ${res.status}`) + return res.json() +} + +export async function putAPI(path: string, body: unknown, init?: RequestInit): Promise { + const res = await fetch(`${API}${path}`, { + method: 'PUT', + headers: jsonHeaders(), + body: JSON.stringify(body), + ...init, + }) + if (!res.ok) throw new Error(`API error: ${res.status}`) + return res.json() +} + +export async function deleteAPI(path: string, init?: RequestInit): Promise { + const res = await fetch(`${API}${path}`, { method: 'DELETE', headers: authHeaders(), ...init }) + if (!res.ok) throw new Error(`API error: ${res.status}`) + return res.json() +} diff --git a/frontend/src/api/queryClient.tsx b/frontend/src/api/queryClient.tsx new file mode 100644 index 0000000..15c6355 --- /dev/null +++ b/frontend/src/api/queryClient.tsx @@ -0,0 +1,22 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}) + +export function QueryProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +export { queryClient } diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..0225219 --- /dev/null +++ b/frontend/src/components/ConfirmModal.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef, type ReactNode } from 'react' + +interface ConfirmModalProps { + open: boolean + title: string + message: string + onConfirm: () => void + onCancel: () => void + destructive?: boolean + confirmLabel?: string + children?: ReactNode +} + +const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + +export default function ConfirmModal({ open, title, message, onConfirm, onCancel, destructive, confirmLabel, children }: ConfirmModalProps) { + const dialogRef = useRef(null) + const cancelRef = useRef(null) + + useEffect(() => { + if (!open) return + + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel() + return + } + if (e.key === 'Tab' && dialogRef.current) { + const focusable = Array.from(dialogRef.current.querySelectorAll(FOCUSABLE)) + if (!focusable.length) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault() + last.focus() + } + } else { + if (document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + } + } + + document.addEventListener('keydown', handleKey) + cancelRef.current?.focus() + + return () => document.removeEventListener('keydown', handleKey) + }, [open, onCancel]) + + if (!open) return null + + return ( +
+
e.stopPropagation()} + > +

{title}

+

{message}

+ {children} +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ErrorBanner.tsx b/frontend/src/components/ErrorBanner.tsx new file mode 100644 index 0000000..789cbcf --- /dev/null +++ b/frontend/src/components/ErrorBanner.tsx @@ -0,0 +1,12 @@ +export default function ErrorBanner({ error, onRetry }: { error: string; onRetry?: () => void }) { + return ( +
+

{error}

+ {onRetry && ( + + )} +
+ ) +} diff --git a/frontend/src/components/Loading.tsx b/frontend/src/components/Loading.tsx new file mode 100644 index 0000000..674830f --- /dev/null +++ b/frontend/src/components/Loading.tsx @@ -0,0 +1,7 @@ +export default function Loading() { + return ( +
+
+
+ ) +} diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx new file mode 100644 index 0000000..95994d6 --- /dev/null +++ b/frontend/src/components/Pagination.tsx @@ -0,0 +1,52 @@ +export default function Pagination({ total, page, pageSize, onPageChange }: { + total: number + page: number + pageSize: number + onPageChange: (p: number) => void +}) { + const totalPages = Math.ceil(total / pageSize) + if (totalPages <= 1) return null + + const pages: number[] = [] + const maxVisible = 7 + let start = Math.max(1, page - Math.floor(maxVisible / 2)) + const end = Math.min(totalPages, start + maxVisible - 1) + start = Math.max(1, end - maxVisible + 1) + + for (let i = start; i <= end; i++) { + pages.push(i) + } + + return ( +
+ {total} items +
+ + {pages.map(p => ( + + ))} + +
+
+ ) +} diff --git a/frontend/src/components/ReleaseSearchResults.tsx b/frontend/src/components/ReleaseSearchResults.tsx new file mode 100644 index 0000000..c230143 --- /dev/null +++ b/frontend/src/components/ReleaseSearchResults.tsx @@ -0,0 +1,218 @@ +import { useState } from 'react' +import { postAPI } from '../api/client' +import { useToast } from './Toast' + +interface SearchResult { + title: string + guid: string + size: number + pub_date: string + indexer_name: string + indexer_priority: number + quality: { + title: string + resolution: string + source: string + video_codec: string + release_group: string + parse_warning: boolean + } + quality_tier: { + name: string + rank: number + resolution: string + } | null + seeders: number + peers: number + category: string + download_url: string + source_indexers: string[] +} + +interface GrabPayload { + download_url: string + title: string + media_type: string + quality: SearchResult['quality'] + indexer_name: string + media_id: number +} + +interface ReleaseSearchResultsProps { + results: SearchResult[] + mediaId: number + mediaType: string + loading?: boolean +} + +type SortCol = 'quality' | 'size' | 'seeders' | 'age' +type SortDir = 'asc' | 'desc' + +function formatFileSize(bytes: number): string { + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB` + } + if (bytes >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(1)} MB` + } + return `${(bytes / 1024).toFixed(0)} KB` +} + +function formatAge(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime() + const minutes = Math.floor(diff / 60000) + if (minutes < 1) return 'now' + if (minutes < 60) return `${minutes}m` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d` + const months = Math.floor(days / 30) + return `${months}mo` +} + +export function ReleaseSearchResults({ results, mediaId, mediaType, loading }: ReleaseSearchResultsProps) { + const [sortCol, setSortCol] = useState('quality') + const [sortDir, setSortDir] = useState('asc') + const [grabbing, setGrabbing] = useState>(new Set()) + const { showToast } = useToast() + + function handleSort(col: SortCol) { + if (sortCol === col) { + setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc')) + } else { + setSortCol(col) + setSortDir('asc') + } + } + + const sorted = [...results].sort((a, b) => { + let cmp = 0 + switch (sortCol) { + case 'quality': + cmp = (a.quality_tier?.rank ?? 999) - (b.quality_tier?.rank ?? 999) + break + case 'size': + cmp = a.size - b.size + break + case 'seeders': + cmp = a.seeders - b.seeders + break + case 'age': + cmp = new Date(a.pub_date).getTime() - new Date(b.pub_date).getTime() + break + } + return sortDir === 'asc' ? cmp : -cmp + }) + + async function handleGrab(result: SearchResult) { + setGrabbing(prev => new Set(prev).add(result.guid)) + try { + await postAPI<{ queue_id: number }>('/api/releases/grab', { + download_url: result.download_url, + title: result.title, + media_type: mediaType, + quality: result.quality, + indexer_name: result.indexer_name, + media_id: mediaId, + } satisfies GrabPayload) + showToast(`✓ Grabbed "${result.title}"`) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + showToast(`✗ Failed to grab: ${message}`) + } finally { + setGrabbing(prev => { + const next = new Set(prev) + next.delete(result.guid) + return next + }) + } + } + + function SortHeader({ col, label }: { col: SortCol; label: string }) { + const active = sortCol === col + return ( + + ) + } + + if (loading) { + return ( +
+
+
+
+
+ ) + } + + if (results.length === 0) { + return
No releases found
+ } + + return ( +
+ + + + + + + + + + + + + + {sorted.map(result => { + const isGrabbing = grabbing.has(result.guid) + const noUrl = !result.download_url + return ( + + + + + + + + + + ) + })} + +
TitleIndexerAction
+ {result.quality_tier ? ( + {result.quality_tier.name} + ) : result.quality.resolution || result.quality.source ? ( + {result.quality.resolution} {result.quality.source} + ) : ( + Unknown + )} + + {result.title} + {formatFileSize(result.size)}{result.indexer_name} + 0 ? 'text-green-400' : ''}>{result.seeders} + {' / '} + {result.peers} + {formatAge(result.pub_date)} + +
+
+ ) +} + +export default ReleaseSearchResults diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..1a2be96 --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,23 @@ +const statusColors: Record = { + pending: 'bg-yellow-900/30 text-yellow-400', + approved: 'bg-blue-900/30 text-blue-400', + searching: 'bg-cyan-900/30 text-cyan-400', + downloading: 'bg-orange-900/30 text-orange-400', + fulfilled: 'bg-green-900/30 text-green-400', + failed: 'bg-red-900/30 text-red-400', + available: 'bg-green-900/30 text-green-400', + unavailable: 'bg-red-900/30 text-red-400', + imported: 'bg-green-900/30 text-green-400', + completed: 'bg-green-900/30 text-green-400', + rejected: 'bg-red-900/30 text-red-400', +} + +export default function StatusBadge({ status }: { status: string }) { + const colorClass = statusColors[status] ?? 'bg-gray-800 text-gray-400' + + return ( + + {status} + + ) +} diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..bf54c92 --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, useState, useCallback } from 'react' +import type { ReactNode } from 'react' + +interface ToastContextValue { + showToast: (message: string) => void +} + +const ToastContext = createContext({ showToast: () => {} }) + +export function useToast(): ToastContextValue { + return useContext(ToastContext) +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toast, setToast] = useState(null) + + const showToast = useCallback((message: string) => { + setToast(message) + setTimeout(() => setToast(null), 3000) + }, []) + + return ( + + {children} +{toast && ( +
+

{toast}

+
+ )} +
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..9353819 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,22 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +:focus-visible { + outline: 2px solid #818cf8; + outline-offset: 2px; +} + +:focus:not(:focus-visible) { + outline: none; +} + +@media (max-width: 768px) { + input, select, textarea { + font-size: 16px !important; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..3650a17 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + } /> + + + , +) diff --git a/frontend/src/pages/Activity.tsx b/frontend/src/pages/Activity.tsx new file mode 100644 index 0000000..c12f69e --- /dev/null +++ b/frontend/src/pages/Activity.tsx @@ -0,0 +1,160 @@ +import { useEffect, useState, useCallback } from 'react' +import { fetchAPI } from '../api/client' +import Pagination from '../components/Pagination' +import ErrorBanner from '../components/ErrorBanner' +import Loading from '../components/Loading' + +interface ActivityEvent { + id: number + event_type: string + media_id: number | null + media_type: string | null + title: string + description: string | null + data: Record + created_at: string +} + +interface PaginatedResponse { + data: ActivityEvent[] + total: number + page: number + page_size: number + total_pages: number +} + +const PAGE_SIZE = 50 + +function eventTypeBadge(type: string): { label: string; className: string } { + switch (type) { + case 'grab': + return { label: 'Grab', className: 'bg-blue-600' } + case 'import': + return { label: 'Import', className: 'bg-green-600' } + case 'download_complete': + return { label: 'Download', className: 'bg-green-600' } + case 'download_failed': + return { label: 'Failed', className: 'bg-red-600' } + case 'quality_upgrade': + return { label: 'Upgrade', className: 'bg-purple-600' } + case 'safety_block': + return { label: 'Blocked', className: 'bg-orange-600' } + case 'error': + return { label: 'Error', className: 'bg-red-600' } + default: + return { label: 'Info', className: 'bg-gray-600' } + } +} + +function formatTimeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime() + const minutes = Math.floor(diff / 60000) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +export default function Activity() { + const [events, setEvents] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [typeFilter, setTypeFilter] = useState('') + + const fetchActivity = useCallback(() => { + setLoading(true) + setError(null) + let url = `/api/activity?page=${page}&page_size=${PAGE_SIZE}` + if (typeFilter) url += `&event_type=${typeFilter}` + fetchAPI(url) + .then(res => { + setEvents(res.data ?? []) + setTotal(res.total) + }) + .catch(err => setError(err.message || 'Something went wrong. Please try again.')) + .finally(() => setLoading(false)) + }, [page, typeFilter]) + + useEffect(() => { + fetchActivity() + }, [fetchActivity]) + + return ( +
+
+

Activity

+ +
+ + {error && } + + {loading ? ( + + ) : events.length === 0 ? ( +
No activity events
+ ) : ( + <> +
+ + + + + + + + + + + {events.map(event => { + const badge = eventTypeBadge(event.event_type) + return ( + + + + + + + ) + })} + +
TypeTitleMediaTime
+ + {badge.label} + + +

{event.title}

+ {event.description && ( +

{event.description}

+ )} +
+ {event.media_id != null ? `${event.media_type} #${event.media_id}` : '—'} + {formatTimeAgo(event.created_at)}
+
+ + + + )} +
+ ) +} diff --git a/frontend/src/pages/Blocklist.tsx b/frontend/src/pages/Blocklist.tsx new file mode 100644 index 0000000..733aeec --- /dev/null +++ b/frontend/src/pages/Blocklist.tsx @@ -0,0 +1,243 @@ +import { useEffect, useState, useCallback } from 'react' +import { fetchAPI, deleteAPI } from '../api/client' +import { useToast } from '../components/Toast' +import Pagination from '../components/Pagination' +import ErrorBanner from '../components/ErrorBanner' +import Loading from '../components/Loading' +import ConfirmModal from '../components/ConfirmModal' + +interface BlocklistItem { + id: number + release_title: string + source_title: string | null + quality: { resolution?: string } | null + indexer: string | null + protocol: string + size: number | null + message: string | null + block_reason: string + created_at: string +} + +interface PaginatedResponse { + data: BlocklistItem[] + total: number + page: number + page_size: number + total_pages: number +} + +const PAGE_SIZE = 50 + +export default function Blocklist() { + const { showToast } = useToast() + const [items, setItems] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selected, setSelected] = useState>(new Set()) + const [clearAllOpen, setClearAllOpen] = useState(false) + const [deleting, setDeleting] = useState(false) + + const fetchBlocklist = useCallback(() => { + setLoading(true) + setError(null) + fetchAPI(`/api/blocklist?page=${page}&page_size=${PAGE_SIZE}`) + .then(res => { + setItems(res.data ?? []) + setTotal(res.total) + setSelected(new Set()) + }) + .catch(err => setError(err.message || 'Something went wrong. Please try again.')) + .finally(() => setLoading(false)) + }, [page]) + + useEffect(() => { + fetchBlocklist() + }, [fetchBlocklist]) + + const toggleSelect = (id: number) => { + setSelected(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const toggleSelectAll = () => { + if (selected.size === items.length) { + setSelected(new Set()) + } else { + setSelected(new Set(items.map(i => i.id))) + } + } + + const deleteSelected = async () => { + setDeleting(true) + let failed = 0 + for (const id of selected) { + try { + await deleteAPI(`/api/blocklist/${id}`) + } catch { + failed++ + } + } + showToast(failed ? `Deleted ${selected.size - failed} items, ${failed} failed` : `Deleted ${selected.size} items`) + setDeleting(false) + fetchBlocklist() + } + + const clearExpired = async () => { + try { + await deleteAPI('/api/blocklist/expired') + showToast('Expired entries cleared') + fetchBlocklist() + } catch { + showToast('Failed to clear expired entries') + } + } + + const clearAll = async () => { + try { + await deleteAPI('/api/blocklist') + showToast('All entries cleared') + fetchBlocklist() + } catch { + showToast('Failed to clear blocklist') + } + setClearAllOpen(false) + } + + const deleteOne = async (id: number) => { + try { + await deleteAPI(`/api/blocklist/${id}`) + setItems(items.filter(i => i.id !== id)) + setTotal(t => t - 1) + showToast('Entry removed') + } catch { + showToast('Failed to remove entry') + } + } + + const formatTimeAgo = (dateStr: string): string => { + const diff = Date.now() - new Date(dateStr).getTime() + const minutes = Math.floor(diff / 60000) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` + } + + return ( +
+
+

Blocklist

+
+ + +
+
+ + {error && } + + {loading ? ( + + ) : items.length === 0 ? ( +
No blocked releases
+ ) : ( + <> + {selected.size > 0 && ( +
+ {selected.size} selected + +
+ )} + +
+
+ + + + + + + + + + + + + {items.map(item => ( + + + + + + + + + + ))} + +
+ + 0} + onChange={toggleSelectAll} + className="rounded bg-gray-800 border-gray-600" + aria-label="Select all" + /> + + ReleaseIndexerQualityReasonDate +
+ toggleSelect(item.id)} + className="rounded bg-gray-800 border-gray-600" + aria-label={`Select ${item.release_title}`} + /> + +

{item.release_title}

+
{item.indexer ?? '—'}{item.quality?.resolution ?? '—'}{item.block_reason}{formatTimeAgo(item.created_at)} + +
+
+
+ + + + )} + + setClearAllOpen(false)} + destructive + /> +
+ ) +} diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx new file mode 100644 index 0000000..78895d0 --- /dev/null +++ b/frontend/src/pages/Calendar.tsx @@ -0,0 +1,233 @@ +import { useEffect, useState, useCallback, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { fetchAPI } from '../api/client' +import ErrorBanner from '../components/ErrorBanner' + +interface CalendarEvent { + id: number + media_type: string + title: string + date: string + year: number | null + status: string + poster_url: string +} + +interface CalendarResponse { + data: CalendarEvent[] + month: string +} + +const typeColors: Record = { + movie: { bg: 'bg-indigo-600/20', border: 'border-l-indigo-500', text: 'text-indigo-300' }, + series: { bg: 'bg-emerald-600/20', border: 'border-l-emerald-500', text: 'text-emerald-300' }, + music: { bg: 'bg-amber-600/20', border: 'border-l-amber-500', text: 'text-amber-300' }, + audiobook: { bg: 'bg-rose-600/20', border: 'border-l-rose-500', text: 'text-rose-300' }, + podcast: { bg: 'bg-violet-600/20', border: 'border-l-violet-500', text: 'text-violet-300' }, +} + +const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] +const monthNames = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +] + +export default function Calendar() { + const navigate = useNavigate() + const now = useMemo(() => new Date(), []) + + const [year, setYear] = useState(now.getFullYear()) + const [month, setMonth] = useState(now.getMonth()) + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const monthParam = `${year}-${String(month + 1).padStart(2, '0')}` + + const fetchData = useCallback(() => { + setLoading(true) + setError(null) + fetchAPI(`/api/calendar?month=${monthParam}`) + .then(res => { + setEvents(res.data ?? []) + }) + .catch(err => setError(err.message || 'Failed to load calendar events')) + .finally(() => setLoading(false)) + }, [monthParam]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const goToPrevMonth = () => { + if (month === 0) { + setMonth(11) + setYear(y => y - 1) + } else { + setMonth(m => m - 1) + } + } + + const goToNextMonth = () => { + if (month === 11) { + setMonth(0) + setYear(y => y + 1) + } else { + setMonth(m => m + 1) + } + } + + const goToToday = () => { + setYear(now.getFullYear()) + setMonth(now.getMonth()) + } + + const handleEventClick = (event: CalendarEvent) => { + navigate(`/library/${event.media_type}/${event.id}`) + } + + // Calendar grid calculations + const firstDay = new Date(year, month, 1).getDay() + const daysInMonth = new Date(year, month + 1, 0).getDate() + const today = new Date() + const isCurrentMonth = today.getFullYear() === year && today.getMonth() === month + const todayDate = today.getDate() + + // Group events by day + const eventsByDay = useMemo(() => { + const map = new Map() + for (const event of events) { + const day = parseInt(event.date.split('-')[2] ?? '0', 10) + if (day > 0) { + const existing = map.get(day) ?? [] + existing.push(event) + map.set(day, existing) + } + } + return map + }, [events]) + + // Build the 42-cell grid (6 rows × 7 cols) + const cells: (number | null)[] = [] + for (let i = 0; i < firstDay; i++) { + cells.push(null) + } + for (let d = 1; d <= daysInMonth; d++) { + cells.push(d) + } + while (cells.length < 42) { + cells.push(null) + } + + return ( +
+
+
+

Calendar

+

Upcoming release dates for monitored media

+
+
+ +
+ + + {monthNames[month]} {year} + + +
+
+
+ + {error && } + + {/* Day-of-week headers */} +
+ {dayLabels.map(d => ( +
+ {d} +
+ ))} +
+ + {loading ? ( +
+ {Array.from({ length: 42 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : ( +
+ {cells.map((day, idx) => { + if (day === null) { + return
+ } + + const dayEvents = eventsByDay.get(day) ?? [] + const isToday = isCurrentMonth && day === todayDate + const maxVisible = 3 + + return ( +
+ + {day} + +
+ {dayEvents.slice(0, maxVisible).map(event => { + const colors = typeColors[event.media_type] ?? { bg: 'bg-gray-600/20', border: 'border-l-gray-500', text: 'text-gray-300' } + return ( + + ) + })} + {dayEvents.length > maxVisible && ( + + +{dayEvents.length - maxVisible} more + + )} +
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..a19fa0d --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState, useCallback } from 'react' +import { fetchAPI } from '../api/client' +import ErrorBanner from '../components/ErrorBanner' +import Loading from '../components/Loading' + +interface DashboardStats { + total_media: number + monitored: number + unavailable: number + available: number + quality_upgrades: number + queue_pending: number + queue_downloading: number + queue_failed: number + blocklist_count: number + blocklist_expired: number + indexers_enabled: number + recent_downloads: number + media_by_type: Record + storage_by_type: Record +} + +export default function Dashboard() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + + const fetchDashboard = useCallback(() => { + setLoading(true) + setError(null) + fetchAPI('/api/dashboard') + .then(setData) + .catch(err => setError(err.message || 'Something went wrong. Please try again.')) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { + fetchDashboard() + }, [fetchDashboard]) + + if (error) { + return ( +
+

Dashboard

+ +
+ ) + } + + const cards = [ + { label: 'Movies', value: data?.media_by_type?.movie ?? 0, color: 'bg-blue-600' }, + { label: 'TV Series', value: data?.media_by_type?.series ?? 0, color: 'bg-green-600' }, + { label: 'Music', value: data?.media_by_type?.music ?? 0, color: 'bg-purple-600' }, + { label: 'Books', value: data?.media_by_type?.book ?? 0, color: 'bg-teal-600' }, + { label: 'Active Downloads', value: data?.queue_downloading ?? 0, color: 'bg-orange-600' }, + { label: 'Missing', value: data?.unavailable ?? 0, color: 'bg-red-600' }, + { label: 'Upgrades Available', value: data?.quality_upgrades ?? 0, color: 'bg-cyan-600' }, + { label: 'Total Storage', value: data ? formatStorage(data.storage_by_type) : '—', color: 'bg-gray-600' }, + ] + + return ( +
+

Dashboard

+ {loading && !data ? ( + + ) : ( +
+ {cards.map(card => ( +
+

{card.label}

+ {loading ? ( +
+ ) : ( +

{card.value}

+ )} +
+ ))} +
+ )} +
+ ) +} + +function formatStorage(storageByType: Record): string { + const total = Object.values(storageByType).reduce((sum, v) => sum + v, 0) + const tb = total / 1e12 + if (tb >= 1) return `${tb.toFixed(1)} TB` + const gb = total / 1e9 + return `${gb.toFixed(1)} GB` +} diff --git a/frontend/src/pages/Discover.tsx b/frontend/src/pages/Discover.tsx new file mode 100644 index 0000000..04dd09b --- /dev/null +++ b/frontend/src/pages/Discover.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState, useCallback } from 'react' +import { fetchAPI, postAPI } from '../api/client' +import { useToast } from '../components/Toast' +import ErrorBanner from '../components/ErrorBanner' + +interface DiscoverItem { + tmdb_id: number + title: string + year: number | null + media_type: string + overview: string + poster_url: string + backdrop_url: string + vote_average: number + in_library: boolean +} + +interface DiscoverResponse { + data: DiscoverItem[] + page: number +} + +export default function Discover() { + const { showToast } = useToast() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [mediaType, setMediaType] = useState('movie') + const [tab, setTab] = useState('trending') + const [page, setPage] = useState(1) + const [adding, setAdding] = useState>(new Set()) + + const fetchData = useCallback(() => { + setLoading(true) + setError(null) + fetchAPI(`/api/discover/${tab}?type=${mediaType}&page=${page}`) + .then(res => { + setItems(res.data ?? []) + }) + .catch(err => setError(err.message || 'Failed to load discover content')) + .finally(() => setLoading(false)) + }, [tab, mediaType, page]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const handleMediaTypeChange = (value: string) => { + setMediaType(value) + setPage(1) + } + + const handleTabChange = (value: string) => { + setTab(value) + setPage(1) + } + + const handleAddToLibrary = async (item: DiscoverItem) => { + setAdding(prev => new Set(prev).add(item.tmdb_id)) + try { + const result = await postAPI<{ id: number; existing?: boolean }>( + '/api/discover/add', + { tmdb_id: item.tmdb_id, media_type: item.media_type } + ) + setItems(prev => + prev.map(i => i.tmdb_id === item.tmdb_id ? { ...i, in_library: true } : i) + ) + if (result.existing) { + showToast(`"${item.title}" is already in your library`) + } else { + showToast(`Added "${item.title}" to library`) + } + } catch { + showToast(`Failed to add "${item.title}" to library`) + } finally { + setAdding(prev => { + const next = new Set(prev) + next.delete(item.tmdb_id) + return next + }) + } + } + + return ( +
+

Discover

+

Browse trending and popular content from TMDB

+ +
+
+ + +
+ +
+ + {error && } + + {loading ? ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : items.length === 0 ? ( +
No results found
+ ) : ( + <> +
+ {items.map(item => ( +
+ {item.poster_url ? ( + {item.title} + ) : ( +
+ + {item.title.charAt(0).toUpperCase()} + +
+ )} + + {item.in_library && ( +
+ ✓ +
+ )} + +
+

{item.title}

+
+ {item.year && {item.year}} + ⭐ {item.vote_average.toFixed(1)} +
+ {item.in_library ? ( + + ) : ( + + )} +
+
+ ))} +
+ +
+ + Page {page} + +
+ + )} +
+ ) +} diff --git a/frontend/src/pages/Library.tsx b/frontend/src/pages/Library.tsx new file mode 100644 index 0000000..82b4efb --- /dev/null +++ b/frontend/src/pages/Library.tsx @@ -0,0 +1,225 @@ +import { useEffect, useState, useCallback, useRef } from 'react' +import { useSearchParams, useNavigate } from 'react-router-dom' +import { fetchAPI, putAPI } from '../api/client' +import { useToast } from '../components/Toast' +import StatusBadge from '../components/StatusBadge' +import Pagination from '../components/Pagination' +import ErrorBanner from '../components/ErrorBanner' +import Loading from '../components/Loading' + +interface MediaItem { + id: number + media_type: string + title: string + year: number | null + status: string + monitored: boolean + current_quality: { resolution?: string } | null +} + +interface PaginatedResponse { + data: MediaItem[] + total: number + page: number + page_size: number + total_pages: number +} + +const MEDIA_TYPES = ['all', 'movie', 'series', 'music', 'book', 'audiobook', 'podcast', 'photo', 'other'] +const STATUS_FILTERS = ['all', 'available', 'unavailable', 'downloading', 'failed'] +const PAGE_SIZE = 50 + +export default function Library() { + const [searchParams, setSearchParams] = useSearchParams() + const navigate = useNavigate() + const { showToast } = useToast() + + const [items, setItems] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const q = searchParams.get('q') ?? '' + const typeFilter = searchParams.get('type') ?? 'all' + const statusFilter = searchParams.get('status') ?? 'all' + const page = Number(searchParams.get('page') ?? '1') + + const debounceRef = useRef | null>(null) + + const fetchMedia = useCallback(() => { + setLoading(true) + setError(null) + const params = new URLSearchParams() + if (q) params.set('q', q) + if (typeFilter !== 'all') params.set('type', typeFilter) + if (statusFilter !== 'all') params.set('status', statusFilter) + params.set('page', String(page)) + params.set('page_size', String(PAGE_SIZE)) + + fetchAPI(`/api/media?${params}`) + .then(res => { + setItems(res.data ?? []) + setTotal(res.total) + }) + .catch(err => setError(err.message || 'Something went wrong. Please try again.')) + .finally(() => setLoading(false)) + }, [q, typeFilter, statusFilter, page]) + + useEffect(() => { + fetchMedia() + }, [fetchMedia]) + + const updateSearch = (key: string, value: string) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev) + if (value && value !== 'all') { + next.set(key, value) + } else { + next.delete(key) + } + if (key !== 'page') next.set('page', '1') + return next + }) + } + + const handleSearchInput = (value: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => updateSearch('q', value), 300) + } + + const toggleMonitored = async (item: MediaItem) => { + const prev = item.monitored + setItems(items.map(i => i.id === item.id ? { ...i, monitored: !prev } : i)) + try { + await putAPI(`/api/media/${item.media_type}/${item.id}`, { monitored: !prev }) + } catch { + setItems(items.map(i => i.id === item.id ? { ...i, monitored: prev } : i)) + showToast('Failed to update monitoring status') + } + } + + const totalPages = Math.ceil(total / PAGE_SIZE) + + return ( +
+

Library

+ +
+ + handleSearchInput(e.target.value)} + className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 placeholder-gray-500 text-sm" + /> + + +
+ + {error && } + + {loading && !items.length ? ( +
+ {[1, 2, 3].map(i => ( +
+
+
+
+ ))} +
+ ) : items.length === 0 ? ( +
+ {q || typeFilter !== 'all' || statusFilter !== 'all' + ? 'No media found matching your search' + : 'Your library is empty. Add media or request new content.'} +
+ ) : ( + <> +
+
+ + + + + + + + + + + + {items.map(item => ( + navigate(`/library/${item.media_type}/${item.id}`)} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/library/${item.media_type}/${item.id}`) } }} + > + + + + + + + ))} + +
TitleTypeStatusQualityMonitor
+ {item.title} + {item.year && {item.year}} + + {item.media_type} + + + + + {item.current_quality?.resolution ?? '—'} + + + +
+
+
+ + setSearchParams(prev => { const n = new URLSearchParams(prev); n.set('page', String(p)); return n })} + /> + + )} +
+ ) +} diff --git a/frontend/src/pages/MediaDetail.tsx b/frontend/src/pages/MediaDetail.tsx new file mode 100644 index 0000000..3b87af5 --- /dev/null +++ b/frontend/src/pages/MediaDetail.tsx @@ -0,0 +1,757 @@ +import { useEffect, useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { fetchAPI, postAPI, deleteAPI } from '../api/client' +import { useToast } from '../components/Toast' +import ConfirmModal from '../components/ConfirmModal' +import StatusBadge from '../components/StatusBadge' +import ErrorBanner from '../components/ErrorBanner' +import ReleaseSearchResults from '../components/ReleaseSearchResults' + +interface Media { + id: number + media_type: string + title: string + sort_title: string + original_title: string | null + overview: string | null + year: number | null + status: string + monitored: boolean + external_ids: Record + metadata: Record + images: Array<{ url: string; type: string; width: number; height: number }> + quality_profile_id: number | null + current_quality: { resolution?: string; source?: string } | null + desired_quality: { resolution?: string; source?: string } | null + quality_upgrade_needed: boolean + added_at: string + updated_at: string +} + +interface MediaFile { + id: number + media_id: number + path: string + original_path: string | null + file_name: string + file_size: number + quality: { resolution?: string; source?: string; codec?: string } | null + codec: string | null + resolution: string | null + source: string | null + transcode_status: string + created_at: string +} + +interface MediaRelation { + id: number + parent_id: number + child_id: number + relation: string + position: number | null + season: number | null +} + +interface MediaDetail { + media: Media + files: MediaFile[] + relations: MediaRelation[] +} + +interface QualityProfileInfo { + id: number + name: string + cutoff_quality: Record + allowed_qualities: Record[] +} + +interface SubtitleInfo { + file_name: string + language: string + language_code: string + hi: boolean + forced: boolean + source: string +} + +interface FileWithSubtitles extends MediaFile { + subtitles: SubtitleInfo[] +} + +interface EpisodeInfo { + media_id: number + title: string + season: number + episode: number + status: string + monitored: boolean + air_date: string | null + has_file: boolean + quality: { resolution?: string } | null +} + +interface MediaHistoryItem { + id: number + event_type: string + title: string + description: string | null + data: Record + created_at: string +} + +interface FullMediaDetail { + media: MediaDetail + quality_profile: QualityProfileInfo | null + files_with_subtitles: FileWithSubtitles[] + episodes: EpisodeInfo[] + history: MediaHistoryItem[] +} + +interface ReleaseSearchResult { + title: string + guid: string + size: number + pub_date: string + indexer_name: string + indexer_priority: number + quality: { + title: string + resolution: string + source: string + video_codec: string + release_group: string + parse_warning: boolean + } + quality_tier: { name: string; rank: number; resolution: string } | null + seeders: number + peers: number + category: string + download_url: string + source_indexers: string[] +} + +interface SubtitleSearchResult { + id: string + file_name: string + language: string + language_code: string + hi: boolean + forced: boolean + download_count: number + release_name: string + provider: string +} + +function eventTypeBadge(type: string): { label: string; className: string } { + switch (type) { + case 'grab': + return { label: 'Grab', className: 'bg-blue-600' } + case 'import': + return { label: 'Import', className: 'bg-green-600' } + case 'download_complete': + return { label: 'Download', className: 'bg-green-600' } + case 'download_failed': + return { label: 'Failed', className: 'bg-red-600' } + case 'quality_upgrade': + return { label: 'Upgrade', className: 'bg-purple-600' } + case 'safety_block': + return { label: 'Blocked', className: 'bg-orange-600' } + case 'error': + return { label: 'Error', className: 'bg-red-600' } + default: + return { label: 'Info', className: 'bg-gray-600' } + } +} + +function formatTimeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime() + const minutes = Math.floor(diff / 60000) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +function formatFileSize(bytes: number): string { + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB` + } + if (bytes >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(1)} MB` + } + return `${(bytes / 1024).toFixed(0)} KB` +} + +type TabKey = 'overview' | 'search' | 'files' | 'episodes' | 'history' + +export default function MediaDetail() { + const { type, id } = useParams<{ type: string; id: string }>() + const navigate = useNavigate() + + const [detail, setDetail] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [activeTab, setActiveTab] = useState('overview') + const [searchResults, setSearchResults] = useState([]) + const [searchLoading, setSearchLoading] = useState(false) + const [searchError, setSearchError] = useState(null) + const { showToast } = useToast() + const [subtitleSearchFile, setSubtitleSearchFile] = useState(null) + const [subtitleResults, setSubtitleResults] = useState([]) + const [subtitleSearchLoading, setSubtitleSearchLoading] = useState(false) + const [subtitleDownloading, setSubtitleDownloading] = useState>(new Set()) + const [extracting, setExtracting] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const [showDeleteModal, setShowDeleteModal] = useState(false) + + useEffect(() => { + if (!type || !id) return + setLoading(true) + setError(null) + fetchAPI(`/api/media/${type}/${id}/detail`) + .then(res => setDetail(res)) + .catch(err => setError(err.message || 'Failed to load media detail')) + .finally(() => setLoading(false)) + }, [type, id]) + + useEffect(() => { + if (!type || !id || activeTab !== 'search') return + const media = detail?.media.media + if (!media) return + setSearchLoading(true) + setSearchError(null) + const query = encodeURIComponent(media.title) + fetchAPI<{ data: ReleaseSearchResult[]; total: number }>(`/api/releases/search?query=${query}&media_type=${media.media_type}`) + .then(res => setSearchResults(res.data ?? [])) + .catch(err => setSearchError(err.message || 'Search failed')) + .finally(() => setSearchLoading(false)) + }, [activeTab, type, id, detail?.media.media]) + + const searchSubtitles = async (file: FileWithSubtitles) => { + if (!type || !id) return + setSubtitleSearchFile(file) + setSubtitleSearchLoading(true) + setSubtitleResults([]) + try { + const res = await fetchAPI<{ data: SubtitleSearchResult[] }>( + `/api/media/${type}/${id}/subtitles/search?languages=en` + ) + setSubtitleResults(res.data ?? []) + } catch { + showToast('Failed to search subtitles') + } finally { + setSubtitleSearchLoading(false) + } + } + + const downloadSub = async (result: SubtitleSearchResult) => { + if (!type || !id) return + setSubtitleDownloading(prev => new Set(prev).add(result.id)) + try { + await postAPI(`/api/media/${type}/${id}/subtitles/download`, { + subtitle_id: result.id, + language_code: result.language_code, + hi: result.hi, + forced: result.forced, + }) + showToast(`✓ Downloaded ${result.language} subtitle`) + // Refresh media detail to show new subtitle + const refreshed = await fetchAPI(`/api/media/${type}/${id}/detail`) + setDetail(refreshed) + setSubtitleResults([]) + setSubtitleSearchFile(null) + } catch { + showToast('Failed to download subtitle') + } finally { + setSubtitleDownloading(prev => { + const next = new Set(prev) + next.delete(result.id) + return next + }) + } + } + + const extractSubs = async () => { + if (!type || !id) return + setExtracting(true) + try { + await postAPI(`/api/media/${type}/${id}/subtitles/extract`, {}) + showToast('✓ Subtitles extracted') + const refreshed = await fetchAPI(`/api/media/${type}/${id}/detail`) + setDetail(refreshed) + } catch { + showToast('Failed to extract subtitles') + } finally { + setExtracting(false) + } + } + + const handleRefreshMetadata = async () => { + if (!type || !id) return + setRefreshing(true) + try { + await postAPI(`/api/media/${type}/${id}/refresh-metadata`, {}) + showToast('✓ Metadata refreshed') + const refreshed = await fetchAPI(`/api/media/${type}/${id}/detail`) + setDetail(refreshed) + } catch { + showToast('Failed to refresh metadata') + } finally { + setRefreshing(false) + } + } + + const handleDeleteMedia = async () => { + if (!type || !id) return + setShowDeleteModal(false) + try { + await deleteAPI(`/api/media/${type}/${id}`) + showToast('✓ Media deleted') + navigate('/library') + } catch { + showToast('Failed to delete media') + } + } + + if (loading) { + return ( +
+
+
+
+ ) + } + + if (error) { + return window.location.reload()} /> + } + + if (!detail) return null + + const media = detail.media.media + const posterImage = detail.media.media.images?.find(i => i.type === 'poster') + const isSeries = media.media_type === 'series' + + const metadata = media.metadata as Record | null + const tmdbRating = (metadata?.tmdb_rating as number) ?? null + const genres = (metadata?.genres as string[]) ?? [] + + const totalSize = detail.files_with_subtitles.reduce((sum, f) => sum + f.file_size, 0) + + const tabs: { key: TabKey; label: string }[] = [ + { key: 'overview', label: 'Overview' }, + { key: 'search', label: 'Search' }, + { key: 'files', label: 'Files' }, + ...(isSeries ? [{ key: 'episodes' as TabKey, label: 'Episodes' }] : []), + { key: 'history', label: 'History' }, + ] + + // Group episodes by season + const seasons: Record = {} + if (isSeries && detail.episodes) { + for (const ep of detail.episodes) { + if (!seasons[ep.season]) seasons[ep.season] = [] + seasons[ep.season].push(ep) + } + } + + return ( +
+ {/* Header */} +
+ +
+

{media.title}

+ + {media.media_type} + +
+
+ + + + {detail.files_with_subtitles.length} file{detail.files_with_subtitles.length !== 1 ? 's' : ''} · {formatFileSize(totalSize)} + +
+
+ + {/* Tab navigation */} +
+ {tabs.map(tab => ( + + ))} +
+ + {/* Overview Tab */} + {activeTab === 'overview' && ( +
+ {/* Poster */} +
+ {posterImage ? ( + {media.title} + ) : ( +
+ + {media.title.charAt(0).toUpperCase()} + +
+ )} +
+ + {/* Info */} +
+
+

{media.title}

+ {media.year && ( + {media.year} + )} +
+ + {media.original_title && media.original_title !== media.title && ( +

{media.original_title}

+ )} + +
+ + + {media.monitored ? 'Monitored' : 'Not Monitored'} + +
+ + {tmdbRating != null && ( +

+ ⭐ {tmdbRating.toFixed(1)} / 10 +

+ )} + + {genres.length > 0 && ( +
+ {genres.map(g => ( + {g} + ))} +
+ )} + +
+ Quality Profile:{' '} + {detail.quality_profile?.name ?? None configured} +
+ + {(media.current_quality || media.desired_quality) && ( +
+ {media.current_quality && ( + + Current: {media.current_quality.resolution ?? 'Unknown'} + + )} + {media.desired_quality && ( + + Desired: {media.desired_quality.resolution ?? 'Unknown'} + + )} +
+ )} + + {media.overview && ( +
+

{media.overview}

+
+ )} +
+
+ )} + + {/* Search Tab */} + {activeTab === 'search' && ( +
+
+

+ Searching indexers for "{media.title}" +

+ {searchResults.length > 0 && ( +

{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}

+ )} +
+ {searchError ? ( + setActiveTab('search')} /> + ) : ( + + )} +
+ )} + + {/* Files Tab */} + {activeTab === 'files' && ( + detail.files_with_subtitles.length === 0 ? ( +
No imported files yet
+ ) : ( +
+
+ +
+
+ + + + + + + + + + + + + {detail.files_with_subtitles.map(file => ( + + + + + + + + + ))} + +
File NameQualitySizeSourceCodecSubtitles
{file.file_name} + {file.quality?.resolution ?? file.resolution ?? 'Unknown'} + {formatFileSize(file.file_size)}{file.source ?? '—'}{file.codec ?? '—'} +
+ {file.subtitles && file.subtitles.length > 0 ? ( +
+ {file.subtitles.map((sub, idx) => ( + + {sub.language_code.toUpperCase()} + {sub.hi && SDH} + {sub.forced && F} + {sub.source === 'extracted' ? 'EX' : 'DL'} + + ))} +
+ ) : ( + None + )} + +
+
+
+ {subtitleSearchFile && ( +
+
+

+ Subtitles for “{subtitleSearchFile.file_name}” +

+ +
+ {subtitleSearchLoading ? ( +
+
+
+
+ ) : subtitleResults.length === 0 ? ( +
No subtitles found
+ ) : ( + + + + + + + + + + + + {subtitleResults.map(result => { + const isDownloading = subtitleDownloading.has(result.id) + return ( + + + + + + + + ) + })} + +
LanguageReleaseDownloadsSourceAction
+ {result.language} + {result.hi && SDH} + {result.forced && Forced} + + {result.release_name || '—'} + {result.download_count}{result.provider} + +
+ )} +
+ )} +
+ ) + )} + + {/* Episodes Tab */} + {activeTab === 'episodes' && isSeries && ( + Object.keys(seasons).length === 0 ? ( +
No episode information available
+ ) : ( +
+ {Object.keys(seasons) + .map(Number) + .sort((a, b) => a - b) + .map(seasonNum => ( +
+

Season {seasonNum}

+
+ + + + + + + + + + + + + {seasons[seasonNum].map(ep => ( + + + + + + + + + ))} + +
#TitleStatusMonitorFileQuality
{ep.episode}{ep.title} + + + {ep.monitored ? ( + + ) : ( + + )} + + {ep.has_file ? ( + + ) : ( + + )} + + {ep.quality?.resolution ?? '—'} +
+
+
+ ))} +
+ ) + )} + + {/* History Tab */} + {activeTab === 'history' && ( + detail.history.length === 0 ? ( +
No history recorded for this item
+ ) : ( +
+ {detail.history.map(event => { + const badge = eventTypeBadge(event.event_type) + return ( +
+
+ + {badge.label} + +
+
+

{event.title}

+ {event.description && ( +

{event.description}

+ )} +
+
+ {formatTimeAgo(event.created_at)} +
+
+ ) + })} +
+ ) + )} + + setShowDeleteModal(false)} + /> +
+ ) +} diff --git a/frontend/src/pages/Queue.tsx b/frontend/src/pages/Queue.tsx new file mode 100644 index 0000000..76043c4 --- /dev/null +++ b/frontend/src/pages/Queue.tsx @@ -0,0 +1,333 @@ +import { useEffect, useState, useCallback, useRef } from 'react' +import { fetchAPI, deleteAPI, postAPI } from '../api/client' +import { useToast } from '../components/Toast' +import StatusBadge from '../components/StatusBadge' +import ErrorBanner from '../components/ErrorBanner' +import Loading from '../components/Loading' +import ConfirmModal from '../components/ConfirmModal' +import Pagination from '../components/Pagination' + +interface QueueItem { + id: number + media_id: number + release_title: string + indexer: string + download_client: string + quality: { resolution?: string } | null + size: number | null + status: string + progress: number + error_message: string | null + protocol: string + created_at: string + updated_at: string +} + +interface ImportReport { + imported: number + skipped: number + errors: number + results: ImportResult[] +} + +interface ImportResult { + media_id: number + media_type: string + source_path: string + dest_path: string + file_size: number + quality: string + status: string +} + +interface ImportHistoryItem { + id: number + media_id: number + media_type: string + action: string + release_title: string + quality: string + created_at: string +} + +const TABS = ['all', 'downloading', 'pending', 'failed', 'imported', 'history'] as const + +export default function Queue() { + const { showToast } = useToast() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [activeTab, setActiveTab] = useState('all') + const [cancelTarget, setCancelTarget] = useState(null) + const [clearAllConfirm, setClearAllConfirm] = useState(false) + const [importing, setImporting] = useState(false) + const [importHistory, setImportHistory] = useState([]) + const [historyPage, setHistoryPage] = useState(1) + const [historyTotal, setHistoryTotal] = useState(0) + const historyPageSize = 50 + const intervalRef = useRef | null>(null) + + const fetchQueue = useCallback(() => { + setError(null) + const params = new URLSearchParams() + if (activeTab !== 'all') params.set('status', activeTab) + + fetchAPI<{ data: QueueItem[] }>(`/api/queue?${params}`) + .then(res => setItems(res.data ?? [])) + .catch(err => { + setError(err.message || 'Something went wrong. Please try again.') + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + }) + .finally(() => setLoading(false)) + }, [activeTab]) + + useEffect(() => { + setLoading(true) + fetchQueue() + + intervalRef.current = setInterval(fetchQueue, 5000) + return () => { + if (intervalRef.current) clearInterval(intervalRef.current) + } + }, [fetchQueue]) + + const fetchImportHistory = useCallback(() => { + fetchAPI<{ data: ImportHistoryItem[]; total: number; page: number; page_size: number; total_pages: number }>(`/api/imports/history?page=${historyPage}&page_size=${historyPageSize}`) + .then(res => { + setImportHistory(res.data ?? []) + setHistoryTotal(res.total) + }) + .catch(() => { + setImportHistory([]) + setHistoryTotal(0) + }) + }, [historyPage]) + + useEffect(() => { + if (activeTab === 'history') { + fetchImportHistory() + } + }, [activeTab, fetchImportHistory]) + + const cancelItem = async (item: QueueItem) => { + try { + await deleteAPI(`/api/queue/${item.id}`) + setItems(items.filter(i => i.id !== item.id)) + showToast('Download cancelled') + } catch { + showToast('Failed to cancel download') + } + setCancelTarget(null) + } + + const retryItem = async (item: QueueItem) => { + try { + await postAPI(`/api/queue/${item.id}/retry`, {}) + showToast('Retrying download') + fetchQueue() + } catch { + showToast('Failed to retry download') + } + } + + const retryAllFailed = async () => { + try { + await postAPI('/api/queue/retry-failed', {}) + showToast('Retrying all failed downloads') + fetchQueue() + } catch { + showToast('Failed to retry downloads') + } + } + + const clearCompleted = async () => { + try { + await postAPI('/api/queue/clear', {}) + showToast('Completed downloads cleared') + fetchQueue() + } catch { + showToast('Failed to clear downloads') + } + } + + const triggerImport = async () => { + setImporting(true) + try { + const report = await postAPI('/api/imports/trigger', {}) + showToast(`Import complete: ${report.imported} imported, ${report.skipped} skipped, ${report.errors} errors`) + fetchQueue() + if (activeTab === 'history') fetchImportHistory() + } catch { + showToast('Failed to trigger import') + } finally { + setImporting(false) + } + } + + const formatSize = (bytes: number | null): string => { + if (!bytes) return '—' + const gb = bytes / 1e9 + return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1e6).toFixed(1)} MB` + } + + const failedCount = items.filter(i => i.status === 'failed').length + const completedCount = items.filter(i => i.status === 'imported').length + + return ( +
+
+

Download Queue

+
+ + {completedCount > 0 && ( + + )} + {failedCount > 0 && ( + + )} +
+
+ +
+ {TABS.map(tab => ( + + ))} +
+ + {error && } + + {loading ? ( + + ) : items.length === 0 ? ( +
No active downloads
+ ) : ( +
+ {items.map(item => ( +
+
+
+

{item.release_title}

+
+ + {item.download_client} + {item.quality?.resolution && ( + {item.quality.resolution} + )} + {formatSize(item.size)} +
+
+
+ {item.status === 'failed' && ( + + )} + {(item.status === 'downloading' || item.status === 'pending' || item.status === 'failed') && ( + + )} +
+
+ {(item.status === 'downloading' || item.status === 'pending') && ( +
+
+
+ )} + {item.status === 'downloading' && ( +

{(item.progress * 100).toFixed(0)}%

+ )} + {item.error_message && ( +

{item.error_message}

+ )} +
+ ))} +
+ )} + + {activeTab === 'history' && ( +
+ {importHistory.length === 0 ? ( +
No import history yet
+ ) : ( + <> +
+ + + + + + + + + + + {importHistory.map(item => ( + + + + + + + ))} + +
Release TitleMedia TypeQualityImported At
{item.release_title || '—'}{item.media_type}{item.quality || '—'}{new Date(item.created_at).toLocaleString()}
+
+ + + )} +
+ )} + + cancelTarget && cancelItem(cancelTarget)} + onCancel={() => setCancelTarget(null)} + destructive + confirmLabel="Cancel Download" + /> + + { clearCompleted(); setClearAllConfirm(false) }} + onCancel={() => setClearAllConfirm(false)} + destructive + /> +
+ ) +} diff --git a/frontend/src/pages/Requests.tsx b/frontend/src/pages/Requests.tsx new file mode 100644 index 0000000..e8d62ee --- /dev/null +++ b/frontend/src/pages/Requests.tsx @@ -0,0 +1,454 @@ +import { useEffect, useState, useCallback } from 'react' +import { fetchAPI, postAPI, putAPI, deleteAPI } from '../api/client' +import { useToast } from '../components/Toast' +import Pagination from '../components/Pagination' +import ErrorBanner from '../components/ErrorBanner' +import Loading from '../components/Loading' +import ConfirmModal from '../components/ConfirmModal' +import StatusBadge from '../components/StatusBadge' + +interface RequestItem { + id: number + media_id: number + title: string + media_type: string + year: number | null + quality_profile_id: number | null + quality_profile_name: string | null + root_folder_id: number | null + root_folder_path: string | null + status: string + requested_by: string + notes: string | null + created_at: string + updated_at: string +} + +interface PaginatedResponse { + data: RequestItem[] + total: number + page: number + page_size: number + total_pages: number +} + +interface QualityProfile { + id: number + name: string +} + +interface RootFolder { + id: number + path: string + media_type: string +} + +const PAGE_SIZE = 20 + +const FILTER_TABS = [ + { value: '', label: 'All' }, + { value: 'pending', label: 'Pending' }, + { value: 'approved', label: 'Approved' }, + { value: 'fulfilled', label: 'Fulfilled' }, + { value: 'rejected', label: 'Rejected' }, +] + +const MEDIA_TYPES = ['movie', 'series', 'music', 'book', 'audiobook', 'podcast', 'other'] + +function formatTimeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime() + const minutes = Math.floor(diff / 60000) + if (minutes < 1) return 'just now' + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d ago` + const months = Math.floor(days / 30) + return `${months}mo ago` +} + +export default function Requests() { + const { showToast } = useToast() + const [requests, setRequests] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [statusFilter, setStatusFilter] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // New request modal + const [showNewModal, setShowNewModal] = useState(false) + const [newTitle, setNewTitle] = useState('') + const [newType, setNewType] = useState('movie') + const [newYear, setNewYear] = useState('') + const [newQualityId, setNewQualityId] = useState('') + const [newRootFolderId, setNewRootFolderId] = useState('') + const [submitting, setSubmitting] = useState(false) + const [formError, setFormError] = useState(null) + + // Reject confirmation + const [rejectTarget, setRejectTarget] = useState(null) + + // Dropdown data + const [qualityProfiles, setQualityProfiles] = useState([]) + const [rootFolders, setRootFolders] = useState([]) + + const fetchRequests = useCallback(() => { + setLoading(true) + setError(null) + const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) }) + if (statusFilter) params.set('status', statusFilter) + fetchAPI(`/api/requests?${params}`) + .then(res => { + setRequests(res.data ?? []) + setTotal(res.total) + }) + .catch(err => setError(err.message || 'Something went wrong. Please try again.')) + .finally(() => setLoading(false)) + }, [page, statusFilter]) + + useEffect(() => { + fetchRequests() + }, [fetchRequests]) + + // Fetch dropdown data on mount + useEffect(() => { + fetchAPI<{ data: QualityProfile[] }>('/api/quality-profiles?page=1&page_size=100') + .then(res => setQualityProfiles(res.data ?? [])) + .catch(() => {}) + + fetchAPI<{ data: RootFolder[] }>('/api/root-folders?page=1&page_size=100') + .then(res => setRootFolders(res.data ?? [])) + .catch(() => {}) + }, []) + + const handleFilterChange = (value: string) => { + setStatusFilter(value) + setPage(1) + } + + const openNewModal = () => { + setNewTitle('') + setNewType('movie') + setNewYear('') + setNewQualityId('') + setNewRootFolderId('') + setFormError(null) + setShowNewModal(true) + } + + const submitNewRequest = async () => { + if (!newTitle.trim()) { + setFormError('Title is required') + return + } + setSubmitting(true) + setFormError(null) + try { + const body: Record = { + title: newTitle.trim(), + media_type: newType, + } + if (newYear) body.year = parseInt(newYear, 10) + if (newQualityId !== '') body.quality_profile_id = newQualityId + if (newRootFolderId !== '') body.root_folder_id = newRootFolderId + + await postAPI('/api/requests', body) + showToast('Request submitted') + setShowNewModal(false) + fetchRequests() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to submit request' + setFormError(msg) + } finally { + setSubmitting(false) + } + } + + const approveRequest = async (req: RequestItem) => { + try { + await putAPI(`/api/requests/${req.id}/approve`, {}) + showToast('Request approved — searching for release') + fetchRequests() + } catch { + showToast('Failed to approve request') + } + } + + const rejectRequest = async () => { + if (!rejectTarget) return + try { + await putAPI(`/api/requests/${rejectTarget.id}/reject`, {}) + showToast('Request rejected') + fetchRequests() + } catch { + showToast('Failed to reject request') + } + setRejectTarget(null) + } + + const withdrawRequest = async (req: RequestItem) => { + try { + await deleteAPI(`/api/requests/${req.id}`) + showToast('Request withdrawn') + fetchRequests() + } catch { + showToast('Failed to withdraw request') + } + } + + // Filter root folders by selected media type + const filteredFolders = rootFolders.filter(f => f.media_type === newType) + + return ( +
+ {/* Header */} +
+

Requests

+ +
+ + {/* Filter tabs */} +
+ {FILTER_TABS.map(tab => ( + + ))} +
+ + {/* Error */} + {error && } + + {/* Loading */} + {loading ? ( + + ) : requests.length === 0 ? ( + /* Empty */ +
+ {statusFilter + ? `No ${statusFilter} requests` + : 'No requests yet. Click + New Request to get started.'} +
+ ) : ( + <> + {/* Request cards */} +
+ {requests.map(req => ( +
+ {/* Header row */} +
+

+ {req.title} + {req.year ? ( + ({req.year}) + ) : null} +

+ +
+ + {/* Meta row */} +
+ Requested by: {req.requested_by} + + {formatTimeAgo(req.created_at)} + {req.quality_profile_name && ( + <> + + Quality: {req.quality_profile_name} + + )} + {req.root_folder_path && ( + <> + + Root: {req.root_folder_path} + + )} +
+ + {/* Actions row */} + {req.status === 'pending' && ( +
+ + +
+ )} + + {/* Withdraw link for own pending/approved requests */} + {(req.status === 'pending' || req.status === 'approved') && ( + + )} +
+ ))} +
+ + {/* Pagination */} + + + )} + + {/* New Request Modal */} + {showNewModal && ( +
setShowNewModal(false)}> +
e.stopPropagation()}> +
+

New Request

+ +
+ + {formError && ( +
+

{formError}

+
+ )} + +
+ {/* Title */} +
+ + setNewTitle(e.target.value)} + placeholder="Movie or show title" + className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none" + autoFocus + /> +
+ + {/* Type */} +
+ + +
+ + {/* Year */} +
+ + setNewYear(e.target.value)} + placeholder="2024" + min="1900" + max="2099" + className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none" + /> +
+ + {/* Quality Profile */} +
+ + +
+ + {/* Root Folder */} +
+ + +
+
+ + {/* Modal actions */} +
+ + +
+
+
+ )} + + {/* Reject Confirmation Modal */} + setRejectTarget(null)} + destructive + confirmLabel="Reject" + /> +
+ ) +} diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx new file mode 100644 index 0000000..e9210d3 --- /dev/null +++ b/frontend/src/pages/Search.tsx @@ -0,0 +1,427 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { fetchAPI, postAPI } from '../api/client' +import { useToast } from '../components/Toast' +import ErrorBanner from '../components/ErrorBanner' + +interface MediaItem { + id: number + media_type: string + title: string + year: number | null + status: string + monitored: boolean +} + +interface SearchResult { + title: string + guid: string + size: number + pub_date: string + indexer_name: string + indexer_priority: number + quality: { + title: string + resolution: string + source: string + video_codec: string + release_group: string + parse_warning: boolean + } + quality_tier: { + name: string + rank: number + resolution: string + } | null + seeders: number + peers: number + category: string + download_url: string + source_indexers: string[] +} + +type SortCol = 'quality' | 'size' | 'seeders' | 'age' +type SortDir = 'asc' | 'desc' + +function formatFileSize(bytes: number): string { + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB` + } + if (bytes >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(1)} MB` + } + return `${(bytes / 1024).toFixed(0)} KB` +} + +function formatAge(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime() + const minutes = Math.floor(diff / 60000) + if (minutes < 1) return 'now' + if (minutes < 60) return `${minutes}m` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d` + const months = Math.floor(days / 30) + return `${months}mo` +} + +function extractSearchHint(title: string): string { + // Take the first few meaningful words from a release title as a media search hint + const cleaned = title.replace(/[.\-]+/g, ' ').trim() + const words = cleaned.split(/\s+/) + // Take up to 3 words, but stop at common quality/year markers + const stopWords = new Set(['720p', '1080p', '2160p', '4k', 'bluray', 'webrip', 'webdl', 'hdtv', 'dvdrip', 'x264', 'x265', 'hevc', 'aac', 'dts']) + const hint: string[] = [] + for (const word of words.slice(0, 6)) { + if (stopWords.has(word.toLowerCase())) break + if (/^\d{4}$/.test(word)) break + hint.push(word) + if (hint.length >= 3) break + } + return hint.join(' ') || title.slice(0, 30) +} + +export default function Search() { + // Search state + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [hasSearched, setHasSearched] = useState(false) + + // Sorting + const [sortCol, setSortCol] = useState('quality') + const [sortDir, setSortDir] = useState('asc') + + // Media selector modal state + const [modalOpen, setModalOpen] = useState(false) + const [pendingResult, setPendingResult] = useState(null) + const [mediaQuery, setMediaQuery] = useState('') + const [mediaItems, setMediaItems] = useState([]) + const [mediaLoading, setMediaLoading] = useState(false) + + // Grab tracking + const [grabbing, setGrabbing] = useState>(new Set()) + + const { showToast } = useToast() + const mediaSearchRef = useRef | null>(null) + const modalInputRef = useRef(null) + + function handleSort(col: SortCol) { + if (sortCol === col) { + setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc')) + } else { + setSortCol(col) + setSortDir('asc') + } + } + + const sorted = [...results].sort((a, b) => { + let cmp = 0 + switch (sortCol) { + case 'quality': + cmp = (a.quality_tier?.rank ?? 999) - (b.quality_tier?.rank ?? 999) + break + case 'size': + cmp = a.size - b.size + break + case 'seeders': + cmp = a.seeders - b.seeders + break + case 'age': + cmp = new Date(a.pub_date).getTime() - new Date(b.pub_date).getTime() + break + } + return sortDir === 'asc' ? cmp : -cmp + }) + + async function doSearch() { + const trimmed = query.trim() + if (!trimmed) return + setLoading(true) + setHasSearched(true) + setError(null) + try { + const res = await fetchAPI<{ data: SearchResult[]; total: number }>( + '/api/releases/search?query=' + encodeURIComponent(trimmed) + ) + setResults(res.data ?? []) + } catch (err) { + const message = err instanceof Error ? err.message : 'Search failed' + setError(message) + } finally { + setLoading(false) + } + } + + function handleSearchSubmit(e: React.FormEvent) { + e.preventDefault() + doSearch() + } + + // Media selector: open modal + function openMediaSelector(result: SearchResult) { + setPendingResult(result) + const hint = extractSearchHint(result.title) + setMediaQuery(hint) + setMediaItems([]) + setModalOpen(true) + } + + // Fetch media items when modal query changes (debounced) + const fetchMedia = useCallback(async (q: string) => { + if (!q.trim()) { + setMediaItems([]) + return + } + setMediaLoading(true) + try { + const res = await fetchAPI<{ data: MediaItem[]; total: number }>( + '/api/search?q=' + encodeURIComponent(q) + '&page_size=20' + ) + setMediaItems(res.data ?? []) + } catch { + setMediaItems([]) + } finally { + setMediaLoading(false) + } + }, []) + + // Debounce media search input + useEffect(() => { + if (!modalOpen) return + if (mediaSearchRef.current) clearTimeout(mediaSearchRef.current) + mediaSearchRef.current = setTimeout(() => { + fetchMedia(mediaQuery) + }, 300) + return () => { + if (mediaSearchRef.current) clearTimeout(mediaSearchRef.current) + } + }, [mediaQuery, modalOpen, fetchMedia]) + + // Focus modal input when modal opens + useEffect(() => { + if (modalOpen) { + // Small delay to let modal render + setTimeout(() => modalInputRef.current?.focus(), 50) + } + }, [modalOpen]) + + async function handleGrabWithMedia(item: MediaItem) { + if (!pendingResult) return + setGrabbing(prev => new Set(prev).add(pendingResult.guid)) + try { + await postAPI<{ queue_id: number }>('/api/releases/grab', { + download_url: pendingResult.download_url, + title: pendingResult.title, + media_type: item.media_type, + quality: pendingResult.quality, + indexer_name: pendingResult.indexer_name, + media_id: item.id, + }) + showToast(`✓ Grabbed "${pendingResult.title}"`) + setModalOpen(false) + setPendingResult(null) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + showToast(`✗ Failed to grab: ${message}`) + } finally { + if (pendingResult) { + setGrabbing(prev => { + const next = new Set(prev) + next.delete(pendingResult.guid) + return next + }) + } + } + } + + function closeModal() { + setModalOpen(false) + setPendingResult(null) + setMediaItems([]) + setMediaQuery('') + } + + function SortHeader({ col, label }: { col: SortCol; label: string }) { + const active = sortCol === col + return ( + + ) + } + + return ( +
+

Search Indexers

+ + {/* Search input */} +
+
+ setQuery(e.target.value)} + placeholder="Search all indexers..." + className="bg-gray-800 border border-gray-700 focus:border-indigo-500 text-white rounded-lg px-4 py-3 w-full outline-none transition-colors" + /> + +
+

Search across all enabled indexers for any release

+
+ + {/* Results area */} + {!hasSearched && ( +
+

Enter a search query to find releases

+
+ )} + + {hasSearched && loading && ( +
+
+
+
+
+ )} + + {hasSearched && error && !loading && ( + + )} + + {hasSearched && !loading && !error && results.length === 0 && ( +
No releases found
+ )} + + {hasSearched && !loading && !error && results.length > 0 && ( +
+

{results.length} release{results.length !== 1 ? 's' : ''} found

+
+ + + + + + + + + + + + + + {sorted.map(result => { + const isGrabbing = grabbing.has(result.guid) + const noUrl = !result.download_url + return ( + + + + + + + + + + ) + })} + +
TitleIndexerAction
+ {result.quality_tier ? ( + {result.quality_tier.name} + ) : result.quality.resolution || result.quality.source ? ( + {result.quality.resolution} {result.quality.source} + ) : ( + Unknown + )} + + {result.title} + {formatFileSize(result.size)}{result.indexer_name} + 0 ? 'text-green-400' : ''}>{result.seeders} + {' / '} + {result.peers} + {formatAge(result.pub_date)} + +
+
+
+ )} + + {/* Media selector modal */} + {modalOpen && pendingResult && ( +
+
e.stopPropagation()}> +

Select Media Item

+

+ Associate "{pendingResult.title}" with a media item in your library +

+ + {/* Media search input */} + setMediaQuery(e.target.value)} + placeholder="Search your library..." + className="bg-gray-800 border border-gray-700 focus:border-indigo-500 text-white rounded-lg px-4 py-2.5 w-full mb-3 text-sm outline-none transition-colors" + /> + + {/* Media items list */} +
+ {mediaLoading && mediaItems.length === 0 && ( +
Searching library...
+ )} + {!mediaLoading && mediaItems.length === 0 && mediaQuery.trim() && ( +
No matching media items found
+ )} + {mediaItems.map(item => { + const isGrabbingItem = grabbing.has(pendingResult.guid) + return ( + + ) + })} +
+ + {/* Modal actions */} +
+ +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..46f3a3f --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,1317 @@ +import { useEffect, useState, useCallback } from 'react' +import { fetchAPI, postAPI, putAPI, deleteAPI } from '../api/client' +import { useToast } from '../components/Toast' +import StatusBadge from '../components/StatusBadge' +import ConfirmModal from '../components/ConfirmModal' +import Loading from '../components/Loading' + +/* -------------------------------------------------------------------------- */ +/* Shared types */ +/* -------------------------------------------------------------------------- */ + +interface Indexer { + id: number + name: string + implementation: string + url: string + enabled: boolean + failure_count: number +} + +interface DownloadClient { + id: number + name: string + implementation: string + url: string + api_key?: string + category?: string + priority?: number + enabled: boolean +} + +interface QualityProfile { + id: number + name: string + media_types: string[] + cutoff_quality: string + allowed_qualities: string[] +} + +interface RootFolder { + id: number + path: string + media_type: string + free_space: number | null +} + +interface Tag { + id: number + name: string + color: string +} + +interface TaskInfo { + id: number + name: string + cron_expr: string + enabled: boolean + last_run_at: string | null + next_run_at: string | null +} + +interface NotificationChannel { + id: number + name: string + type: 'webhook' | 'telegram' + enabled: boolean + config: Record + event_types: string[] + created_at: string +} + +const EVENT_TYPES = [ + { value: 'grab', label: 'Grab' }, + { value: 'import', label: 'Import' }, + { value: 'download_complete', label: 'Download Complete' }, + { value: 'download_failed', label: 'Download Failed' }, + { value: 'quality_upgrade', label: 'Quality Upgrade' }, + { value: 'safety_block', label: 'Safety Block' }, + { value: 'error', label: 'Error' }, + { value: 'info', label: 'Info' }, +] as const + +/* -------------------------------------------------------------------------- */ +/* Toggle helper (accessible switch) */ +/* -------------------------------------------------------------------------- */ + +function Toggle({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) { + return ( + + ) +} + +/* -------------------------------------------------------------------------- */ +/* Section wrapper */ +/* -------------------------------------------------------------------------- */ + +function Section({ title, loading, error, onRetry, children }: { + title: string + loading: boolean + error: string | null + onRetry: () => void + children: React.ReactNode +}) { + return ( +
+

{title}

+ {loading ? ( + + ) : error ? ( +
+

{error}

+ +
+ ) : ( + children + )} +
+ ) +} + +function formatBytes(bytes: number | null): string { + if (!bytes) return '—' + const tb = bytes / 1e12 + if (tb >= 1) return `${tb.toFixed(1)} TB` + const gb = bytes / 1e9 + return `${gb.toFixed(1)} GB` +} + +/* -------------------------------------------------------------------------- */ +/* Metadata Section */ +/* -------------------------------------------------------------------------- */ + +function MetadataSection() { + const { showToast } = useToast() + const [refreshing, setRefreshing] = useState(false) + const [showConfirm, setShowConfirm] = useState(false) + + const handleRefreshAll = async () => { + setShowConfirm(false) + setRefreshing(true) + try { + await postAPI('/api/media/refresh-all', {}) + showToast('✓ Refreshing metadata for all monitored media...') + } catch { + showToast('Failed to trigger metadata refresh') + } finally { + setRefreshing(false) + } + } + + return ( +
{}}> +

+ Re-fetch metadata from external providers (TMDB, etc.) for all monitored media items. + This may take several minutes. +

+ + setShowConfirm(false)} + /> +
+ ) +} + +/* -------------------------------------------------------------------------- */ +/* Settings Page */ +/* -------------------------------------------------------------------------- */ + +export default function Settings() { + return ( +
+

Settings

+
+ + + + + + + + +
+
+ ) +} + +/* -------------------------------------------------------------------------- */ +/* Notifications Section */ +/* -------------------------------------------------------------------------- */ + +function NotificationsSection() { + const { showToast } = useToast() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [testing, setTesting] = useState(null) + const [showAdd, setShowAdd] = useState(false) + const [addForm, setAddForm] = useState({ + name: '', + type: 'webhook' as 'webhook' | 'telegram', + url: '', + bot_token: '', + chat_id: '', + event_types: ['grab'] as string[], + }) + const [editingId, setEditingId] = useState(null) + const [editForm, setEditForm] = useState({ + name: '', + config: {} as Record, + event_types: [] as string[], + }) + + const fetch_ = useCallback(() => { + setLoading(true); setError(null) + fetchAPI('/api/notifications/channels') + .then(d => setItems(d ?? [])) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { fetch_() }, [fetch_]) + + const testItem = async (id: number) => { + setTesting(id) + try { + const res = await postAPI<{ success: boolean; error?: string }>(`/api/notifications/channels/${id}/test`, {}) + showToast(res.success ? 'Notification sent' : `Test failed: ${res.error ?? 'unknown'}`) + } catch { showToast('Test failed') } + setTesting(null) + } + + const addItem = async () => { + try { + const body: Record = { + name: addForm.name, + type: addForm.type, + event_types: addForm.event_types, + } + if (addForm.type === 'webhook') { + body.config = { url: addForm.url } + } else { + body.config = { bot_token: addForm.bot_token, chat_id: addForm.chat_id } + } + await postAPI('/api/notifications/channels', body) + setShowAdd(false) + setAddForm({ + name: '', + type: 'webhook', + url: '', + bot_token: '', + chat_id: '', + event_types: ['grab'], + }) + fetch_() + } catch { showToast('Failed to add notification channel') } + } + + const deleteItem = async (id: number) => { + try { await deleteAPI(`/api/notifications/channels/${id}`); fetch_() } catch { showToast('Failed to delete') } + } + + const toggleEnabled = async (item: NotificationChannel) => { + try { + await putAPI(`/api/notifications/channels/${item.id}`, { enabled: !item.enabled }) + fetch_() + } catch { showToast('Failed to toggle') } + } + + const toggleEventType = (eventType: string) => { + setAddForm(prev => ({ + ...prev, + event_types: prev.event_types.includes(eventType) + ? prev.event_types.filter(t => t !== eventType) + : [...prev.event_types, eventType], + })) + } + + const toggleEditEventType = (eventType: string) => { + setEditForm(prev => ({ + ...prev, + event_types: prev.event_types.includes(eventType) + ? prev.event_types.filter(t => t !== eventType) + : [...prev.event_types, eventType], + })) + } + + const startEdit = (item: NotificationChannel) => { + if (showAdd) return + setEditingId(item.id) + setEditForm({ + name: item.name, + config: { ...item.config }, + event_types: [...item.event_types], + }) + } + + const saveEdit = async (item: NotificationChannel) => { + try { + const body: Record = { name: editForm.name, event_types: editForm.event_types } + if (item.type === 'webhook') { + body.config = { url: editForm.config['url'] ?? '' } + } else { + body.config = { bot_token: editForm.config['bot_token'] ?? '', chat_id: editForm.config['chat_id'] ?? '' } + } + await putAPI(`/api/notifications/channels/${item.id}`, body) + setEditingId(null) + fetch_() + } catch { showToast('Failed to save') } + } + + const typeBadgeColor = (type: string) => + type === 'webhook' ? 'bg-blue-900/40 text-blue-300' : 'bg-cyan-900/40 text-cyan-300' + + return ( +
+ {items.length === 0 ? ( +

No notification channels configured. Add one to receive alerts.

+ ) : ( +
+ {items.map(item => ( + editingId === item.id ? ( +
+ setEditForm({ ...editForm, name: e.target.value })} className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100" /> + {item.type === 'webhook' ? ( + setEditForm({ ...editForm, config: { ...editForm.config, url: e.target.value } })} className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100" /> + ) : ( + <> + setEditForm({ ...editForm, config: { ...editForm.config, bot_token: e.target.value } })} className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100" /> + setEditForm({ ...editForm, config: { ...editForm.config, chat_id: e.target.value } })} className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100" /> + + )} +
+ {EVENT_TYPES.map(et => ( + + ))} +
+
+ + + + +
+
+ ) : ( +
+
+
+ toggleEnabled(item)} label={`${item.name}: ${item.enabled ? 'disable' : 'enable'}`} /> +
+
+

{item.name}

+ {item.type} +
+

+ {item.type === 'webhook' + ? (item.config['url'] ?? '—') + : `chat: ${item.config['chat_id'] ?? '—'}`} +

+
+
+
+ + + +
+
+ {item.event_types.length > 0 && ( +
+ {item.event_types.map(et => ( + {et.replace(/_/g, ' ')} + ))} +
+ )} +
+ ) + ))} +
+ )} + {showAdd ? ( +
+ setAddForm({ ...addForm, name: e.target.value })} className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100" /> + + {addForm.type === 'webhook' ? ( + setAddForm({ ...addForm, url: e.target.value })} className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100" /> + ) : ( + <> + setAddForm({ ...addForm, bot_token: e.target.value })} className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100" /> + setAddForm({ ...addForm, chat_id: e.target.value })} className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100" /> + + )} +
+ {EVENT_TYPES.map(et => ( + + ))} +
+
+ + +
+
+ ) : ( + + )} +
+ ) +} + +/* -------------------------------------------------------------------------- */ +/* Indexers Section */ +/* -------------------------------------------------------------------------- */ + +interface CardigannSettingsField { + name: string + type: string + label: string +} + +interface CardigannValidation { + valid: boolean + definition?: { + site: string + name: string + settings: CardigannSettingsField[] + has_login: boolean + } + error?: string + warnings?: string[] +} + +const implBadgeColor = (impl: string) => { + switch (impl) { + case 'torznab': return 'bg-green-900/40 text-green-300' + case 'newznab': return 'bg-blue-900/40 text-blue-300' + case 'cardigann': return 'bg-amber-900/40 text-amber-300' + default: return 'bg-gray-800 text-gray-400' + } +} + +function IndexersSection() { + const { showToast } = useToast() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [testing, setTesting] = useState(null) + const [showAdd, setShowAdd] = useState(false) + const [addForm, setAddForm] = useState({ name: '', implementation: 'newznab', url: '', api_key: '' }) + + // Cardigann-specific state + const [yamlInput, setYamlInput] = useState('') + const [yamlValidation, setYamlValidation] = useState(null) + const [validating, setValidating] = useState(false) + const [cardigannConfig, setCardigannConfig] = useState>({}) + const [editingId, setEditingId] = useState(null) + const [editForm, setEditForm] = useState({ name: '', url: '', api_key: '', enabled: true }) + + const fetch_ = useCallback(() => { + setLoading(true); setError(null) + fetchAPI('/api/indexers') + .then(d => setItems(d ?? [])) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { fetch_() }, [fetch_]) + + const toggleEnabled = async (item: Indexer) => { + try { + await putAPI(`/api/indexers/${item.id}`, { enabled: !item.enabled }) + fetch_() + } catch { showToast('Failed to toggle') } + } + + const startEdit = (item: Indexer) => { + if (showAdd) return + setEditingId(item.id) + setEditForm({ name: item.name, url: item.url, api_key: '', enabled: item.enabled }) + } + + const saveEdit = async (id: number) => { + try { + const body: Record = { name: editForm.name, url: editForm.url, enabled: editForm.enabled } + if (editForm.api_key) body.api_key = editForm.api_key + await putAPI(`/api/indexers/${id}`, body) + setEditingId(null) + fetch_() + } catch { showToast('Failed to save') } + } + + const testItem = async (id: number) => { + setTesting(id) + try { + const res = await postAPI<{ success: boolean; error?: string }>(`/api/indexers/${id}/test`, {}) + showToast(res.success ? 'Connected' : `Connection failed: ${res.error ?? 'unknown'}`) + } catch { showToast('Connection failed') } + setTesting(null) + } + + const validateYaml = async () => { + if (!yamlInput.trim()) return + setValidating(true) + try { + const res = await postAPI('/api/indexers/validate-cardigann', { yaml: yamlInput }) + setYamlValidation(res) + if (res.valid && res.definition) { + // Pre-populate name from definition + setAddForm(prev => ({ ...prev, name: prev.name || res.definition!.name })) + } + } catch { + setYamlValidation({ valid: false, error: 'Validation request failed' }) + } + setValidating(false) + } + + const addItem = async () => { + try { + if (addForm.implementation === 'cardigann') { + if (!yamlValidation?.valid) { + showToast('Please validate the YAML definition first') + return + } + await postAPI('/api/indexers', { + name: addForm.name || yamlValidation.definition?.name || '', + implementation: 'cardigann', + url: '', + settings: { + yaml: yamlInput, + config: cardigannConfig, + }, + }) + } else { + await postAPI('/api/indexers', addForm) + } + setShowAdd(false) + setAddForm({ name: '', implementation: 'newznab', url: '', api_key: '' }) + setYamlInput('') + setYamlValidation(null) + setCardigannConfig({}) + fetch_() + } catch { showToast('Failed to add indexer') } + } + + const deleteItem = async (id: number) => { + try { await deleteAPI(`/api/indexers/${id}`); fetch_() } catch { showToast('Failed to delete') } + } + + const resetAddForm = () => { + setShowAdd(false) + setAddForm({ name: '', implementation: 'newznab', url: '', api_key: '' }) + setYamlInput('') + setYamlValidation(null) + setCardigannConfig({}) + } + + return ( +
+ {items.length === 0 ? ( +

No indexers configured. Add one to start searching.

+ ) : ( +
+ {items.map(item => ( + editingId === item.id ? ( +
+ setEditForm({ ...editForm, name: e.target.value })} className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100" /> +{item.implementation !== 'cardigann' && ( + <> + setEditForm({ ...editForm, url: e.target.value })} className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100" /> + setEditForm({ ...editForm, api_key: e.target.value })} className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100" /> + + )} +
+ setEditForm({ ...editForm, enabled: !editForm.enabled })} label="Indexer enabled" /> + {editForm.enabled ? 'Enabled' : 'Disabled'} +
+
+ + + + +
+
+ ) : ( +
+
+ toggleEnabled(item)} label={`${item.name}: ${item.enabled ? 'disable' : 'enable'}`} /> +
+
+

{item.name}

+ {item.implementation} +
+

+ {item.implementation === 'cardigann' ? 'YAML definition' : item.url} +

+
+
+
+ + + +
+
+ ) + ))} +
+ )} + {showAdd ? ( +
+ setAddForm({ ...addForm, name: e.target.value })} className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100" /> + + + {addForm.implementation === 'cardigann' ? ( + <> +
+ +