From 6a5bb1d699c323e5b7c6367a726c86a007d6e739 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Sat, 22 Aug 2026 02:59:16 +0200 Subject: [PATCH] init --- .env.example | 22 + .gitignore | 19 + Makefile | 34 + README.md | 322 ++ backend/cmd/migrate/main.go | 108 + backend/cmd/seed/main.go | 100 + backend/cmd/server/main.go | 153 + backend/go.mod | 39 + backend/go.sum | 100 + backend/internal/auth/handler.go | 93 + backend/internal/auth/models.go | 14 + backend/internal/auth/repository.go | 68 + backend/internal/auth/service.go | 243 + backend/internal/auth/service_test.go | 56 + backend/internal/config/config.go | 70 + backend/internal/db/db.go | 80 + backend/internal/dev/handler.go | 130 + backend/internal/downloads/handler.go | 145 + backend/internal/downloads/models.go | 22 + backend/internal/downloads/repository.go | 94 + backend/internal/downloads/service.go | 185 + backend/internal/galleries/handler.go | 677 +++ .../internal/galleries/handler_sqlite_test.go | 90 + backend/internal/galleries/models.go | 125 + backend/internal/galleries/repository.go | 230 + .../galleries/repository_sqlite_test.go | 87 + backend/internal/galleries/views.go | 45 + backend/internal/gifts/handler.go | 73 + backend/internal/gifts/handler_test.go | 125 + backend/internal/gifts/models.go | 28 + backend/internal/gifts/repository.go | 97 + .../internal/gifts/repository_sqlite_test.go | 57 + backend/internal/gifts/service.go | 33 + backend/internal/media/handler.go | 355 ++ backend/internal/media/models.go | 46 + backend/internal/media/processor.go | 154 + backend/internal/media/repository.go | 225 + backend/internal/storage/storage.go | 143 + docker-compose.yml | 41 + frontend/.prettierignore | 2 + frontend/.prettierrc | 6 + frontend/eslint.config.js | 27 + frontend/index.html | 17 + frontend/package-lock.json | 4336 +++++++++++++++++ frontend/package.json | 41 + frontend/postcss.config.js | 6 + frontend/src/App.tsx | 99 + frontend/src/components/app/AppLoading.tsx | 8 + frontend/src/components/app/AuthLayout.tsx | 23 + frontend/src/components/app/RequireAuth.tsx | 18 + .../components/dashboard/DashboardLayout.tsx | 90 + .../src/components/dashboard/GalleryCard.tsx | 84 + .../src/components/dashboard/StudioStat.tsx | 19 + .../components/dashboard/UploadDropzone.tsx | 180 + .../src/components/gallery/ClientGallery.tsx | 377 ++ .../src/components/gallery/PasswordGate.tsx | 71 + .../src/components/gallery/PhotoViewer.tsx | 141 + frontend/src/components/gift/Background.tsx | 21 + frontend/src/components/gift/Confetti.tsx | 36 + .../src/components/gift/GiftExperience.tsx | 217 + frontend/src/components/gift/GiftItem.tsx | 24 + frontend/src/components/gift/ImageItem.tsx | 36 + frontend/src/components/gift/IntroScreen.tsx | 90 + .../src/components/gift/ProgressIndicator.tsx | 22 + frontend/src/components/gift/RevealScreen.tsx | 90 + frontend/src/components/gift/TextItem.tsx | 25 + frontend/src/components/gift/VideoItem.tsx | 31 + frontend/src/features/auth/AuthContext.tsx | 30 + .../src/features/auth/AuthContextValue.ts | 12 + frontend/src/features/auth/useAuth.ts | 11 + frontend/src/lib/api.ts | 312 ++ frontend/src/lib/format.ts | 24 + frontend/src/main.tsx | 11 + frontend/src/pages/GalleryPreviewPage.tsx | 33 + frontend/src/pages/GiftPage.tsx | 88 + frontend/src/pages/LoginPage.tsx | 82 + frontend/src/pages/NotFoundPage.tsx | 19 + frontend/src/pages/PublicGalleryPage.tsx | 66 + frontend/src/pages/RegisterPage.tsx | 91 + .../src/pages/dashboard/DashboardPage.tsx | 126 + frontend/src/pages/dashboard/DevPage.tsx | 299 ++ .../src/pages/dashboard/GalleriesPage.tsx | 82 + .../src/pages/dashboard/GalleryEditorPage.tsx | 653 +++ .../src/pages/dashboard/PlaceholderPage.tsx | 25 + frontend/src/styles/index.css | 3950 +++++++++++++++ frontend/src/types/dev.ts | 34 + frontend/src/types/gallery.ts | 87 + frontend/src/types/gift.ts | 20 + frontend/src/vite-env.d.ts | 1 + frontend/tailwind.config.ts | 9 + frontend/tsconfig.app.json | 21 + frontend/tsconfig.json | 4 + frontend/tsconfig.node.json | 17 + frontend/vite.config.ts | 11 + migrations/001_create_gifts.sql | 31 + migrations/002_seed_demo.sql | 91 + migrations/003_gallery_platform.sql | 92 + migrations/sqlite/001_create_gifts.sql | 29 + migrations/sqlite/002_seed_demo.sql | 91 + migrations/sqlite/003_gallery_platform.sql | 92 + 100 files changed, 17409 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/cmd/migrate/main.go create mode 100644 backend/cmd/seed/main.go create mode 100644 backend/cmd/server/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/auth/handler.go create mode 100644 backend/internal/auth/models.go create mode 100644 backend/internal/auth/repository.go create mode 100644 backend/internal/auth/service.go create mode 100644 backend/internal/auth/service_test.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/db/db.go create mode 100644 backend/internal/dev/handler.go create mode 100644 backend/internal/downloads/handler.go create mode 100644 backend/internal/downloads/models.go create mode 100644 backend/internal/downloads/repository.go create mode 100644 backend/internal/downloads/service.go create mode 100644 backend/internal/galleries/handler.go create mode 100644 backend/internal/galleries/handler_sqlite_test.go create mode 100644 backend/internal/galleries/models.go create mode 100644 backend/internal/galleries/repository.go create mode 100644 backend/internal/galleries/repository_sqlite_test.go create mode 100644 backend/internal/galleries/views.go create mode 100644 backend/internal/gifts/handler.go create mode 100644 backend/internal/gifts/handler_test.go create mode 100644 backend/internal/gifts/models.go create mode 100644 backend/internal/gifts/repository.go create mode 100644 backend/internal/gifts/repository_sqlite_test.go create mode 100644 backend/internal/gifts/service.go create mode 100644 backend/internal/media/handler.go create mode 100644 backend/internal/media/models.go create mode 100644 backend/internal/media/processor.go create mode 100644 backend/internal/media/repository.go create mode 100644 backend/internal/storage/storage.go create mode 100644 docker-compose.yml create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/app/AppLoading.tsx create mode 100644 frontend/src/components/app/AuthLayout.tsx create mode 100644 frontend/src/components/app/RequireAuth.tsx create mode 100644 frontend/src/components/dashboard/DashboardLayout.tsx create mode 100644 frontend/src/components/dashboard/GalleryCard.tsx create mode 100644 frontend/src/components/dashboard/StudioStat.tsx create mode 100644 frontend/src/components/dashboard/UploadDropzone.tsx create mode 100644 frontend/src/components/gallery/ClientGallery.tsx create mode 100644 frontend/src/components/gallery/PasswordGate.tsx create mode 100644 frontend/src/components/gallery/PhotoViewer.tsx create mode 100644 frontend/src/components/gift/Background.tsx create mode 100644 frontend/src/components/gift/Confetti.tsx create mode 100644 frontend/src/components/gift/GiftExperience.tsx create mode 100644 frontend/src/components/gift/GiftItem.tsx create mode 100644 frontend/src/components/gift/ImageItem.tsx create mode 100644 frontend/src/components/gift/IntroScreen.tsx create mode 100644 frontend/src/components/gift/ProgressIndicator.tsx create mode 100644 frontend/src/components/gift/RevealScreen.tsx create mode 100644 frontend/src/components/gift/TextItem.tsx create mode 100644 frontend/src/components/gift/VideoItem.tsx create mode 100644 frontend/src/features/auth/AuthContext.tsx create mode 100644 frontend/src/features/auth/AuthContextValue.ts create mode 100644 frontend/src/features/auth/useAuth.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/format.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/GalleryPreviewPage.tsx create mode 100644 frontend/src/pages/GiftPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/NotFoundPage.tsx create mode 100644 frontend/src/pages/PublicGalleryPage.tsx create mode 100644 frontend/src/pages/RegisterPage.tsx create mode 100644 frontend/src/pages/dashboard/DashboardPage.tsx create mode 100644 frontend/src/pages/dashboard/DevPage.tsx create mode 100644 frontend/src/pages/dashboard/GalleriesPage.tsx create mode 100644 frontend/src/pages/dashboard/GalleryEditorPage.tsx create mode 100644 frontend/src/pages/dashboard/PlaceholderPage.tsx create mode 100644 frontend/src/styles/index.css create mode 100644 frontend/src/types/dev.ts create mode 100644 frontend/src/types/gallery.ts create mode 100644 frontend/src/types/gift.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 migrations/001_create_gifts.sql create mode 100644 migrations/002_seed_demo.sql create mode 100644 migrations/003_gallery_platform.sql create mode 100644 migrations/sqlite/001_create_gifts.sql create mode 100644 migrations/sqlite/002_seed_demo.sql create mode 100644 migrations/sqlite/003_gallery_platform.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..be368c2 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +DB_DRIVER=postgres +POSTGRES_DB=surprise +POSTGRES_USER=surprise +POSTGRES_PASSWORD=surprise_dev_password +POSTGRES_PORT=5432 + +DATABASE_URL=postgres://surprise:surprise_dev_password@localhost:5432/surprise?sslmode=disable +SQLITE_PATH=./data/surprise.db +PORT=8080 +CORS_ORIGIN=http://localhost:5173,http://127.0.0.1:5173 +SESSION_SECRET=local-development-session-secret-change-me +COOKIE_SECURE=false + +STORAGE_ENDPOINT=localhost:9000 +STORAGE_ACCESS_KEY=minioadmin +STORAGE_SECRET_KEY=minioadmin +STORAGE_BUCKET=gallery-media +STORAGE_USE_SSL=false +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 + +VITE_API_BASE_URL=http://localhost:8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a8c392 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.env +.env.* +!.env.example + +node_modules/ +frontend/dist/ +frontend/.vite/ + +coverage/ +*.test +*.exe +*.db +*.db-shm +*.db-wal +data/ +backend/data/ + +.DS_Store +Thumbs.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b73f312 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: dev frontend-dev migrate seed test build lint format compose-up compose-down + +dev: + go -C backend run ./cmd/server + +frontend-dev: + npm --prefix frontend run dev + +migrate: + go -C backend run ./cmd/migrate -dir ../migrations + +seed: + go -C backend run ./cmd/seed + +test: + go -C backend test ./... + +build: + go -C backend build ./... + npm --prefix frontend run build + +lint: + go -C backend vet ./... + npm --prefix frontend run lint + +format: + go -C backend fmt ./... + npm --prefix frontend run format + +compose-up: + docker compose up -d --wait + +compose-down: + docker compose down diff --git a/README.md b/README.md new file mode 100644 index 0000000..f770b5a --- /dev/null +++ b/README.md @@ -0,0 +1,322 @@ +# Northline Delivery Studio + +Northline is an original photographer client-delivery platform inspired by the category of premium gallery products such as DLVRD. It is built around one loop: + +```text +Upload -> Curate -> Customize -> Publish -> Send a private link +``` + +Photographers get a focused studio dashboard. Clients get a private editorial gallery without creating an account. + +## Stack + +- Frontend: React, TypeScript, Vite, React Router, Tailwind CSS, Framer Motion +- Backend: Go 1.24+, `net/http`, REST/JSON, `database/sql` +- Database: PostgreSQL by default; SQLite remains available for lightweight local testing +- Storage: MinIO locally through an S3-compatible storage interface +- Media: direct browser uploads with presigned URLs, asynchronous preview processing, signed download URLs + +## Run Locally + +Prerequisites: + +- Docker Desktop with the Compose plugin +- Go 1.24 or newer +- Node.js 20 or newer +- GNU Make, or use the direct commands below + +From the repository root: + +```bash +cp .env.example .env +npm --prefix frontend install +docker compose up -d --wait +make migrate +make seed +``` + +Start the API: + +```bash +make dev +``` + +Start the frontend in a second terminal: + +```bash +make frontend-dev +``` + +Open: + +- Dashboard: [http://localhost:5173/login](http://localhost:5173/login) +- Dev diagnostics: [http://localhost:5173/dashboard/dev](http://localhost:5173/dashboard/dev) +- Demo client gallery: [http://localhost:5173/g/emma-james-wedding](http://localhost:5173/g/emma-james-wedding) +- MinIO console: [http://localhost:9001](http://localhost:9001) + +### PowerShell + +```powershell +Copy-Item .env.example .env +npm --prefix frontend install +docker compose up -d --wait +go -C backend run ./cmd/migrate -dir ../migrations +go -C backend run ./cmd/seed +``` + +In separate terminals: + +```powershell +go -C backend run ./cmd/server +npm --prefix frontend run dev +``` + +## Demo Credentials + +The seed command creates: + +```text +Email: demo@example.com +Password: DemoPassword123! +Studio: Northline Studio +``` + +It also creates the published demo gallery: + +```text +Title: Emma & James +Slug: emma-james-wedding +Client: Emma & James +``` + +The demo media uses external placeholder URLs so the gallery is visually useful immediately. Real uploads use MinIO/S3 objects and never store media bytes in PostgreSQL. + +If an upload stops at 0%, open **Dashboard -> Dev**. The workbench shows the effective browser origin, allowed CORS origins, MinIO reachability, bucket, and the latest client-side upload error. + +## Storage Configuration + +The default `.env.example` uses MinIO: + +```dotenv +STORAGE_ENDPOINT=localhost:9000 +STORAGE_ACCESS_KEY=minioadmin +STORAGE_SECRET_KEY=minioadmin +STORAGE_BUCKET=gallery-media +STORAGE_USE_SSL=false +``` + +The Go `storage.Storage` interface supports: + +- `CreateUploadURL` +- `CreateDownloadURL` +- `Delete` +- `Stat` +- `Get` +- `Put` + +The current implementation is `MinIO`, using the MinIO Go SDK. Replacing it with AWS S3, Cloudflare R2, or Backblaze B2 only requires another implementation of that interface and provider configuration. + +Upload flow: + +```text +Browser -> POST /api/galleries/:id/uploads +API -> creates media row + presigned PUT URL +Browser -> PUT file directly to MinIO +Browser -> POST /api/uploads/:id/complete +Worker -> generates image previews and marks media READY +``` + +Original object keys are private. The API exposes only short-lived signed URLs to authenticated owners or authorized gallery visitors. + +## SQLite Mode + +SQLite is retained as a development/test option. It still requires MinIO for the complete upload workflow, but it does not require PostgreSQL. + +```dotenv +DB_DRIVER=sqlite +SQLITE_PATH=./data/surprise.db +``` + +Then run the same migration and seed commands: + +```bash +make migrate +make seed +make dev +``` + +PowerShell process variables use `$env:` syntax: + +```powershell +$env:DB_DRIVER = "sqlite" +$env:SQLITE_PATH = ".\data\surprise.db" +go -C backend run ./cmd/migrate -dir ../migrations +go -C backend run ./cmd/seed +go -C backend run ./cmd/server +``` + +The migration runner selects `migrations/sqlite/` for SQLite and the root migration files for PostgreSQL. + +## Product Workflow + +### Photographer + +1. Register or sign in. +2. Create a gallery with title, client, and description. +3. Drag in photos or videos. +4. Uploads go directly to MinIO using presigned URLs with per-file progress. +5. Set a cover image, ordering, layout, appearance, branding, password, expiry, and client controls. +6. Preview the exact public renderer. +7. Publish and copy `/g/:slug`. + +### Client + +1. Open the private gallery link. +2. Enter a gallery password if required. +3. Browse responsive image and video media. +4. Open photos in an immersive fullscreen viewer. +5. Favorite photographs anonymously. +6. Download individual originals or request an asynchronous gallery ZIP. + +## API Overview + +Authentication: + +```text +POST /api/auth/register +POST /api/auth/login +POST /api/auth/logout +GET /api/auth/me +``` + +Authenticated galleries: + +```text +GET /api/galleries +POST /api/galleries +GET /api/galleries/:id +PATCH /api/galleries/:id +DELETE /api/galleries/:id +POST /api/galleries/:id/publish +POST /api/galleries/:id/unpublish +GET /api/galleries/:id/preview +``` + +Uploads and media: + +```text +GET /api/galleries/:id/media +POST /api/galleries/:id/uploads +POST /api/uploads/:id/complete +PATCH /api/media/:id +POST /api/media/:id/download +DELETE /api/media/:id +DELETE /api/uploads/:id +``` + +Public gallery: + +```text +GET /api/public/galleries/:slug +POST /api/public/galleries/:slug/authenticate +POST /api/public/galleries/:slug/media/:mediaId/favorite +DELETE /api/public/galleries/:slug/media/:mediaId/favorite +POST /api/public/galleries/:slug/media/:mediaId/download +POST /api/public/galleries/:slug/download-all +GET /api/public/galleries/:slug/download-all/:jobId +``` + +Health: + +```text +GET /health +``` + +Photographer routes use an HTTP-only signed session cookie. Public visitor identity and gallery access are separate signed cookies; clients do not need accounts. + +## Database + +The gallery platform migration adds: + +- `users`: photographer accounts and bcrypt password hashes +- `galleries`: ownership, slug, publication state, controls, expiry, theme, and branding JSON +- `media`: original object metadata, preview keys, processing state, dimensions, and ordering +- `favorites`: anonymous gallery-scoped favorite records +- `downloads`: individual download audit records +- `download_jobs`: asynchronous ZIP status and object key + +Migration files: + +- `migrations/003_gallery_platform.sql` for PostgreSQL +- `migrations/sqlite/003_gallery_platform.sql` for SQLite + +The schema does not store uploaded media content. `external_url` exists only to make the development seed gallery useful without shipping copyrighted or binary assets; real uploads have private `storage_key` values instead. + +## Project Structure + +```text +backend/ + cmd/server/ API and worker startup + cmd/migrate/ dialect-aware SQL migration runner + cmd/seed/ demo photographer and gallery seed + internal/auth/ bcrypt auth, signed sessions, visitor access cookies + internal/galleries/ gallery CRUD and public gallery responses + internal/media/ upload lifecycle, media repository, preview worker + internal/storage/ MinIO/S3-compatible storage interface and adapter + internal/downloads async individual/ZIP download handling +frontend/ + src/components/dashboard/ photographer workspace, editor, upload queue + src/components/gallery/ shared public/preview renderer and viewer + src/features/auth/ authenticated session context + src/pages/ auth, dashboard, editor, public gallery routes + src/lib/ typed API client and formatting helpers +migrations/ PostgreSQL and SQLite SQL migrations +docker-compose.yml PostgreSQL and MinIO +``` + +Preview and public delivery use the same `ClientGallery` component. The preview endpoint changes only authorization and the `preview` banner; it does not create a second mock gallery UI. + +## Checks + +Backend: + +```bash +``` + +Frontend: + +```bash +npm --prefix frontend run build +npm --prefix frontend run lint +npm --prefix frontend run format:check +``` + +The backend test suite includes real SQLite migration/repository/auth coverage. A live PostgreSQL/MinIO run requires Docker and should be performed with the clean-checkout commands above. + +## Deliberate MVP Boundaries + +Implemented: + +- Photographer registration/login/logout +- Gallery CRUD and ownership checks +- Gallery publication and preview +- MinIO presigned direct uploads +- Per-file upload progress and cancellation +- Image preview/thumbnail worker +- Video playback using optimized/original object URLs +- Gallery themes, layouts, branding, controls, password, expiry, and cover selection +- Public responsive gallery at `/g/:slug` +- Fullscreen photo viewer with keyboard and touch navigation +- Anonymous favorites +- Signed individual downloads +- Background ZIP generation and polling + +Not implemented yet: + +- Billing, subscriptions, teams, organizations, and CRM +- Persistent distributed job queue +- FFmpeg video transcoding and streaming manifests +- Full HEIC decoding; unsupported image formats fall back to the original object +- Production email delivery +- Advanced proofing/comments/analytics +- Custom domains and cloud archive workflows diff --git a/backend/cmd/migrate/main.go b/backend/cmd/migrate/main.go new file mode 100644 index 0000000..ccb884e --- /dev/null +++ b/backend/cmd/migrate/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "database/sql" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/example/sndit/backend/internal/config" + "github.com/example/sndit/backend/internal/db" +) + +func main() { + directory := flag.String("dir", "migrations", "directory containing SQL migrations") + flag.Parse() + + cfg := config.Load() + ctx := context.Background() + database, err := db.New(ctx, cfg.DBDriver, cfg.DatabaseDSN()) + if err != nil { + log.Fatalf("database unavailable: %v", err) + } + defer database.Close() + + if err := run(ctx, database, *directory, cfg.DBDriver); err != nil { + log.Fatal(err) + } +} + +func run(ctx context.Context, database *sql.DB, directory, driver string) error { + if _, err := database.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + return fmt.Errorf("create migration table: %w", err) + } + + directory = migrationDirectory(directory, driver) + files, err := migrationFiles(directory) + if err != nil { + return err + } + + for _, path := range files { + version := filepath.Base(path) + var applied bool + if err := database.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&applied); err != nil { + return fmt.Errorf("check migration %s: %w", version, err) + } + if applied { + continue + } + + sql, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read migration %s: %w", version, err) + } + + tx, err := database.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin migration %s: %w", version, err) + } + if _, err := tx.ExecContext(ctx, string(sql)); err != nil { + _ = tx.Rollback() + return fmt.Errorf("apply migration %s: %w", version, err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil { + _ = tx.Rollback() + return fmt.Errorf("record migration %s: %w", version, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %s: %w", version, err) + } + log.Printf("applied migration %s", version) + } + + return nil +} + +func migrationDirectory(directory, driver string) string { + if strings.EqualFold(driver, "sqlite") || strings.EqualFold(driver, "sqlite3") { + return filepath.Join(directory, "sqlite") + } + return directory +} + +func migrationFiles(directory string) ([]string, error) { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, fmt.Errorf("read migration directory: %w", err) + } + + files := make([]string, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { + files = append(files, filepath.Join(directory, entry.Name())) + } + } + sort.Strings(files) + return files, nil +} diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go new file mode 100644 index 0000000..1b524b1 --- /dev/null +++ b/backend/cmd/seed/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + + "github.com/example/sndit/backend/internal/config" + "github.com/example/sndit/backend/internal/db" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +const ( + demoEmail = "demo@example.com" + demoPassword = "DemoPassword123!" +) + +func main() { + cfg := config.Load() + ctx := context.Background() + database, err := db.New(ctx, cfg.DBDriver, cfg.DatabaseDSN()) + if err != nil { + log.Fatalf("database unavailable: %v", err) + } + defer database.Close() + + if err := seed(ctx, database); err != nil { + log.Fatal(err) + } + log.Printf("seeded %s with the Northline Studio demo gallery", demoEmail) +} + +func seed(ctx context.Context, database *sql.DB) error { + passwordHash, err := bcrypt.GenerateFromPassword([]byte(demoPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash demo password: %w", err) + } + if _, err := database.ExecContext(ctx, ` + INSERT INTO users (id, email, password_hash, name) + VALUES ($1, $2, $3, $4) + ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP + `, demoUserID, demoEmail, string(passwordHash), "Northline Studio"); err != nil { + return fmt.Errorf("seed demo user: %w", err) + } + + if _, err := database.ExecContext(ctx, ` + INSERT INTO galleries ( + id, user_id, slug, title, client_name, description, status, + downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled, + cover_media_id, theme_config, branding_config, published_at + ) + VALUES ($1, $2, $3, $4, $5, $6, 'published', TRUE, TRUE, TRUE, FALSE, $7, $8, $9, CURRENT_TIMESTAMP) + ON CONFLICT (slug) DO UPDATE SET + user_id = EXCLUDED.user_id, title = EXCLUDED.title, client_name = EXCLUDED.client_name, + description = EXCLUDED.description, status = EXCLUDED.status, + downloads_enabled = EXCLUDED.downloads_enabled, favorites_enabled = EXCLUDED.favorites_enabled, + download_all_enabled = EXCLUDED.download_all_enabled, watermark_enabled = EXCLUDED.watermark_enabled, + cover_media_id = EXCLUDED.cover_media_id, theme_config = EXCLUDED.theme_config, + branding_config = EXCLUDED.branding_config, published_at = EXCLUDED.published_at, + updated_at = CURRENT_TIMESTAMP + `, demoGalleryID, demoUserID, "emma-james-wedding", "Emma & James", "Emma & James", "An early summer wedding, held close to the water and the people who make it home.", demoCoverID, + `{"mode":"light","layout":"editorial","accent":"#ad695b","font":"serif"}`, + `{"studioName":"Northline Studio","tagline":"Photographs for keeps.","websiteUrl":"https://example.com","instagramUrl":"https://instagram.com"}`); err != nil { + return fmt.Errorf("seed demo gallery: %w", err) + } + + mediaRows := []struct { + id, filename, mime, externalURL string + sortOrder int + duration float64 + }{ + {demoCoverID.String(), "emma-james-01.jpg", "image/jpeg", "https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=1800&q=88", 1, 0}, + {demoPhotoID.String(), "emma-james-02.jpg", "image/jpeg", "https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=1800&q=88", 2, 0}, + {demoVideoID.String(), "emma-james-film.mp4", "video/mp4", "https://storage.googleapis.com/coverr-main/mp4/Mt_Baker.mp4", 3, 31.0}, + } + for _, item := range mediaRows { + if _, err := database.ExecContext(ctx, ` + INSERT INTO media (id, gallery_id, original_filename, mime_type, file_size, storage_key, external_url, processing_status, sort_order, duration_seconds) + VALUES ($1, $2, $3, $4, 0, $5, $6, 'READY', $7, $8) + ON CONFLICT (id) DO UPDATE SET + gallery_id = EXCLUDED.gallery_id, original_filename = EXCLUDED.original_filename, + mime_type = EXCLUDED.mime_type, storage_key = EXCLUDED.storage_key, + external_url = EXCLUDED.external_url, processing_status = EXCLUDED.processing_status, + sort_order = EXCLUDED.sort_order, duration_seconds = EXCLUDED.duration_seconds, updated_at = CURRENT_TIMESTAMP + `, uuid.MustParse(item.id), demoGalleryID, item.filename, item.mime, "demo/"+item.filename, item.externalURL, item.sortOrder, item.duration); err != nil { + return fmt.Errorf("seed demo media %s: %w", item.filename, err) + } + } + return nil +} + +var ( + demoUserID = uuid.MustParse("55555555-5555-4555-8555-555555555555") + demoGalleryID = uuid.MustParse("66666666-6666-4666-8666-666666666666") + demoCoverID = uuid.MustParse("77777777-7777-4777-8777-777777777777") + demoPhotoID = uuid.MustParse("88888888-8888-4888-8888-888888888888") + demoVideoID = uuid.MustParse("99999999-9999-4999-8999-999999999999") +) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..c779e8a --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,153 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/example/sndit/backend/internal/auth" + "github.com/example/sndit/backend/internal/config" + "github.com/example/sndit/backend/internal/db" + devtools "github.com/example/sndit/backend/internal/dev" + "github.com/example/sndit/backend/internal/downloads" + "github.com/example/sndit/backend/internal/galleries" + "github.com/example/sndit/backend/internal/gifts" + "github.com/example/sndit/backend/internal/media" + "github.com/example/sndit/backend/internal/storage" +) + +func main() { + cfg := config.Load() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + database, err := db.New(ctx, cfg.DBDriver, cfg.DatabaseDSN()) + if err != nil { + log.Fatalf("database unavailable: %v", err) + } + defer database.Close() + + objectStorage, err := storage.NewMinIO(storage.Config{ + Endpoint: cfg.StorageEndpoint, + AccessKey: cfg.StorageAccessKey, + SecretKey: cfg.StorageSecretKey, + Bucket: cfg.StorageBucket, + UseSSL: cfg.StorageUseSSL, + CORSOrigins: cfg.CORSOrigin, + }) + if err != nil { + log.Fatalf("storage unavailable: %v", err) + } + if err := objectStorage.EnsureBucket(ctx); err != nil { + log.Fatalf("storage unavailable: %v", err) + } + + authRepository := auth.NewRepository(database) + authService, err := auth.NewService(authRepository, cfg.SessionSecret, cfg.CookieSecure) + if err != nil { + log.Fatalf("authentication unavailable: %v", err) + } + galleryRepository := galleries.NewRepository(database) + mediaRepository := media.NewRepository(database) + mediaProcessor := media.NewProcessor(mediaRepository, objectStorage, 2) + defer mediaProcessor.Close() + downloadRepository := downloads.NewRepository(database) + downloadService := downloads.NewService(downloadRepository, mediaRepository, objectStorage, 1) + defer downloadService.Close() + + authHandler := auth.NewHandler(authService) + galleryHandler := galleries.NewHandler(galleryRepository, mediaRepository, objectStorage, authService) + mediaHandler := media.NewHandler(mediaRepository, objectStorage, mediaProcessor, authService) + downloadHandler := downloads.NewHandler(galleryRepository, mediaRepository, objectStorage, authService, downloadService) + devHandler := devtools.NewHandler(database, objectStorage, authService, devtools.Config{ + DBDriver: cfg.DBDriver, + StorageEndpoint: cfg.StorageEndpoint, + StorageBucket: cfg.StorageBucket, + StorageUseSSL: cfg.StorageUseSSL, + CORSOrigins: cfg.CORSOrigin, + CookieSecure: cfg.CookieSecure, + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /health", health) + authHandler.RegisterRoutes(mux) + galleryHandler.RegisterProtectedRoutes(mux, authService.Require) + mediaHandler.RegisterRoutes(mux, authService.Require) + galleryHandler.RegisterPublicRoutes(mux) + downloadHandler.RegisterRoutes(mux) + devHandler.RegisterRoutes(mux, authService.Require) + + // Keep the original public gift endpoint available while the gallery product + // uses /api/public/galleries/:slug. + legacyGifts := gifts.NewHandler(gifts.NewService(gifts.NewRepository(database))) + mux.Handle("/api/gifts/", legacyGifts.Routes()) + + server := &http.Server{ + Addr: ":" + cfg.Port, + Handler: withCORS(withLogging(mux), cfg.CORSOrigin), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + log.Printf("API listening on http://localhost%s", server.Addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server failed: %v", err) + } + }() + + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("server shutdown failed: %v", err) + } +} + +func health(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}` + "\n")) +} + +func withCORS(next http.Handler, allowedOrigin string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && originAllowed(origin, allowedOrigin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Vary", "Origin") + } + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func originAllowed(origin, configured string) bool { + for _, allowed := range strings.Split(configured, ",") { + if strings.TrimSpace(allowed) == "*" || strings.TrimSpace(allowed) == origin { + return true + } + } + return false +} + +func withLogging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(started).Round(time.Millisecond)) + }) +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..0e2d1ff --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,39 @@ +module github.com/example/sndit/backend + +go 1.24.0 + +require ( + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.6 + github.com/joho/godotenv v1.5.1 + github.com/minio/minio-go/v7 v7.0.95 + golang.org/x/crypto v0.39.0 + modernc.org/sqlite v1.39.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/crc64nvme v1.0.2 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.3.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.26.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..d8b7c6b --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,100 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg= +github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU= +github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= +modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go new file mode 100644 index 0000000..2fafaf4 --- /dev/null +++ b/backend/internal/auth/handler.go @@ -0,0 +1,93 @@ +package auth + +import ( + "encoding/json" + "errors" + "net/http" + "strings" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/auth/register", h.Register) + mux.HandleFunc("POST /api/auth/login", h.Login) + mux.HandleFunc("POST /api/auth/logout", h.Logout) + mux.HandleFunc("GET /api/auth/me", h.Me) +} + +type credentialsRequest struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` +} + +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + var request credentialsRequest + if !decodeJSON(w, r, &request) { + return + } + user, err := h.service.Register(r.Context(), request.Email, request.Password, request.Name) + if err != nil { + if errors.Is(err, ErrEmailTaken) { + writeJSON(w, http.StatusConflict, map[string]string{"error": "email is already registered"}) + return + } + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + h.service.SetSession(w, user) + writeJSON(w, http.StatusCreated, map[string]User{"user": user}) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var request credentialsRequest + if !decodeJSON(w, r, &request) { + return + } + user, err := h.service.Login(r.Context(), request.Email, request.Password) + if err != nil { + if errors.Is(err, ErrInvalidCredentials) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid email or password"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not sign in"}) + return + } + h.service.SetSession(w, user) + writeJSON(w, http.StatusOK, map[string]User{"user": user}) +} + +func (h *Handler) Logout(w http.ResponseWriter, _ *http.Request) { + h.service.ClearSession(w) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { + user, err := h.service.UserFromRequest(r.Context(), r) + if err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + writeJSON(w, http.StatusOK, map[string]User{"user": user}) +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": "content type must be application/json"}) + return false + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"}) + return false + } + return true +} diff --git a/backend/internal/auth/models.go b/backend/internal/auth/models.go new file mode 100644 index 0000000..673f397 --- /dev/null +++ b/backend/internal/auth/models.go @@ -0,0 +1,14 @@ +package auth + +import "github.com/google/uuid" + +type User struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` +} + +type storedUser struct { + User + PasswordHash string +} diff --git a/backend/internal/auth/repository.go b/backend/internal/auth/repository.go new file mode 100644 index 0000000..1927b31 --- /dev/null +++ b/backend/internal/auth/repository.go @@ -0,0 +1,68 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" +) + +type Repository struct { + db *sql.DB +} + +func NewRepository(db *sql.DB) *Repository { + return &Repository{db: db} +} + +var ErrEmailTaken = errors.New("email already registered") + +func (r *Repository) CreateUser(ctx context.Context, email, passwordHash, name string) (User, error) { + user := User{ID: uuid.New(), Email: strings.ToLower(strings.TrimSpace(email)), Name: strings.TrimSpace(name)} + _, err := r.db.ExecContext(ctx, ` + INSERT INTO users (id, email, password_hash, name) + VALUES ($1, $2, $3, $4) + `, user.ID, user.Email, passwordHash, user.Name) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + return User{}, ErrEmailTaken + } + return User{}, fmt.Errorf("create user: %w", err) + } + return user, nil +} + +func (r *Repository) FindByEmail(ctx context.Context, email string) (storedUser, error) { + var user storedUser + err := r.db.QueryRowContext(ctx, ` + SELECT id, email, name, password_hash + FROM users + WHERE lower(email) = lower($1) + `, strings.TrimSpace(email)).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash) + if errors.Is(err, sql.ErrNoRows) { + return storedUser{}, sql.ErrNoRows + } + if err != nil { + return storedUser{}, fmt.Errorf("find user by email: %w", err) + } + return user, nil +} + +func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (User, error) { + var user User + err := r.db.QueryRowContext(ctx, ` + SELECT id, email, name + FROM users + WHERE id = $1 + `, id).Scan(&user.ID, &user.Email, &user.Name) + if errors.Is(err, sql.ErrNoRows) { + return User{}, sql.ErrNoRows + } + if err != nil { + return User{}, fmt.Errorf("find user by id: %w", err) + } + return user, nil +} diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go new file mode 100644 index 0000000..3b97826 --- /dev/null +++ b/backend/internal/auth/service.go @@ -0,0 +1,243 @@ +package auth + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +const ( + sessionCookieName = "studio_session" + visitorCookieName = "studio_visitor" + accessCookieName = "studio_gallery_access" + sessionDuration = 7 * 24 * time.Hour + accessDuration = 12 * time.Hour +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrInvalidSession = errors.New("invalid session") +) + +type Service struct { + repository *Repository + secret []byte + secure bool +} + +func NewService(repository *Repository, secret string, secureCookie bool) (*Service, error) { + if len(secret) < 32 { + return nil, fmt.Errorf("session secret must be at least 32 characters") + } + return &Service{repository: repository, secret: []byte(secret), secure: secureCookie}, nil +} + +func (s *Service) Register(ctx context.Context, email, password, name string) (User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + name = strings.TrimSpace(name) + if !strings.Contains(email, "@") || len(email) > 254 { + return User{}, fmt.Errorf("enter a valid email address") + } + if len(password) < 8 { + return User{}, fmt.Errorf("password must be at least 8 characters") + } + if name == "" || len(name) > 120 { + return User{}, fmt.Errorf("name is required") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return User{}, fmt.Errorf("hash password: %w", err) + } + return s.repository.CreateUser(ctx, email, string(hash), name) +} + +func (s *Service) Login(ctx context.Context, email, password string) (User, error) { + user, err := s.repository.FindByEmail(ctx, email) + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrInvalidCredentials + } + if err != nil { + return User{}, err + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return User{}, ErrInvalidCredentials + } + return user.User, nil +} + +func (s *Service) SetSession(w http.ResponseWriter, user User) { + payload := sessionPayload{UserID: user.ID.String(), ExpiresAt: time.Now().Add(sessionDuration).Unix()} + token, err := s.signJSON(payload) + if err != nil { + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + MaxAge: int(sessionDuration.Seconds()), + HttpOnly: true, + Secure: s.secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func (s *Service) ClearSession(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: s.secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (User, error) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return User{}, ErrInvalidSession + } + var payload sessionPayload + if err := s.verifyJSON(cookie.Value, &payload); err != nil { + return User{}, ErrInvalidSession + } + userID, err := uuid.Parse(payload.UserID) + if err != nil || payload.ExpiresAt <= time.Now().Unix() { + return User{}, ErrInvalidSession + } + user, err := s.repository.FindByID(ctx, userID) + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrInvalidSession + } + return user, err +} + +type contextKey string + +const userContextKey contextKey = "authenticated-user" + +func (s *Service) Require(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, err := s.UserFromRequest(r.Context(), r) + if err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) + }) +} + +func UserFromContext(ctx context.Context) (User, bool) { + user, ok := ctx.Value(userContextKey).(User) + return user, ok +} + +func (s *Service) EnsureVisitor(w http.ResponseWriter, r *http.Request) string { + if cookie, err := r.Cookie(visitorCookieName); err == nil { + if _, err := uuid.Parse(cookie.Value); err == nil { + return cookie.Value + } + } + + visitorID := uuid.NewString() + http.SetCookie(w, &http.Cookie{ + Name: visitorCookieName, + Value: visitorID, + Path: "/", + MaxAge: int(365 * 24 * time.Hour / time.Second), + HttpOnly: true, + Secure: s.secure, + SameSite: http.SameSiteLaxMode, + }) + return visitorID +} + +func (s *Service) GrantGalleryAccess(w http.ResponseWriter, slug string) { + payload := accessPayload{Slug: slug, ExpiresAt: time.Now().Add(accessDuration).Unix()} + token, err := s.signJSON(payload) + if err != nil { + return + } + http.SetCookie(w, &http.Cookie{ + Name: accessCookieName, + Value: token, + Path: "/", + MaxAge: int(accessDuration.Seconds()), + HttpOnly: true, + Secure: s.secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func (s *Service) HasGalleryAccess(r *http.Request, slug string) bool { + cookie, err := r.Cookie(accessCookieName) + if err != nil { + return false + } + var payload accessPayload + if err := s.verifyJSON(cookie.Value, &payload); err != nil { + return false + } + return payload.Slug == slug && payload.ExpiresAt > time.Now().Unix() +} + +type sessionPayload struct { + UserID string `json:"userId"` + ExpiresAt int64 `json:"expiresAt"` +} + +type accessPayload struct { + Slug string `json:"slug"` + ExpiresAt int64 `json:"expiresAt"` +} + +func (s *Service) signJSON(value any) (string, error) { + data, err := json.Marshal(value) + if err != nil { + return "", err + } + encoded := base64.RawURLEncoding.EncodeToString(data) + return encoded + "." + s.signature(encoded), nil +} + +func (s *Service) verifyJSON(token string, target any) error { + encoded, signature, ok := strings.Cut(token, ".") + if !ok || subtle.ConstantTimeCompare([]byte(signature), []byte(s.signature(encoded))) != 1 { + return ErrInvalidSession + } + data, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return ErrInvalidSession + } + if err := json.Unmarshal(data, target); err != nil { + return ErrInvalidSession + } + return nil +} + +func (s *Service) signature(value string) string { + hash := hmac.New(sha256.New, s.secret) + _, _ = hash.Write([]byte(value)) + return base64.RawURLEncoding.EncodeToString(hash.Sum(nil)) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/backend/internal/auth/service_test.go b/backend/internal/auth/service_test.go new file mode 100644 index 0000000..27213f2 --- /dev/null +++ b/backend/internal/auth/service_test.go @@ -0,0 +1,56 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + appdb "github.com/example/sndit/backend/internal/db" +) + +func TestRegisterLoginAndSession(t *testing.T) { + ctx := context.Background() + database, err := appdb.New(ctx, "sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite database: %v", err) + } + defer database.Close() + if _, err := database.ExecContext(ctx, ` + CREATE TABLE users ( + id TEXT PRIMARY KEY NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + t.Fatalf("create users table: %v", err) + } + + service, err := NewService(NewRepository(database), "test-session-secret-that-is-long-enough", false) + if err != nil { + t.Fatalf("create auth service: %v", err) + } + user, err := service.Register(ctx, "Photographer@Example.com", "DemoPassword123!", "Northline Studio") + if err != nil { + t.Fatalf("register user: %v", err) + } + if user.Email != "photographer@example.com" { + t.Fatalf("email was not normalized: %q", user.Email) + } + loggedIn, err := service.Login(ctx, "PHOTOGRAPHER@example.com", "DemoPassword123!") + if err != nil || loggedIn.ID != user.ID { + t.Fatalf("login failed: user=%+v err=%v", loggedIn, err) + } + + recorder := httptest.NewRecorder() + service.SetSession(recorder, user) + request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + request.AddCookie(recorder.Result().Cookies()[0]) + fromSession, err := service.UserFromRequest(ctx, request) + if err != nil || fromSession.ID != user.ID { + t.Fatalf("session lookup failed: user=%+v err=%v", fromSession, err) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..55f879b --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,70 @@ +package config + +import ( + "os" + "strconv" + "strings" + + "github.com/joho/godotenv" +) + +// Config contains the small set of process-level settings needed by the API. +type Config struct { + DBDriver string + Port string + DatabaseURL string + SQLitePath string + CORSOrigin string + SessionSecret string + CookieSecure bool + StorageEndpoint string + StorageAccessKey string + StorageSecretKey string + StorageBucket string + StorageUseSSL bool +} + +func Load() Config { + // Loading is intentionally best-effort: real environment variables still win, + // while local commands can be run from either the repository or backend folder. + for _, path := range []string{".env", "../.env", "../../.env"} { + _ = godotenv.Load(path) + } + + return Config{ + DBDriver: envOrDefault("DB_DRIVER", "postgres"), + Port: envOrDefault("PORT", "8080"), + DatabaseURL: envOrDefault("DATABASE_URL", "postgres://surprise:surprise_dev_password@localhost:5432/surprise?sslmode=disable"), + SQLitePath: envOrDefault("SQLITE_PATH", "./data/surprise.db"), + CORSOrigin: envOrDefault("CORS_ORIGIN", "http://localhost:5173,http://127.0.0.1:5173"), + SessionSecret: envOrDefault("SESSION_SECRET", "local-development-session-secret-change-me"), + CookieSecure: parseBoolEnv("COOKIE_SECURE", false), + StorageEndpoint: envOrDefault("STORAGE_ENDPOINT", "localhost:9000"), + StorageAccessKey: envOrDefault("STORAGE_ACCESS_KEY", "minioadmin"), + StorageSecretKey: envOrDefault("STORAGE_SECRET_KEY", "minioadmin"), + StorageBucket: envOrDefault("STORAGE_BUCKET", "gallery-media"), + StorageUseSSL: parseBoolEnv("STORAGE_USE_SSL", false), + } +} + +func (c Config) DatabaseDSN() string { + if strings.EqualFold(c.DBDriver, "sqlite") || strings.EqualFold(c.DBDriver, "sqlite3") { + return c.SQLitePath + } + return c.DatabaseURL +} + +func envOrDefault(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func parseBoolEnv(key string, fallback bool) bool { + value, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(key))) + if err != nil { + return fallback + } + return value +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..46af9d0 --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,80 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + + _ "github.com/jackc/pgx/v5/stdlib" + _ "modernc.org/sqlite" +) + +func New(ctx context.Context, driver, dsn string) (*sql.DB, error) { + driverName, err := normalizeDriver(driver) + if err != nil { + return nil, err + } + if driverName == "sqlite" { + if err := ensureSQLiteDirectory(dsn); err != nil { + return nil, fmt.Errorf("prepare sqlite path: %w", err) + } + } + + database, err := sql.Open(driverName, dsn) + if err != nil { + return nil, fmt.Errorf("open %s database: %w", driverName, err) + } + + if driverName == "sqlite" { + // In-memory databases are connection-local, so keep SQLite single-connection + // and enable foreign keys for every operation through this handle. + database.SetMaxOpenConns(1) + database.SetMaxIdleConns(1) + if _, err := database.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { + database.Close() + return nil, fmt.Errorf("configure sqlite: %w", err) + } + } else { + database.SetMaxOpenConns(10) + database.SetMaxIdleConns(1) + } + + if err := database.PingContext(ctx); err != nil { + database.Close() + return nil, fmt.Errorf("ping %s database: %w", driverName, err) + } + + return database, nil +} + +func normalizeDriver(driver string) (string, error) { + switch strings.ToLower(strings.TrimSpace(driver)) { + case "postgres", "postgresql", "pgx": + return "pgx", nil + case "sqlite", "sqlite3": + return "sqlite", nil + default: + return "", fmt.Errorf("unsupported database driver %q (use postgres or sqlite)", driver) + } +} + +func ensureSQLiteDirectory(dsn string) error { + if dsn == ":memory:" || strings.HasPrefix(dsn, "file::memory:") { + return nil + } + + path := strings.SplitN(dsn, "?", 2)[0] + path = strings.TrimPrefix(path, "file:") + if path == "" || path == ":memory:" { + return nil + } + + directory := filepath.Dir(path) + if directory == "." || directory == "" { + return nil + } + return os.MkdirAll(directory, 0o755) +} diff --git a/backend/internal/dev/handler.go b/backend/internal/dev/handler.go new file mode 100644 index 0000000..0574821 --- /dev/null +++ b/backend/internal/dev/handler.go @@ -0,0 +1,130 @@ +package dev + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/example/sndit/backend/internal/auth" + "github.com/example/sndit/backend/internal/storage" +) + +type Config struct { + DBDriver string + StorageEndpoint string + StorageBucket string + StorageUseSSL bool + CORSOrigins string + CookieSecure bool +} + +type Handler struct { + db *sql.DB + storage storage.Storage + auth *auth.Service + config Config +} + +func NewHandler(db *sql.DB, objectStorage storage.Storage, authService *auth.Service, config Config) *Handler { + return &Handler{db: db, storage: objectStorage, auth: authService, config: config} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) { + mux.Handle("GET /api/dev/diagnostics", require(http.HandlerFunc(h.Diagnostics))) + mux.Handle("POST /api/dev/storage-check", require(http.HandlerFunc(h.StorageCheck))) +} + +func (h *Handler) Diagnostics(w http.ResponseWriter, r *http.Request) { + user, _ := auth.UserFromContext(r.Context()) + databaseError := "" + databaseContext, cancel := context.WithTimeout(r.Context(), 2*time.Second) + if err := h.db.PingContext(databaseContext); err != nil { + databaseError = err.Error() + } + cancel() + + storageError := "" + storageContext, storageCancel := context.WithTimeout(r.Context(), 3*time.Second) + if err := h.storage.EnsureBucket(storageContext); err != nil { + storageError = err.Error() + } + storageCancel() + + writeJSON(w, http.StatusOK, map[string]any{ + "environment": "development", + "now": time.Now().UTC().Format(time.RFC3339), + "user": map[string]string{ + "id": user.ID.String(), + "email": user.Email, + "name": user.Name, + }, + "database": map[string]any{ + "driver": h.config.DBDriver, + "connected": databaseError == "", + "error": databaseError, + }, + "storage": map[string]any{ + "provider": "MinIO / S3-compatible", + "endpoint": h.config.StorageEndpoint, + "bucket": h.config.StorageBucket, + "secure": h.config.StorageUseSSL, + "reachable": storageError == "", + "error": storageError, + }, + "http": map[string]any{ + "corsOrigins": splitOrigins(h.config.CORSOrigins), + "cookieSecure": h.config.CookieSecure, + }, + }) +} + +func (h *Handler) StorageCheck(w http.ResponseWriter, r *http.Request) { + if err := h.storage.EnsureBucket(r.Context()); err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "bucket", "error": err.Error()}) + return + } + var randomBytes [12]byte + if _, err := rand.Read(randomBytes[:]); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "step": "random", "error": err.Error()}) + return + } + key := fmt.Sprintf("dev/diagnostics/%s.txt", hex.EncodeToString(randomBytes[:])) + contents := "northline storage check " + time.Now().UTC().Format(time.RFC3339Nano) + if err := h.storage.Put(r.Context(), key, strings.NewReader(contents), int64(len(contents)), "text/plain"); err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "put", "error": err.Error()}) + return + } + info, statErr := h.storage.Stat(r.Context(), key) + deleteErr := h.storage.Delete(r.Context(), key) + if statErr != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "stat", "error": statErr.Error()}) + return + } + if deleteErr != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "delete", "error": deleteErr.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "bytes": info.Size, "contentType": info.ContentType}) +} + +func splitOrigins(value string) []string { + result := make([]string, 0) + for _, origin := range strings.Split(value, ",") { + if trimmed := strings.TrimSpace(origin); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/backend/internal/downloads/handler.go b/backend/internal/downloads/handler.go new file mode 100644 index 0000000..3301b7f --- /dev/null +++ b/backend/internal/downloads/handler.go @@ -0,0 +1,145 @@ +package downloads + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/example/sndit/backend/internal/auth" + "github.com/example/sndit/backend/internal/galleries" + "github.com/example/sndit/backend/internal/media" + "github.com/example/sndit/backend/internal/storage" + "github.com/google/uuid" +) + +type Handler struct { + galleries *galleries.Repository + media *media.Repository + storage storage.Storage + auth *auth.Service + service *Service +} + +func NewHandler(galleryRepository *galleries.Repository, mediaRepository *media.Repository, objectStorage storage.Storage, authService *auth.Service, service *Service) *Handler { + return &Handler{galleries: galleryRepository, media: mediaRepository, storage: objectStorage, auth: authService, service: service} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/download", h.Download) + mux.HandleFunc("POST /api/public/galleries/{slug}/download-all", h.DownloadAll) + mux.HandleFunc("GET /api/public/galleries/{slug}/download-all/{jobId}", h.DownloadAllStatus) +} + +func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { + record, err := h.publicRecord(r) + if err != nil { + writeError(w, http.StatusNotFound, "gallery not found") + return + } + if !record.DownloadsEnabled { + writeError(w, http.StatusForbidden, "downloads are disabled") + return + } + mediaID, err := uuid.Parse(r.PathValue("mediaId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid media id") + return + } + item, err := h.media.GetByID(r.Context(), mediaID) + if err != nil || item.GalleryID != record.ID || item.ProcessingStatus != media.StatusReady { + writeError(w, http.StatusNotFound, "media not found") + return + } + url, err := h.downloadURL(r, item) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not create download") + return + } + visitorID := h.auth.EnsureVisitor(w, r) + _ = h.media.RecordDownload(r.Context(), record.ID, &mediaID, visitorID) + writeJSON(w, http.StatusOK, map[string]string{"url": url}) +} + +func (h *Handler) DownloadAll(w http.ResponseWriter, r *http.Request) { + record, err := h.publicRecord(r) + if err != nil { + writeError(w, http.StatusNotFound, "gallery not found") + return + } + if !record.DownloadAllEnabled || !record.DownloadsEnabled { + writeError(w, http.StatusForbidden, "gallery downloads are disabled") + return + } + visitorID := h.auth.EnsureVisitor(w, r) + job, err := h.service.Create(r.Context(), record.ID, visitorID) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not start gallery download") + return + } + writeJSON(w, http.StatusAccepted, map[string]string{"jobId": job.ID.String(), "status": job.Status}) +} + +func (h *Handler) DownloadAllStatus(w http.ResponseWriter, r *http.Request) { + record, err := h.publicRecord(r) + if err != nil { + writeError(w, http.StatusNotFound, "gallery not found") + return + } + if !record.DownloadAllEnabled || !record.DownloadsEnabled { + writeError(w, http.StatusForbidden, "gallery downloads are disabled") + return + } + jobID, err := uuid.Parse(r.PathValue("jobId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid download job id") + return + } + visitorID := h.auth.EnsureVisitor(w, r) + job, err := h.service.Get(r.Context(), jobID, record.ID, visitorID) + if err != nil { + writeError(w, http.StatusNotFound, "download job not found") + return + } + response := map[string]any{"jobId": job.ID.String(), "status": job.Status} + if job.Error != "" { + response["error"] = job.Error + } + if job.Status == StatusReady { + url, err := h.storage.CreateDownloadURL(r.Context(), job.StorageKey, time.Hour) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not create download URL") + return + } + response["url"] = url + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) publicRecord(r *http.Request) (galleries.GalleryRecord, error) { + record, err := h.galleries.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug"))) + if err != nil || record.IsExpired() { + return galleries.GalleryRecord{}, galleries.ErrNotFound + } + if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) { + return galleries.GalleryRecord{}, galleries.ErrNotFound + } + return record, nil +} + +func (h *Handler) downloadURL(r *http.Request, item media.Record) (string, error) { + if item.ExternalURL != "" { + return item.ExternalURL, nil + } + return h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/backend/internal/downloads/models.go b/backend/internal/downloads/models.go new file mode 100644 index 0000000..789bd47 --- /dev/null +++ b/backend/internal/downloads/models.go @@ -0,0 +1,22 @@ +package downloads + +import "github.com/google/uuid" + +const ( + StatusQueued = "QUEUED" + StatusProcessing = "PROCESSING" + StatusReady = "READY" + StatusFailed = "FAILED" +) + +type Job struct { + ID uuid.UUID + GalleryID uuid.UUID + VisitorID string + Status string + StorageKey string + Error string + CreatedAt string + UpdatedAt string + CompletedAt string +} diff --git a/backend/internal/downloads/repository.go b/backend/internal/downloads/repository.go new file mode 100644 index 0000000..4c7be55 --- /dev/null +++ b/backend/internal/downloads/repository.go @@ -0,0 +1,94 @@ +package downloads + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +type Repository struct { + db *sql.DB +} + +func NewRepository(db *sql.DB) *Repository { + return &Repository{db: db} +} + +var ErrNotFound = errors.New("download job not found") + +func (r *Repository) Create(ctx context.Context, galleryID uuid.UUID, visitorID string) (Job, error) { + job := Job{ID: uuid.New(), GalleryID: galleryID, VisitorID: visitorID, Status: StatusQueued} + _, err := r.db.ExecContext(ctx, ` + INSERT INTO download_jobs (id, gallery_id, visitor_id, status) + VALUES ($1, $2, $3, $4) + `, job.ID, job.GalleryID, job.VisitorID, job.Status) + if err != nil { + return Job{}, fmt.Errorf("create download job: %w", err) + } + return r.GetForVisitor(ctx, job.ID, galleryID, visitorID) +} + +func (r *Repository) GetForVisitor(ctx context.Context, jobID, galleryID uuid.UUID, visitorID string) (Job, error) { + return r.get(ctx, ` + WHERE id = $1 AND gallery_id = $2 AND visitor_id = $3 + `, jobID, galleryID, visitorID) +} + +func (r *Repository) Get(ctx context.Context, jobID uuid.UUID) (Job, error) { + return r.get(ctx, `WHERE id = $1`, jobID) +} + +func (r *Repository) get(ctx context.Context, predicate string, args ...any) (Job, error) { + var ( + job Job + storageKey, jobError, createdAt, updatedAt sql.NullString + completedAt sql.NullString + ) + err := r.db.QueryRowContext(ctx, ` + SELECT id, gallery_id, visitor_id, status, storage_key, error, created_at, updated_at, completed_at + FROM download_jobs + `+predicate+` + `, args...).Scan( + &job.ID, &job.GalleryID, &job.VisitorID, &job.Status, &storageKey, &jobError, + &createdAt, &updatedAt, &completedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return Job{}, ErrNotFound + } + if err != nil { + return Job{}, fmt.Errorf("find download job: %w", err) + } + job.StorageKey = storageKey.String + job.Error = jobError.String + job.CreatedAt = createdAt.String + job.UpdatedAt = updatedAt.String + job.CompletedAt = completedAt.String + return job, nil +} + +func (r *Repository) MarkProcessing(ctx context.Context, jobID uuid.UUID) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE download_jobs SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 + `, StatusProcessing, jobID) + return err +} + +func (r *Repository) MarkReady(ctx context.Context, jobID uuid.UUID, storageKey string) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE download_jobs SET status = $1, storage_key = $2, error = NULL, + updated_at = CURRENT_TIMESTAMP, completed_at = CURRENT_TIMESTAMP + WHERE id = $3 + `, StatusReady, storageKey, jobID) + return err +} + +func (r *Repository) MarkFailed(ctx context.Context, jobID uuid.UUID, message string) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE download_jobs SET status = $1, error = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + `, StatusFailed, message, jobID) + return err +} diff --git a/backend/internal/downloads/service.go b/backend/internal/downloads/service.go new file mode 100644 index 0000000..bf5b396 --- /dev/null +++ b/backend/internal/downloads/service.go @@ -0,0 +1,185 @@ +package downloads + +import ( + "archive/zip" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/example/sndit/backend/internal/media" + "github.com/example/sndit/backend/internal/storage" + "github.com/google/uuid" +) + +type Service struct { + repository *Repository + media *media.Repository + storage storage.Storage + jobs chan uuid.UUID + stop chan struct{} + waitGroup sync.WaitGroup +} + +var placeholderClient = &http.Client{Timeout: time.Minute} + +func NewService(repository *Repository, mediaRepository *media.Repository, objectStorage storage.Storage, workers int) *Service { + if workers < 1 { + workers = 1 + } + service := &Service{ + repository: repository, + media: mediaRepository, + storage: objectStorage, + jobs: make(chan uuid.UUID, 32), + stop: make(chan struct{}), + } + for index := 0; index < workers; index++ { + service.waitGroup.Add(1) + go service.worker() + } + return service +} + +func (s *Service) Create(ctx context.Context, galleryID uuid.UUID, visitorID string) (Job, error) { + job, err := s.repository.Create(ctx, galleryID, visitorID) + if err != nil { + return Job{}, err + } + select { + case s.jobs <- job.ID: + case <-s.stop: + return Job{}, fmt.Errorf("download service is stopping") + } + return job, nil +} + +func (s *Service) Get(ctx context.Context, jobID, galleryID uuid.UUID, visitorID string) (Job, error) { + return s.repository.GetForVisitor(ctx, jobID, galleryID, visitorID) +} + +func (s *Service) Close() { + close(s.stop) + s.waitGroup.Wait() +} + +func (s *Service) worker() { + defer s.waitGroup.Done() + for { + select { + case jobID := <-s.jobs: + s.process(jobID) + case <-s.stop: + return + } + } +} + +func (s *Service) process(jobID uuid.UUID) { + ctx := context.Background() + if err := s.repository.MarkProcessing(ctx, jobID); err != nil { + return + } + + var job Job + // The worker needs the gallery ID and visitor only for status storage. The + // job lookup below is intentionally not visitor-scoped because the ID is + // generated internally and never exposed before creation succeeds. + job, err := s.repository.Get(ctx, jobID) + if err != nil { + _ = s.repository.MarkFailed(ctx, jobID, err.Error()) + return + } + items, err := s.media.ListByGallery(ctx, job.GalleryID) + if err != nil { + _ = s.repository.MarkFailed(ctx, jobID, err.Error()) + return + } + + temporary, err := os.CreateTemp("", "gallery-download-*.zip") + if err != nil { + _ = s.repository.MarkFailed(ctx, jobID, err.Error()) + return + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + + archive := zip.NewWriter(temporary) + for _, item := range items { + if item.ProcessingStatus != media.StatusReady { + continue + } + object, err := s.openItem(ctx, item) + if err != nil { + _ = archive.Close() + _ = temporary.Close() + _ = s.repository.MarkFailed(ctx, jobID, err.Error()) + return + } + entry, err := archive.Create(filepath.Base(item.OriginalFilename)) + if err == nil { + _, err = io.Copy(entry, object) + } + _ = object.Close() + if err != nil { + _ = archive.Close() + _ = temporary.Close() + _ = s.repository.MarkFailed(ctx, jobID, err.Error()) + return + } + } + if err := archive.Close(); err != nil { + _ = temporary.Close() + _ = s.repository.MarkFailed(ctx, jobID, err.Error()) + return + } + if err := temporary.Close(); err != nil { + _ = s.repository.MarkFailed(ctx, jobID, err.Error()) + return + } + fileInfo, err := os.Stat(temporaryPath) + if err != nil { + _ = s.repository.MarkFailed(ctx, jobID, err.Error()) + return + } + key := fmt.Sprintf("galleries/%s/downloads/%s.zip", job.GalleryID, job.ID) + file, err := os.Open(temporaryPath) + if err != nil { + _ = s.repository.MarkFailed(ctx, jobID, err.Error()) + return + } + err = s.storage.Put(ctx, key, file, fileInfo.Size(), "application/zip") + _ = file.Close() + if err != nil { + _ = s.repository.MarkFailed(ctx, jobID, err.Error()) + return + } + _ = s.repository.MarkReady(ctx, jobID, key) +} + +func (s *Service) openItem(ctx context.Context, item media.Record) (io.ReadCloser, error) { + if item.ExternalURL != "" { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, item.ExternalURL, nil) + if err != nil { + return nil, err + } + response, err := placeholderClient.Do(request) + if err != nil { + return nil, err + } + if response.StatusCode >= http.StatusBadRequest { + _ = response.Body.Close() + return nil, fmt.Errorf("download placeholder returned %s", response.Status) + } + return response.Body, nil + } + if strings.TrimSpace(item.StorageKey) == "" { + return nil, fmt.Errorf("media has no storage object") + } + return s.storage.Get(ctx, item.StorageKey) +} diff --git a/backend/internal/galleries/handler.go b/backend/internal/galleries/handler.go new file mode 100644 index 0000000..268441b --- /dev/null +++ b/backend/internal/galleries/handler.go @@ -0,0 +1,677 @@ +package galleries + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/example/sndit/backend/internal/auth" + "github.com/example/sndit/backend/internal/media" + "github.com/example/sndit/backend/internal/storage" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +const signedURLDuration = time.Hour + +type Handler struct { + repository *Repository + media *media.Repository + storage storage.Storage + auth *auth.Service +} + +func NewHandler(repository *Repository, mediaRepository *media.Repository, objectStorage storage.Storage, authService *auth.Service) *Handler { + return &Handler{ + repository: repository, + media: mediaRepository, + storage: objectStorage, + auth: authService, + } +} + +func (h *Handler) RegisterProtectedRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) { + mux.Handle("GET /api/galleries", require(http.HandlerFunc(h.List))) + mux.Handle("POST /api/galleries", require(http.HandlerFunc(h.Create))) + mux.Handle("GET /api/galleries/{id}", require(http.HandlerFunc(h.Get))) + mux.Handle("PATCH /api/galleries/{id}", require(http.HandlerFunc(h.Update))) + mux.Handle("DELETE /api/galleries/{id}", require(http.HandlerFunc(h.Delete))) + mux.Handle("POST /api/galleries/{id}/publish", require(http.HandlerFunc(h.Publish))) + mux.Handle("POST /api/galleries/{id}/unpublish", require(http.HandlerFunc(h.Unpublish))) + mux.Handle("GET /api/galleries/{id}/preview", require(http.HandlerFunc(h.Preview))) +} + +func (h *Handler) RegisterPublicRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/public/galleries/{slug}", h.Public) + mux.HandleFunc("POST /api/public/galleries/{slug}/authenticate", h.AuthenticatePublic) + mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Favorite) + mux.HandleFunc("DELETE /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Unfavorite) +} + +type createRequest struct { + Title string `json:"title"` + ClientName string `json:"clientName"` + Description string `json:"description"` +} + +type updateRequest struct { + Title *string `json:"title"` + ClientName *string `json:"clientName"` + Description *string `json:"description"` + Password *string `json:"password"` + ClearPassword bool `json:"clearPassword"` + DownloadsEnabled *bool `json:"downloadsEnabled"` + FavoritesEnabled *bool `json:"favoritesEnabled"` + DownloadAllEnabled *bool `json:"downloadAllEnabled"` + WatermarkEnabled *bool `json:"watermarkEnabled"` + ExpiresAt *string `json:"expiresAt"` + CoverMediaID *string `json:"coverMediaId"` + ThemeConfig json.RawMessage `json:"themeConfig"` + BrandingConfig json.RawMessage `json:"brandingConfig"` +} + +type publicPasswordRequest struct { + Password string `json:"password"` +} + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + summaries, err := h.repository.ListForUser(r.Context(), user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not load galleries") + return + } + for index := range summaries { + if summaries[index].CoverMediaID == "" { + continue + } + coverID, err := uuid.Parse(summaries[index].CoverMediaID) + if err != nil { + continue + } + cover, err := h.media.GetForUser(r.Context(), user.ID, coverID) + if err != nil { + continue + } + summaries[index].CoverURL, _ = h.mediaURL(r.Context(), cover, false) + } + writeJSON(w, http.StatusOK, map[string]any{"galleries": summaries}) +} + +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + var request createRequest + if !decodeJSON(w, r, &request) { + return + } + request.Title = strings.TrimSpace(request.Title) + request.ClientName = strings.TrimSpace(request.ClientName) + if request.Title == "" || len(request.Title) > 180 || request.ClientName == "" || len(request.ClientName) > 180 { + writeError(w, http.StatusBadRequest, "title and client name are required") + return + } + + record, err := h.repository.Create(r.Context(), user.ID, newSlug(request.Title), request.Title, request.ClientName, strings.TrimSpace(request.Description)) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not create gallery") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"gallery": h.detail(record, nil)}) +} + +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + record, err := h.recordForUser(r, user.ID) + if err != nil { + writeGalleryError(w, err) + return + } + items, err := h.media.ListByGallery(r.Context(), record.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not load gallery media") + return + } + views, err := h.mediaViews(r.Context(), items, "", true) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not sign media URLs") + return + } + writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)}) +} + +func (h *Handler) Preview(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + record, err := h.recordForUser(r, user.ID) + if err != nil { + writeGalleryError(w, err) + return + } + gallery, err := h.publicPayload(r.Context(), r, record, true, true, "") + if err != nil { + writeError(w, http.StatusInternalServerError, "could not build gallery preview") + return + } + gallery.Preview = true + writeJSON(w, http.StatusOK, map[string]any{"gallery": gallery}) +} + +func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + current, err := h.recordForUser(r, user.ID) + if err != nil { + writeGalleryError(w, err) + return + } + var request updateRequest + if !decodeJSON(w, r, &request) { + return + } + + input := UpdateInput{ + Title: current.Title, + ClientName: current.ClientName, + Description: current.Description, + DownloadsEnabled: current.DownloadsEnabled, + FavoritesEnabled: current.FavoritesEnabled, + DownloadAllEnabled: current.DownloadAllEnabled, + WatermarkEnabled: current.WatermarkEnabled, + ExpiresAt: stringPointer(current.ExpiresAt), + CoverMediaID: stringPointer(current.CoverMediaID), + ThemeConfig: current.ThemeConfig, + BrandingConfig: current.BrandingConfig, + } + if current.PasswordHash != "" { + input.PasswordHash = ¤t.PasswordHash + } + if request.Title != nil { + input.Title = strings.TrimSpace(*request.Title) + } + if request.ClientName != nil { + input.ClientName = strings.TrimSpace(*request.ClientName) + } + if request.Description != nil { + input.Description = strings.TrimSpace(*request.Description) + } + if input.Title == "" || input.ClientName == "" || len(input.Title) > 180 || len(input.ClientName) > 180 { + writeError(w, http.StatusBadRequest, "title and client name are required") + return + } + if request.Password != nil { + if strings.TrimSpace(*request.Password) == "" { + input.ClearPassword = true + } else if len(*request.Password) < 4 { + writeError(w, http.StatusBadRequest, "gallery password must be at least 4 characters") + return + } else { + hash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not secure gallery password") + return + } + hashed := string(hash) + input.PasswordHash = &hashed + input.ClearPassword = false + } + } + if request.ClearPassword { + input.ClearPassword = true + input.PasswordHash = nil + } + if request.DownloadsEnabled != nil { + input.DownloadsEnabled = *request.DownloadsEnabled + } + if request.FavoritesEnabled != nil { + input.FavoritesEnabled = *request.FavoritesEnabled + } + if request.DownloadAllEnabled != nil { + input.DownloadAllEnabled = *request.DownloadAllEnabled + } + if request.WatermarkEnabled != nil { + input.WatermarkEnabled = *request.WatermarkEnabled + } + if request.ExpiresAt != nil { + value := strings.TrimSpace(*request.ExpiresAt) + if value != "" { + if _, err := time.Parse(time.RFC3339, value); err != nil { + writeError(w, http.StatusBadRequest, "expiry must be an ISO timestamp") + return + } + } + input.ExpiresAt = &value + } + if request.CoverMediaID != nil { + value := strings.TrimSpace(*request.CoverMediaID) + if value != "" { + coverID, err := uuid.Parse(value) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid cover media id") + return + } + cover, err := h.media.GetForUser(r.Context(), user.ID, coverID) + if err != nil || cover.GalleryID != current.ID { + writeError(w, http.StatusBadRequest, "cover media does not belong to this gallery") + return + } + } + input.CoverMediaID = &value + } + if len(request.ThemeConfig) > 0 { + if !json.Valid(request.ThemeConfig) { + writeError(w, http.StatusBadRequest, "theme config must be valid JSON") + return + } + input.ThemeConfig = request.ThemeConfig + } + if len(request.BrandingConfig) > 0 { + if !json.Valid(request.BrandingConfig) { + writeError(w, http.StatusBadRequest, "branding config must be valid JSON") + return + } + input.BrandingConfig = request.BrandingConfig + } + + record, err := h.repository.Update(r.Context(), user.ID, current.ID, input) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not update gallery") + return + } + items, err := h.media.ListByGallery(r.Context(), record.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not load gallery media") + return + } + views, err := h.mediaViews(r.Context(), items, "", true) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not sign media URLs") + return + } + writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)}) +} + +func (h *Handler) Publish(w http.ResponseWriter, r *http.Request) { + h.setStatus(w, r, StatusPublished) +} + +func (h *Handler) Unpublish(w http.ResponseWriter, r *http.Request) { + h.setStatus(w, r, StatusDraft) +} + +func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + record, err := h.recordForUser(r, user.ID) + if err != nil { + writeGalleryError(w, err) + return + } + record, err = h.repository.SetStatus(r.Context(), user.ID, record.ID, status) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not update gallery status") + return + } + writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, nil)}) +} + +func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + id, err := pathUUID(r, "id") + if err != nil { + writeGalleryError(w, err) + return + } + if err := h.repository.Delete(r.Context(), user.ID, id); err != nil { + writeGalleryError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) Public(w http.ResponseWriter, r *http.Request) { + slug := strings.TrimSpace(r.PathValue("slug")) + record, err := h.repository.GetPublicBySlug(r.Context(), slug) + if err != nil || record.IsExpired() { + writeError(w, http.StatusNotFound, "gallery not found") + return + } + if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) { + gallery := h.lockedPayload(record) + writeJSON(w, http.StatusOK, gallery) + return + } + visitorID := "" + if record.FavoritesEnabled { + visitorID = h.auth.EnsureVisitor(w, r) + } + gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not load gallery") + return + } + writeJSON(w, http.StatusOK, gallery) +} + +func (h *Handler) AuthenticatePublic(w http.ResponseWriter, r *http.Request) { + slug := strings.TrimSpace(r.PathValue("slug")) + record, err := h.repository.GetPublicBySlug(r.Context(), slug) + if err != nil || record.IsExpired() { + writeError(w, http.StatusNotFound, "gallery not found") + return + } + var request publicPasswordRequest + if !decodeJSON(w, r, &request) { + return + } + if record.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(record.PasswordHash), []byte(request.Password)) != nil { + writeError(w, http.StatusUnauthorized, "incorrect gallery password") + return + } + h.auth.GrantGalleryAccess(w, record.Slug) + visitorID := "" + if record.FavoritesEnabled { + visitorID = h.auth.EnsureVisitor(w, r) + } + gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not load gallery") + return + } + writeJSON(w, http.StatusOK, gallery) +} + +func (h *Handler) Favorite(w http.ResponseWriter, r *http.Request) { + h.setFavorite(w, r, true) +} + +func (h *Handler) Unfavorite(w http.ResponseWriter, r *http.Request) { + h.setFavorite(w, r, false) +} + +func (h *Handler) setFavorite(w http.ResponseWriter, r *http.Request, favorited bool) { + record, err := h.publicRecordForRequest(r) + if err != nil { + writeError(w, http.StatusNotFound, "gallery not found") + return + } + if !record.FavoritesEnabled { + writeError(w, http.StatusForbidden, "favorites are disabled") + return + } + mediaID, err := pathUUID(r, "mediaId") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid media id") + return + } + item, err := h.media.GetByID(r.Context(), mediaID) + if err != nil || item.GalleryID != record.ID { + writeError(w, http.StatusNotFound, "media not found") + return + } + visitorID := h.auth.EnsureVisitor(w, r) + if err := h.media.SetFavorite(r.Context(), record.ID, mediaID, visitorID, favorited); err != nil { + writeError(w, http.StatusInternalServerError, "could not update favorite") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"favorited": favorited}) +} + +func (h *Handler) publicRecordForRequest(r *http.Request) (GalleryRecord, error) { + record, err := h.repository.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug"))) + if err != nil || record.IsExpired() { + return GalleryRecord{}, ErrNotFound + } + if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) { + return GalleryRecord{}, ErrNotFound + } + return record, nil +} + +func (h *Handler) publicPayload(ctx context.Context, _ *http.Request, record GalleryRecord, preview, includeOriginal bool, visitorID string) (Public, error) { + items, err := h.media.ListByGallery(ctx, record.ID) + if err != nil { + return Public{}, err + } + views, err := h.mediaViews(ctx, items, visitorID, includeOriginal) + if err != nil { + return Public{}, err + } + gallery := Public{ + Slug: record.Slug, + Title: record.Title, + ClientName: record.ClientName, + Description: record.Description, + ThemeConfig: record.ThemeConfig, + BrandingConfig: record.BrandingConfig, + DownloadsEnabled: record.DownloadsEnabled, + FavoritesEnabled: record.FavoritesEnabled, + DownloadAllEnabled: record.DownloadAllEnabled, + WatermarkEnabled: record.WatermarkEnabled, + ExpiresAt: record.ExpiresAt, + Media: views, + } + for index := range items { + if record.CoverMediaID != "" && items[index].ID.String() == record.CoverMediaID { + gallery.Cover = &views[index] + break + } + } + if gallery.Cover == nil { + for index := range items { + if media.IsImage(items[index]) && views[index].PreviewURL != "" { + gallery.Cover = &views[index] + break + } + } + } + return gallery, nil +} + +func (h *Handler) lockedPayload(record GalleryRecord) map[string]any { + return map[string]any{ + "slug": record.Slug, + "title": record.Title, + "clientName": record.ClientName, + "description": record.Description, + "themeConfig": record.ThemeConfig, + "brandingConfig": record.BrandingConfig, + "requiresPassword": true, + "media": []media.Public{}, + } +} + +func (h *Handler) recordForUser(r *http.Request, userID uuid.UUID) (GalleryRecord, error) { + id, err := pathUUID(r, "id") + if err != nil { + return GalleryRecord{}, err + } + return h.repository.GetForUser(r.Context(), userID, id) +} + +func (h *Handler) detail(record GalleryRecord, items []media.Public) Detail { + if items == nil { + items = []media.Public{} + } + return Detail{ + ID: record.ID.String(), + Slug: record.Slug, + Title: record.Title, + ClientName: record.ClientName, + Description: record.Description, + Status: record.Status, + DownloadsEnabled: record.DownloadsEnabled, + FavoritesEnabled: record.FavoritesEnabled, + DownloadAllEnabled: record.DownloadAllEnabled, + WatermarkEnabled: record.WatermarkEnabled, + ExpiresAt: record.ExpiresAt, + CoverMediaID: record.CoverMediaID, + ThemeConfig: record.ThemeConfig, + BrandingConfig: record.BrandingConfig, + CreatedAt: record.CreatedAt, + PublishedAt: record.PublishedAt, + Media: items, + } +} + +func (h *Handler) mediaViews(ctx context.Context, items []media.Record, visitorID string, includeOriginal bool) ([]media.Public, error) { + views := make([]media.Public, 0, len(items)) + for _, item := range items { + view := media.Public{ + ID: item.ID, + OriginalFilename: item.OriginalFilename, + MimeType: item.MimeType, + FileSize: item.FileSize, + ProcessingStatus: item.ProcessingStatus, + Width: item.Width, + Height: item.Height, + DurationSeconds: item.DurationSeconds, + SortOrder: item.SortOrder, + } + if item.ProcessingStatus == media.StatusReady { + var err error + view.ThumbnailURL, err = h.mediaVariantURL(ctx, item, item.ThumbnailKey) + if err != nil { + return nil, err + } + view.PreviewURL, err = h.mediaURL(ctx, item, false) + if err != nil { + return nil, err + } + if includeOriginal { + view.OriginalURL, err = h.mediaURL(ctx, item, true) + if err != nil { + return nil, err + } + } + } + if visitorID != "" { + var err error + view.Favorited, err = h.media.IsFavorited(ctx, item.GalleryID, item.ID, visitorID) + if err != nil { + return nil, err + } + } + views = append(views, view) + } + return views, nil +} + +func (h *Handler) mediaURL(ctx context.Context, item media.Record, original bool) (string, error) { + if item.ExternalURL != "" { + return item.ExternalURL, nil + } + key := item.PreviewKey + if original { + key = item.StorageKey + } + return h.mediaVariantURL(ctx, item, key) +} + +func (h *Handler) mediaVariantURL(ctx context.Context, item media.Record, key string) (string, error) { + if item.ExternalURL != "" { + return item.ExternalURL, nil + } + if key == "" { + return "", fmt.Errorf("media object is not ready") + } + return h.storage.CreateDownloadURL(ctx, key, signedURLDuration) +} + +func newSlug(title string) string { + var builder strings.Builder + lastWasSeparator := true + for _, character := range strings.ToLower(title) { + if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') { + builder.WriteRune(character) + lastWasSeparator = false + continue + } + if builder.Len() > 0 && !lastWasSeparator { + builder.WriteByte('-') + lastWasSeparator = true + } + } + slug := strings.Trim(builder.String(), "-") + if slug == "" { + slug = "gallery" + } + suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:8] + return slug + "-" + suffix +} + +func pathUUID(r *http.Request, name string) (uuid.UUID, error) { + id, err := uuid.Parse(r.PathValue(name)) + if err != nil { + return uuid.Nil, fmt.Errorf("invalid %s", name) + } + return id, nil +} + +func stringPointer(value string) *string { + if value == "" { + return nil + } + copy := value + return © +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json") + return false + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return false + } + return true +} + +func writeGalleryError(w http.ResponseWriter, err error) { + if errors.Is(err, ErrNotFound) || strings.HasPrefix(err.Error(), "invalid ") { + writeError(w, http.StatusNotFound, "gallery not found") + return + } + writeError(w, http.StatusInternalServerError, "could not load gallery") +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/backend/internal/galleries/handler_sqlite_test.go b/backend/internal/galleries/handler_sqlite_test.go new file mode 100644 index 0000000..c5804cc --- /dev/null +++ b/backend/internal/galleries/handler_sqlite_test.go @@ -0,0 +1,90 @@ +package galleries + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/example/sndit/backend/internal/auth" + appdb "github.com/example/sndit/backend/internal/db" + "github.com/example/sndit/backend/internal/media" + "github.com/google/uuid" +) + +func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) { + ctx := context.Background() + database, err := appdb.New(ctx, "sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite database: %v", err) + } + defer database.Close() + + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("find test source file") + } + migrationPath := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite", "003_gallery_platform.sql") + migration, err := os.ReadFile(migrationPath) + if err != nil { + t.Fatalf("read gallery migration: %v", err) + } + if _, err := database.ExecContext(ctx, string(migration)); err != nil { + t.Fatalf("apply gallery migration: %v", err) + } + + userID := uuid.MustParse("55555555-5555-4555-8555-555555555555") + galleryID := uuid.MustParse("66666666-6666-4666-8666-666666666666") + mediaID := uuid.MustParse("77777777-7777-4777-8777-777777777777") + if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil { + t.Fatalf("insert user: %v", err) + } + if _, err := database.ExecContext(ctx, ` + INSERT INTO galleries (id, user_id, slug, title, client_name, description, status, cover_media_id, branding_config) + VALUES ($1, $2, $3, $4, $5, $6, 'published', $7, $8) + `, galleryID, userID, "demo-gallery", "Emma & James", "Emma & James", "A day worth keeping.", mediaID, `{"studioName":"Northline Studio"}`); err != nil { + t.Fatalf("insert gallery: %v", err) + } + if _, err := database.ExecContext(ctx, ` + INSERT INTO media (id, gallery_id, original_filename, mime_type, storage_key, external_url, processing_status, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, 'READY', 0) + `, mediaID, galleryID, "one.jpg", "image/jpeg", "demo/one.jpg", "https://example.com/one.jpg"); err != nil { + t.Fatalf("insert media: %v", err) + } + + authService, err := auth.NewService(auth.NewRepository(database), "test-gallery-secret-that-is-long-enough", false) + if err != nil { + t.Fatalf("create auth service: %v", err) + } + handler := NewHandler(NewRepository(database), media.NewRepository(database), nil, authService) + mux := http.NewServeMux() + handler.RegisterPublicRoutes(mux) + + getRequest := httptest.NewRequest(http.MethodGet, "/api/public/galleries/demo-gallery", nil) + getRecorder := httptest.NewRecorder() + mux.ServeHTTP(getRecorder, getRequest) + if getRecorder.Code != http.StatusOK { + t.Fatalf("expected public gallery 200, got %d: %s", getRecorder.Code, getRecorder.Body.String()) + } + var gallery Public + if err := json.NewDecoder(getRecorder.Body).Decode(&gallery); err != nil { + t.Fatalf("decode public gallery: %v", err) + } + if gallery.Title != "Emma & James" || len(gallery.Media) != 1 || gallery.Media[0].PreviewURL != "https://example.com/one.jpg" { + t.Fatalf("unexpected public gallery: %+v", gallery) + } + + favoriteRequest := httptest.NewRequest(http.MethodPost, "/api/public/galleries/demo-gallery/media/77777777-7777-4777-8777-777777777777/favorite", nil) + for _, cookie := range getRecorder.Result().Cookies() { + favoriteRequest.AddCookie(cookie) + } + favoriteRecorder := httptest.NewRecorder() + mux.ServeHTTP(favoriteRecorder, favoriteRequest) + if favoriteRecorder.Code != http.StatusOK { + t.Fatalf("expected favorite 200, got %d: %s", favoriteRecorder.Code, favoriteRecorder.Body.String()) + } +} diff --git a/backend/internal/galleries/models.go b/backend/internal/galleries/models.go new file mode 100644 index 0000000..58961db --- /dev/null +++ b/backend/internal/galleries/models.go @@ -0,0 +1,125 @@ +package galleries + +import ( + "encoding/json" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + StatusDraft = "draft" + StatusPublished = "published" + StatusArchived = "archived" +) + +type GalleryRecord struct { + ID uuid.UUID + UserID uuid.UUID + Slug string + Title string + ClientName string + Description string + Status string + PasswordHash string + DownloadsEnabled bool + FavoritesEnabled bool + DownloadAllEnabled bool + WatermarkEnabled bool + ExpiresAt string + CoverMediaID string + ThemeConfig json.RawMessage + BrandingConfig json.RawMessage + CreatedAt string + UpdatedAt string + PublishedAt string +} + +type Summary struct { + ID uuid.UUID `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + ClientName string `json:"clientName"` + Description string `json:"description"` + Status string `json:"status"` + DownloadsEnabled bool `json:"downloadsEnabled"` + FavoritesEnabled bool `json:"favoritesEnabled"` + DownloadAllEnabled bool `json:"downloadAllEnabled"` + WatermarkEnabled bool `json:"watermarkEnabled"` + ExpiresAt string `json:"expiresAt,omitempty"` + CoverMediaID string `json:"coverMediaId,omitempty"` + CoverURL string `json:"coverUrl,omitempty"` + ThemeConfig json.RawMessage `json:"themeConfig"` + BrandingConfig json.RawMessage `json:"brandingConfig"` + CreatedAt string `json:"createdAt"` + PublishedAt string `json:"publishedAt,omitempty"` + PhotoCount int `json:"photoCount"` + VideoCount int `json:"videoCount"` + TotalBytes int64 `json:"totalBytes"` +} + +type UpdateInput struct { + Title string + ClientName string + Description string + PasswordHash *string + ClearPassword bool + DownloadsEnabled bool + FavoritesEnabled bool + DownloadAllEnabled bool + WatermarkEnabled bool + ExpiresAt *string + CoverMediaID *string + ThemeConfig json.RawMessage + BrandingConfig json.RawMessage +} + +func (g GalleryRecord) IsExpired() bool { + if strings.TrimSpace(g.ExpiresAt) == "" { + return false + } + value := strings.TrimSpace(g.ExpiresAt) + layouts := []string{ + time.RFC3339Nano, + "2006-01-02 15:04:05-07:00", + "2006-01-02 15:04:05-07", + "2006-01-02 15:04:05", + } + var parsed time.Time + var err error + for _, layout := range layouts { + parsed, err = time.Parse(layout, value) + if err == nil { + break + } + } + if err != nil { + return false + } + return !time.Now().Before(parsed) +} + +func (g GalleryRecord) ToSummary(photoCount, videoCount int, totalBytes int64) Summary { + return Summary{ + ID: g.ID, + Slug: g.Slug, + Title: g.Title, + ClientName: g.ClientName, + Description: g.Description, + Status: g.Status, + DownloadsEnabled: g.DownloadsEnabled, + FavoritesEnabled: g.FavoritesEnabled, + DownloadAllEnabled: g.DownloadAllEnabled, + WatermarkEnabled: g.WatermarkEnabled, + ExpiresAt: g.ExpiresAt, + CoverMediaID: g.CoverMediaID, + ThemeConfig: g.ThemeConfig, + BrandingConfig: g.BrandingConfig, + CreatedAt: g.CreatedAt, + PublishedAt: g.PublishedAt, + PhotoCount: photoCount, + VideoCount: videoCount, + TotalBytes: totalBytes, + } +} diff --git a/backend/internal/galleries/repository.go b/backend/internal/galleries/repository.go new file mode 100644 index 0000000..5519c4a --- /dev/null +++ b/backend/internal/galleries/repository.go @@ -0,0 +1,230 @@ +package galleries + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" +) + +type Repository struct { + db *sql.DB +} + +func NewRepository(db *sql.DB) *Repository { + return &Repository{db: db} +} + +var ErrNotFound = errors.New("gallery not found") + +func (r *Repository) Create(ctx context.Context, userID uuid.UUID, slug, title, clientName, description string) (GalleryRecord, error) { + id := uuid.New() + _, err := r.db.ExecContext(ctx, ` + INSERT INTO galleries (id, user_id, slug, title, client_name, description, status, theme_config, branding_config) + VALUES ($1, $2, $3, $4, $5, $6, 'draft', '{}', '{}') + `, id, userID, slug, title, clientName, description) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + return GalleryRecord{}, fmt.Errorf("gallery slug already exists: %w", err) + } + return GalleryRecord{}, fmt.Errorf("create gallery: %w", err) + } + return r.GetForUser(ctx, userID, id) +} + +func (r *Repository) ListForUser(ctx context.Context, userID uuid.UUID) ([]Summary, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT + g.id, g.slug, g.title, g.client_name, g.description, g.status, + g.downloads_enabled, g.favorites_enabled, g.download_all_enabled, + g.watermark_enabled, g.expires_at, g.cover_media_id, + g.theme_config, g.branding_config, g.created_at, g.published_at, + COUNT(CASE WHEN m.mime_type LIKE 'image/%' THEN 1 END), + COUNT(CASE WHEN m.mime_type LIKE 'video/%' THEN 1 END), + COALESCE(SUM(m.file_size), 0) + FROM galleries g + LEFT JOIN media m ON m.gallery_id = g.id + WHERE g.user_id = $1 AND g.status <> 'archived' + GROUP BY g.id + ORDER BY g.created_at DESC + `, userID) + if err != nil { + return nil, fmt.Errorf("list galleries: %w", err) + } + defer rows.Close() + + result := make([]Summary, 0) + for rows.Next() { + var ( + item Summary + expiresAt, coverMediaID, createdAt sql.NullString + publishedAt sql.NullString + themeConfig, brandingConfig []byte + ) + if err := rows.Scan( + &item.ID, &item.Slug, &item.Title, &item.ClientName, &item.Description, &item.Status, + &item.DownloadsEnabled, &item.FavoritesEnabled, &item.DownloadAllEnabled, + &item.WatermarkEnabled, &expiresAt, &coverMediaID, &themeConfig, &brandingConfig, + &createdAt, &publishedAt, &item.PhotoCount, &item.VideoCount, &item.TotalBytes, + ); err != nil { + return nil, fmt.Errorf("scan gallery summary: %w", err) + } + item.ExpiresAt = expiresAt.String + item.CoverMediaID = coverMediaID.String + item.ThemeConfig = nonEmptyJSON(themeConfig) + item.BrandingConfig = nonEmptyJSON(brandingConfig) + item.CreatedAt = createdAt.String + item.PublishedAt = publishedAt.String + result = append(result, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate galleries: %w", err) + } + return result, nil +} + +func (r *Repository) GetForUser(ctx context.Context, userID, galleryID uuid.UUID) (GalleryRecord, error) { + return r.get(ctx, ` + SELECT id, user_id, slug, title, client_name, description, status, password_hash, + downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled, + expires_at, cover_media_id, theme_config, branding_config, created_at, updated_at, published_at + FROM galleries + WHERE id = $1 AND user_id = $2 + `, galleryID, userID) +} + +func (r *Repository) GetPublicBySlug(ctx context.Context, slug string) (GalleryRecord, error) { + return r.get(ctx, ` + SELECT id, user_id, slug, title, client_name, description, status, password_hash, + downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled, + expires_at, cover_media_id, theme_config, branding_config, created_at, updated_at, published_at + FROM galleries + WHERE slug = $1 AND status = 'published' + `, slug) +} + +func (r *Repository) Update(ctx context.Context, userID, galleryID uuid.UUID, input UpdateInput) (GalleryRecord, error) { + var passwordValue any + if input.ClearPassword { + passwordValue = nil + } else if input.PasswordHash != nil { + passwordValue = *input.PasswordHash + } else { + current, err := r.GetForUser(ctx, userID, galleryID) + if err != nil { + return GalleryRecord{}, err + } + passwordValue = current.PasswordHash + } + + var expiresValue any + if input.ExpiresAt != nil && strings.TrimSpace(*input.ExpiresAt) != "" { + expiresValue = *input.ExpiresAt + } + var coverValue any + if input.CoverMediaID != nil && strings.TrimSpace(*input.CoverMediaID) != "" { + coverID, err := uuid.Parse(strings.TrimSpace(*input.CoverMediaID)) + if err != nil { + return GalleryRecord{}, fmt.Errorf("parse cover media id: %w", err) + } + coverValue = coverID + } + + _, err := r.db.ExecContext(ctx, ` + UPDATE galleries + SET title = $1, client_name = $2, description = $3, + password_hash = $4, downloads_enabled = $5, favorites_enabled = $6, + download_all_enabled = $7, watermark_enabled = $8, expires_at = $9, + cover_media_id = $10, theme_config = $11, branding_config = $12, + updated_at = CURRENT_TIMESTAMP + WHERE id = $13 AND user_id = $14 + `, input.Title, input.ClientName, input.Description, passwordValue, input.DownloadsEnabled, + input.FavoritesEnabled, input.DownloadAllEnabled, input.WatermarkEnabled, expiresValue, + coverValue, jsonValue(input.ThemeConfig), jsonValue(input.BrandingConfig), galleryID, userID) + if err != nil { + return GalleryRecord{}, fmt.Errorf("update gallery: %w", err) + } + return r.GetForUser(ctx, userID, galleryID) +} + +func (r *Repository) SetStatus(ctx context.Context, userID, galleryID uuid.UUID, status string) (GalleryRecord, error) { + var query string + if status == StatusPublished { + query = `UPDATE galleries SET status = $1, published_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3` + } else { + query = `UPDATE galleries SET status = $1, published_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3` + } + _, err := r.db.ExecContext(ctx, query, status, galleryID, userID) + if err != nil { + return GalleryRecord{}, fmt.Errorf("set gallery status: %w", err) + } + return r.GetForUser(ctx, userID, galleryID) +} + +func (r *Repository) Delete(ctx context.Context, userID, galleryID uuid.UUID) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM galleries WHERE id = $1 AND user_id = $2`, galleryID, userID) + if err != nil { + return fmt.Errorf("delete gallery: %w", err) + } + count, err := result.RowsAffected() + if err != nil || count == 0 { + return ErrNotFound + } + return nil +} + +type rowScanner interface { + Scan(...any) error +} + +func (r *Repository) get(ctx context.Context, query string, args ...any) (GalleryRecord, error) { + return scanGallery(r.db.QueryRowContext(ctx, query, args...)) +} + +func scanGallery(row rowScanner) (GalleryRecord, error) { + var ( + gallery GalleryRecord + passwordHash, expiresAt, coverMediaID sql.NullString + themeConfig, brandingConfig []byte + createdAt, updatedAt, publishedAt sql.NullString + ) + err := row.Scan( + &gallery.ID, &gallery.UserID, &gallery.Slug, &gallery.Title, &gallery.ClientName, + &gallery.Description, &gallery.Status, &passwordHash, &gallery.DownloadsEnabled, + &gallery.FavoritesEnabled, &gallery.DownloadAllEnabled, &gallery.WatermarkEnabled, + &expiresAt, &coverMediaID, &themeConfig, &brandingConfig, &createdAt, &updatedAt, &publishedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return GalleryRecord{}, ErrNotFound + } + if err != nil { + return GalleryRecord{}, fmt.Errorf("scan gallery: %w", err) + } + gallery.PasswordHash = passwordHash.String + gallery.ExpiresAt = expiresAt.String + gallery.CoverMediaID = coverMediaID.String + gallery.ThemeConfig = nonEmptyJSON(themeConfig) + gallery.BrandingConfig = nonEmptyJSON(brandingConfig) + gallery.CreatedAt = createdAt.String + gallery.UpdatedAt = updatedAt.String + gallery.PublishedAt = publishedAt.String + return gallery, nil +} + +func nonEmptyJSON(value []byte) json.RawMessage { + if len(value) == 0 { + return json.RawMessage(`{}`) + } + return json.RawMessage(value) +} + +func jsonValue(value json.RawMessage) string { + if len(value) == 0 { + return `{}` + } + return string(value) +} diff --git a/backend/internal/galleries/repository_sqlite_test.go b/backend/internal/galleries/repository_sqlite_test.go new file mode 100644 index 0000000..48ee3ac --- /dev/null +++ b/backend/internal/galleries/repository_sqlite_test.go @@ -0,0 +1,87 @@ +package galleries + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + appdb "github.com/example/sndit/backend/internal/db" + "github.com/google/uuid" +) + +func TestRepositoryHandlesSQLiteGallerySchema(t *testing.T) { + ctx := context.Background() + database, err := appdb.New(ctx, "sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite database: %v", err) + } + defer database.Close() + + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("find test source file") + } + migrationPath := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite", "003_gallery_platform.sql") + migration, err := os.ReadFile(migrationPath) + if err != nil { + t.Fatalf("read gallery migration: %v", err) + } + if _, err := database.ExecContext(ctx, string(migration)); err != nil { + t.Fatalf("apply gallery migration: %v", err) + } + + userID := uuid.MustParse("55555555-5555-4555-8555-555555555555") + if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil { + t.Fatalf("insert user: %v", err) + } + repository := NewRepository(database) + record, err := repository.Create(ctx, userID, "emma-james-wedding-12345678", "Emma & James", "Emma & James", "A summer wedding.") + if err != nil { + t.Fatalf("create gallery: %v", err) + } + if record.Status != StatusDraft || record.Title != "Emma & James" { + t.Fatalf("unexpected gallery: %+v", record) + } + + mediaID := uuid.MustParse("77777777-7777-4777-8777-777777777777") + if _, err := database.ExecContext(ctx, ` + INSERT INTO media (id, gallery_id, original_filename, mime_type, file_size, storage_key, processing_status, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, 'READY', 0) + `, mediaID, record.ID, "one.jpg", "image/jpeg", 4096, "gallery/one.jpg"); err != nil { + t.Fatalf("insert media: %v", err) + } + + updated, err := repository.Update(ctx, userID, record.ID, UpdateInput{ + Title: record.Title, + ClientName: record.ClientName, + Description: record.Description, + DownloadsEnabled: true, + FavoritesEnabled: true, + DownloadAllEnabled: true, + ThemeConfig: json.RawMessage(`{"mode":"dark"}`), + BrandingConfig: json.RawMessage(`{"studioName":"Northline"}`), + }) + if err != nil { + t.Fatalf("update gallery: %v", err) + } + if string(updated.ThemeConfig) != `{"mode":"dark"}` { + t.Fatalf("theme config was not persisted: %s", updated.ThemeConfig) + } + + summaries, err := repository.ListForUser(ctx, userID) + if err != nil { + t.Fatalf("list galleries: %v", err) + } + if len(summaries) != 1 || summaries[0].PhotoCount != 1 || summaries[0].TotalBytes != 4096 { + t.Fatalf("unexpected summary: %+v", summaries) + } + if _, err := repository.SetStatus(ctx, userID, record.ID, StatusPublished); err != nil { + t.Fatalf("publish gallery: %v", err) + } + if _, err := repository.GetPublicBySlug(ctx, record.Slug); err != nil { + t.Fatalf("public gallery lookup: %v", err) + } +} diff --git a/backend/internal/galleries/views.go b/backend/internal/galleries/views.go new file mode 100644 index 0000000..3b461e7 --- /dev/null +++ b/backend/internal/galleries/views.go @@ -0,0 +1,45 @@ +package galleries + +import ( + "encoding/json" + + "github.com/example/sndit/backend/internal/media" +) + +type Detail struct { + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + ClientName string `json:"clientName"` + Description string `json:"description"` + Status string `json:"status"` + DownloadsEnabled bool `json:"downloadsEnabled"` + FavoritesEnabled bool `json:"favoritesEnabled"` + DownloadAllEnabled bool `json:"downloadAllEnabled"` + WatermarkEnabled bool `json:"watermarkEnabled"` + ExpiresAt string `json:"expiresAt,omitempty"` + CoverMediaID string `json:"coverMediaId,omitempty"` + ThemeConfig json.RawMessage `json:"themeConfig"` + BrandingConfig json.RawMessage `json:"brandingConfig"` + CreatedAt string `json:"createdAt"` + PublishedAt string `json:"publishedAt,omitempty"` + Media []media.Public `json:"media"` +} + +type Public struct { + Slug string `json:"slug"` + Title string `json:"title"` + ClientName string `json:"clientName"` + Description string `json:"description"` + ThemeConfig json.RawMessage `json:"themeConfig"` + BrandingConfig json.RawMessage `json:"brandingConfig"` + DownloadsEnabled bool `json:"downloadsEnabled"` + FavoritesEnabled bool `json:"favoritesEnabled"` + DownloadAllEnabled bool `json:"downloadAllEnabled"` + WatermarkEnabled bool `json:"watermarkEnabled"` + ExpiresAt string `json:"expiresAt,omitempty"` + Preview bool `json:"preview,omitempty"` + RequiresPassword bool `json:"requiresPassword"` + Cover *media.Public `json:"cover,omitempty"` + Media []media.Public `json:"media"` +} diff --git a/backend/internal/gifts/handler.go b/backend/internal/gifts/handler.go new file mode 100644 index 0000000..3cfe3fd --- /dev/null +++ b/backend/internal/gifts/handler.go @@ -0,0 +1,73 @@ +package gifts + +import ( + "encoding/json" + "errors" + "net/http" + "strings" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /health", h.Health) + mux.HandleFunc("GET /api/gifts/{slug}", h.GetGift) + return mux +} + +func (h *Handler) Health(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *Handler) GetGift(w http.ResponseWriter, r *http.Request) { + slug := strings.TrimSpace(r.PathValue("slug")) + if !validSlug(slug) { + writeError(w, http.StatusBadRequest, "invalid gift slug") + return + } + + gift, err := h.service.GetPublicGift(r.Context(), slug) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "gift not found") + return + } + writeError(w, http.StatusInternalServerError, "could not load gift") + return + } + + writeJSON(w, http.StatusOK, gift) +} + +func validSlug(slug string) bool { + if len(slug) == 0 || len(slug) > 100 { + return false + } + for index, character := range slug { + if (character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || + (character == '-' && index > 0 && index < len(slug)-1) { + continue + } + return false + } + return true +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/backend/internal/gifts/handler_test.go b/backend/internal/gifts/handler_test.go new file mode 100644 index 0000000..13582db --- /dev/null +++ b/backend/internal/gifts/handler_test.go @@ -0,0 +1,125 @@ +package gifts + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" +) + +type fakeStore struct { + gift PublicGift + err error +} + +func (f fakeStore) GetPublicBySlug(context.Context, string) (PublicGift, error) { + return f.gift, f.err +} + +func TestGetGiftReturnsPublicRepresentation(t *testing.T) { + giftID := uuid.MustParse("11111111-1111-4111-8111-111111111111") + itemID := uuid.MustParse("22222222-2222-4222-8222-222222222222") + service := NewService(fakeStore{gift: PublicGift{ + ID: giftID, + Slug: "demo", + RecipientName: "Anna", + SenderName: "Alex", + Title: "A little surprise for you", + IntroMessage: "I made something for you.", + RevealMessage: "A beautiful final note.", + Items: []PublicGiftItem{{ + ID: itemID, + Type: "text", + Title: "A note", + Text: "Hello", + SortOrder: 1, + }}, + }}) + + request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil) + recorder := httptest.NewRecorder() + NewHandler(service).Routes().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", recorder.Code) + } + if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" { + t.Fatalf("unexpected content type: %q", contentType) + } + + var response PublicGift + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.RecipientName != "Anna" || len(response.Items) != 1 { + t.Fatalf("unexpected response: %+v", response) + } +} + +func TestGetGiftReturnsNotFound(t *testing.T) { + service := NewService(fakeStore{err: ErrNotFound}) + request := httptest.NewRequest(http.MethodGet, "/api/gifts/missing", nil) + recorder := httptest.NewRecorder() + + NewHandler(service).Routes().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", recorder.Code) + } + if body := recorder.Body.String(); body != "{\"error\":\"gift not found\"}\n" { + t.Fatalf("unexpected error body: %q", body) + } +} + +func TestGetGiftHidesStoreErrors(t *testing.T) { + service := NewService(fakeStore{err: errors.New("database connection lost")}) + request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil) + recorder := httptest.NewRecorder() + + NewHandler(service).Routes().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", recorder.Code) + } + if body := recorder.Body.String(); body != "{\"error\":\"could not load gift\"}\n" { + t.Fatalf("unexpected error body: %q", body) + } +} + +func TestGetGiftRejectsInvalidSlug(t *testing.T) { + service := NewService(fakeStore{}) + request := httptest.NewRequest(http.MethodGet, "/api/gifts/not%20a%20slug", nil) + recorder := httptest.NewRecorder() + + NewHandler(service).Routes().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", recorder.Code) + } +} + +func TestHealthReturnsOK(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/health", nil) + + NewHandler(NewService(fakeStore{})).Routes().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", recorder.Code) + } + if body := recorder.Body.String(); body != "{\"status\":\"ok\"}\n" { + t.Fatalf("unexpected health body: %q", body) + } +} + +func TestServiceRejectsEmptySlug(t *testing.T) { + service := NewService(fakeStore{}) + _, err := service.GetPublicGift(context.Background(), "") + if err != ErrNotFound { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} diff --git a/backend/internal/gifts/models.go b/backend/internal/gifts/models.go new file mode 100644 index 0000000..c8d07b4 --- /dev/null +++ b/backend/internal/gifts/models.go @@ -0,0 +1,28 @@ +package gifts + +import ( + "encoding/json" + + "github.com/google/uuid" +) + +type PublicGift struct { + ID uuid.UUID `json:"id"` + Slug string `json:"slug"` + RecipientName string `json:"recipientName"` + SenderName string `json:"senderName"` + Title string `json:"title"` + IntroMessage string `json:"introMessage"` + RevealMessage string `json:"revealMessage"` + Items []PublicGiftItem `json:"items"` +} + +type PublicGiftItem struct { + ID uuid.UUID `json:"id"` + Type string `json:"type"` + Title string `json:"title,omitempty"` + Text string `json:"text,omitempty"` + MediaURL string `json:"mediaUrl,omitempty"` + SortOrder int `json:"sortOrder"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} diff --git a/backend/internal/gifts/repository.go b/backend/internal/gifts/repository.go new file mode 100644 index 0000000..52879ad --- /dev/null +++ b/backend/internal/gifts/repository.go @@ -0,0 +1,97 @@ +package gifts + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +type Repository struct { + db *sql.DB +} + +func NewRepository(db *sql.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) GetPublicBySlug(ctx context.Context, slug string) (PublicGift, error) { + var gift PublicGift + err := r.db.QueryRowContext(ctx, ` + SELECT id, slug, recipient_name, sender_name, title, intro_message, reveal_message + FROM gifts + WHERE slug = $1 AND status = 'published' + `, slug).Scan( + &gift.ID, + &gift.Slug, + &gift.RecipientName, + &gift.SenderName, + &gift.Title, + &gift.IntroMessage, + &gift.RevealMessage, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return PublicGift{}, ErrNotFound + } + return PublicGift{}, fmt.Errorf("find gift: %w", err) + } + + rows, err := r.db.QueryContext(ctx, ` + SELECT id, type, title, text, media_url, sort_order, metadata + FROM gift_items + WHERE gift_id = $1 + ORDER BY sort_order ASC, id ASC + `, gift.ID) + if err != nil { + return PublicGift{}, fmt.Errorf("find gift items: %w", err) + } + defer rows.Close() + + gift.Items = make([]PublicGiftItem, 0) + for rows.Next() { + var ( + item PublicGiftItem + title sql.NullString + text sql.NullString + mediaURL sql.NullString + metadata []byte + ) + + if err := rows.Scan( + &item.ID, + &item.Type, + &title, + &text, + &mediaURL, + &item.SortOrder, + &metadata, + ); err != nil { + return PublicGift{}, fmt.Errorf("scan gift item: %w", err) + } + + item.Title = title.String + item.Text = text.String + item.MediaURL = mediaURL.String + if len(metadata) == 0 { + item.Metadata = []byte(`{}`) + } else { + item.Metadata = metadata + } + gift.Items = append(gift.Items, item) + } + + if err := rows.Err(); err != nil { + return PublicGift{}, fmt.Errorf("iterate gift items: %w", err) + } + + return gift, nil +} + +// Store is the read contract used by the service. Keeping it small makes the +// HTTP layer straightforward to test without a running database. +type Store interface { + GetPublicBySlug(context.Context, string) (PublicGift, error) +} + +var _ Store = (*Repository)(nil) diff --git a/backend/internal/gifts/repository_sqlite_test.go b/backend/internal/gifts/repository_sqlite_test.go new file mode 100644 index 0000000..352e276 --- /dev/null +++ b/backend/internal/gifts/repository_sqlite_test.go @@ -0,0 +1,57 @@ +package gifts + +import ( + "context" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "testing" + + appdb "github.com/example/sndit/backend/internal/db" +) + +func TestRepositoryReadsSQLiteMigrations(t *testing.T) { + ctx := context.Background() + database, err := appdb.New(ctx, "sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite database: %v", err) + } + defer database.Close() + + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("find test source file") + } + migrationDirectory := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite") + entries, err := os.ReadDir(migrationDirectory) + if err != nil { + t.Fatalf("read sqlite migrations: %v", err) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + migration, err := os.ReadFile(filepath.Join(migrationDirectory, entry.Name())) + if err != nil { + t.Fatalf("read migration %s: %v", entry.Name(), err) + } + if _, err := database.ExecContext(ctx, string(migration)); err != nil { + t.Fatalf("apply migration %s: %v", entry.Name(), err) + } + } + + gift, err := NewRepository(database).GetPublicBySlug(ctx, "demo") + if err != nil { + t.Fatalf("load demo gift: %v", err) + } + if gift.RecipientName != "Anna" || gift.SenderName != "Alex" { + t.Fatalf("unexpected gift: %+v", gift) + } + if len(gift.Items) != 3 || gift.Items[0].Type != "image" || gift.Items[1].SortOrder != 2 { + t.Fatalf("unexpected gift items: %+v", gift.Items) + } +} diff --git a/backend/internal/gifts/service.go b/backend/internal/gifts/service.go new file mode 100644 index 0000000..74fdca5 --- /dev/null +++ b/backend/internal/gifts/service.go @@ -0,0 +1,33 @@ +package gifts + +import ( + "context" + "errors" + "fmt" +) + +var ErrNotFound = errors.New("gift not found") + +type Service struct { + store Store +} + +func NewService(store Store) *Service { + return &Service{store: store} +} + +func (s *Service) GetPublicGift(ctx context.Context, slug string) (PublicGift, error) { + if slug == "" { + return PublicGift{}, ErrNotFound + } + + gift, err := s.store.GetPublicBySlug(ctx, slug) + if err != nil { + if errors.Is(err, ErrNotFound) { + return PublicGift{}, ErrNotFound + } + return PublicGift{}, fmt.Errorf("get public gift: %w", err) + } + + return gift, nil +} diff --git a/backend/internal/media/handler.go b/backend/internal/media/handler.go new file mode 100644 index 0000000..2cb1f76 --- /dev/null +++ b/backend/internal/media/handler.go @@ -0,0 +1,355 @@ +package media + +import ( + "context" + "encoding/json" + "fmt" + "mime" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/example/sndit/backend/internal/auth" + "github.com/example/sndit/backend/internal/storage" + "github.com/google/uuid" +) + +const ( + maxUploadSize = int64(10 << 30) + uploadURLDuration = 24 * time.Hour +) + +type ProcessorQueue interface { + Enqueue(uuid.UUID) +} + +type Handler struct { + repository *Repository + storage storage.Storage + processor ProcessorQueue + auth *auth.Service +} + +func NewHandler(repository *Repository, objectStorage storage.Storage, processor ProcessorQueue, authService *auth.Service) *Handler { + return &Handler{repository: repository, storage: objectStorage, processor: processor, auth: authService} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) { + mux.Handle("GET /api/galleries/{id}/media", require(http.HandlerFunc(h.List))) + mux.Handle("POST /api/galleries/{id}/uploads", require(http.HandlerFunc(h.CreateUpload))) + mux.Handle("POST /api/uploads/{id}/complete", require(http.HandlerFunc(h.CompleteUpload))) + mux.Handle("PATCH /api/media/{id}", require(http.HandlerFunc(h.Update))) + mux.Handle("POST /api/media/{id}/download", require(http.HandlerFunc(h.Download))) + mux.Handle("DELETE /api/uploads/{id}", require(http.HandlerFunc(h.Delete))) + mux.Handle("DELETE /api/media/{id}", require(http.HandlerFunc(h.Delete))) +} + +type uploadRequest struct { + Filename string `json:"filename"` + MimeType string `json:"mimeType"` + FileSize int64 `json:"fileSize"` +} + +type updateRequest struct { + SortOrder *int `json:"sortOrder"` +} + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + galleryID, err := parseID(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid gallery id") + return + } + belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID) + if err != nil || !belongs { + writeError(w, http.StatusNotFound, "gallery not found") + return + } + items, err := h.repository.ListByGallery(r.Context(), galleryID) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not load media") + return + } + views := make([]Public, 0, len(items)) + for _, item := range items { + view, err := h.view(r.Context(), item, true) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not sign media URLs") + return + } + views = append(views, view) + } + writeJSON(w, http.StatusOK, map[string]any{"media": views}) +} + +func (h *Handler) CreateUpload(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + galleryID, err := parseID(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid gallery id") + return + } + belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID) + if err != nil || !belongs { + writeError(w, http.StatusNotFound, "gallery not found") + return + } + var request uploadRequest + if !decodeJSON(w, r, &request) { + return + } + filename := safeFilename(request.Filename) + mimeType := strings.ToLower(strings.TrimSpace(request.MimeType)) + if mimeType == "" { + mimeType = mime.TypeByExtension(filepath.Ext(filename)) + } + if filename == "" || len(filename) > 255 || !allowedMimeType(mimeType) { + writeError(w, http.StatusBadRequest, "unsupported media file") + return + } + if request.FileSize <= 0 || request.FileSize > maxUploadSize { + writeError(w, http.StatusBadRequest, "file size must be between 1 byte and 10 GB") + return + } + mediaID := uuid.New() + storageKey := fmt.Sprintf("galleries/%s/%s/original/%s", galleryID, mediaID, filename) + item, err := h.repository.Create(r.Context(), galleryID, mediaID, filename, mimeType, request.FileSize, storageKey) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not create upload") + return + } + uploadURL, err := h.storage.CreateUploadURL(r.Context(), storageKey, mimeType, uploadURLDuration) + if err != nil { + _, _ = h.repository.Delete(r.Context(), user.ID, mediaID) + writeError(w, http.StatusInternalServerError, "could not create upload URL") + return + } + writeJSON(w, http.StatusCreated, map[string]any{ + "uploadId": mediaID.String(), + "uploadUrl": uploadURL, + "media": publicFromRecord(item), + }) +} + +func (h *Handler) CompleteUpload(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + mediaID, err := parseID(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid media id") + return + } + item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID) + if err != nil { + writeError(w, http.StatusNotFound, "media not found") + return + } + info, err := h.storage.Stat(r.Context(), item.StorageKey) + if err != nil { + writeError(w, http.StatusBadRequest, "uploaded object is not available yet") + return + } + item, err = h.repository.Complete(r.Context(), user.ID, mediaID, info.Size) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not complete upload") + return + } + h.processor.Enqueue(item.ID) + writeJSON(w, http.StatusOK, map[string]any{"media": publicFromRecord(item)}) +} + +func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + mediaID, err := parseID(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid media id") + return + } + var request updateRequest + if !decodeJSON(w, r, &request) { + return + } + if request.SortOrder == nil || *request.SortOrder < 0 { + writeError(w, http.StatusBadRequest, "sort order must be zero or greater") + return + } + if err := h.repository.UpdateSortOrder(r.Context(), user.ID, mediaID, *request.SortOrder); err != nil { + writeError(w, http.StatusNotFound, "media not found") + return + } + item, _ := h.repository.GetForUser(r.Context(), user.ID, mediaID) + view, err := h.view(r.Context(), item, true) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not sign media URL") + return + } + writeJSON(w, http.StatusOK, map[string]any{"media": view}) +} + +func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + mediaID, err := parseID(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid media id") + return + } + item, err := h.repository.Delete(r.Context(), user.ID, mediaID) + if err != nil { + writeError(w, http.StatusNotFound, "media not found") + return + } + for _, key := range []string{item.StorageKey, item.PreviewKey, item.ThumbnailKey} { + if key != "" && key != item.ExternalURL { + _ = h.storage.Delete(r.Context(), key) + } + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + mediaID, err := parseID(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid media id") + return + } + item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID) + if err != nil || item.ProcessingStatus != StatusReady { + writeError(w, http.StatusNotFound, "media not found") + return + } + url := item.ExternalURL + if url == "" { + url, err = h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not create download") + return + } + } + _ = h.repository.RecordDownload(r.Context(), item.GalleryID, &item.ID, user.ID.String()) + writeJSON(w, http.StatusOK, map[string]string{"url": url}) +} + +func (h *Handler) view(ctx context.Context, item Record, includeOriginal bool) (Public, error) { + view := publicFromRecord(item) + if item.ProcessingStatus != StatusReady { + return view, nil + } + if item.ExternalURL != "" { + view.ThumbnailURL = item.ExternalURL + view.PreviewURL = item.ExternalURL + if includeOriginal { + view.OriginalURL = item.ExternalURL + } + return view, nil + } + previewKey := item.PreviewKey + if previewKey == "" { + previewKey = item.StorageKey + } + thumbnailKey := item.ThumbnailKey + if thumbnailKey == "" { + thumbnailKey = previewKey + } + var err error + view.ThumbnailURL, err = h.storage.CreateDownloadURL(ctx, thumbnailKey, time.Hour) + if err != nil { + return Public{}, err + } + view.PreviewURL, err = h.storage.CreateDownloadURL(ctx, previewKey, time.Hour) + if err != nil { + return Public{}, err + } + if includeOriginal { + view.OriginalURL, err = h.storage.CreateDownloadURL(ctx, item.StorageKey, time.Hour) + if err != nil { + return Public{}, err + } + } + return view, nil +} + +func publicFromRecord(item Record) Public { + return Public{ + ID: item.ID, + OriginalFilename: item.OriginalFilename, + MimeType: item.MimeType, + FileSize: item.FileSize, + ProcessingStatus: item.ProcessingStatus, + Width: item.Width, + Height: item.Height, + DurationSeconds: item.DurationSeconds, + SortOrder: item.SortOrder, + } +} + +func allowedMimeType(value string) bool { + switch value { + case "image/jpeg", "image/png", "image/webp", "image/heic", "image/heif", "video/mp4", "video/quicktime", "video/webm": + return true + default: + return false + } +} + +func safeFilename(value string) string { + value = strings.ReplaceAll(value, "\\", "/") + value = filepath.Base(value) + if value == "." || value == ".." { + return "" + } + return strings.TrimSpace(value) +} + +func parseID(value string) (uuid.UUID, error) { + return uuid.Parse(value) +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json") + return false + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return false + } + return true +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/backend/internal/media/models.go b/backend/internal/media/models.go new file mode 100644 index 0000000..87ab784 --- /dev/null +++ b/backend/internal/media/models.go @@ -0,0 +1,46 @@ +package media + +import "github.com/google/uuid" + +const ( + StatusUploading = "UPLOADING" + StatusProcessing = "PROCESSING" + StatusReady = "READY" + StatusFailed = "FAILED" +) + +type Record struct { + ID uuid.UUID + GalleryID uuid.UUID + OriginalFilename string + MimeType string + FileSize int64 + StorageKey string + ExternalURL string + ThumbnailKey string + PreviewKey string + ProcessingStatus string + ProcessingError string + Width int + Height int + DurationSeconds float64 + SortOrder int + CreatedAt string + UpdatedAt string +} + +type Public struct { + ID uuid.UUID `json:"id"` + OriginalFilename string `json:"originalFilename"` + MimeType string `json:"mimeType"` + FileSize int64 `json:"fileSize"` + ProcessingStatus string `json:"processingStatus"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + DurationSeconds float64 `json:"durationSeconds,omitempty"` + SortOrder int `json:"sortOrder"` + ThumbnailURL string `json:"thumbnailUrl,omitempty"` + PreviewURL string `json:"previewUrl,omitempty"` + OriginalURL string `json:"originalUrl,omitempty"` + Favorited bool `json:"favorited"` +} diff --git a/backend/internal/media/processor.go b/backend/internal/media/processor.go new file mode 100644 index 0000000..ce2b19f --- /dev/null +++ b/backend/internal/media/processor.go @@ -0,0 +1,154 @@ +package media + +import ( + "bytes" + "context" + "fmt" + "image" + "image/jpeg" + _ "image/png" + "io" + "sync" + + "github.com/example/sndit/backend/internal/storage" + "github.com/google/uuid" +) + +type Processor struct { + repository *Repository + storage storage.Storage + jobs chan uuid.UUID + stop chan struct{} + waitGroup sync.WaitGroup +} + +func NewProcessor(repository *Repository, objectStorage storage.Storage, workers int) *Processor { + if workers < 1 { + workers = 1 + } + processor := &Processor{ + repository: repository, + storage: objectStorage, + jobs: make(chan uuid.UUID, 256), + stop: make(chan struct{}), + } + for index := 0; index < workers; index++ { + processor.waitGroup.Add(1) + go processor.worker() + } + return processor +} + +func (p *Processor) Enqueue(mediaID uuid.UUID) { + select { + case p.jobs <- mediaID: + case <-p.stop: + } +} + +func (p *Processor) Close() { + close(p.stop) + p.waitGroup.Wait() +} + +func (p *Processor) worker() { + defer p.waitGroup.Done() + for { + select { + case mediaID := <-p.jobs: + p.process(mediaID) + case <-p.stop: + return + } + } +} + +func (p *Processor) process(mediaID uuid.UUID) { + ctx := context.Background() + item, err := p.repository.GetByID(ctx, mediaID) + if err != nil { + return + } + + if item.ExternalURL != "" || !IsImage(item) { + _ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, item.Width, item.Height) + return + } + + object, err := p.storage.Get(ctx, item.StorageKey) + if err != nil { + _ = p.repository.MarkFailed(ctx, mediaID, err.Error()) + return + } + defer object.Close() + + decoded, _, err := image.Decode(io.LimitReader(object, 100<<20)) + if err != nil { + // Formats without a stdlib decoder, such as HEIC, remain usable through + // the original object until a dedicated processing service is added. + _ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, 0, 0) + return + } + + width := decoded.Bounds().Dx() + height := decoded.Bounds().Dy() + previewKey := variantKey(item, "preview.jpg") + thumbnailKey := variantKey(item, "thumbnail.jpg") + preview, err := encodeJPEG(resize(decoded, 2400), 88) + if err != nil { + _ = p.repository.MarkFailed(ctx, mediaID, err.Error()) + return + } + thumbnail, err := encodeJPEG(resize(decoded, 640), 84) + if err != nil { + _ = p.repository.MarkFailed(ctx, mediaID, err.Error()) + return + } + if err := p.storage.Put(ctx, previewKey, bytes.NewReader(preview), int64(len(preview)), "image/jpeg"); err != nil { + _ = p.repository.MarkFailed(ctx, mediaID, err.Error()) + return + } + if err := p.storage.Put(ctx, thumbnailKey, bytes.NewReader(thumbnail), int64(len(thumbnail)), "image/jpeg"); err != nil { + _ = p.repository.MarkFailed(ctx, mediaID, err.Error()) + return + } + if err := p.repository.MarkReady(ctx, mediaID, previewKey, thumbnailKey, width, height); err != nil { + return + } +} + +func variantKey(item Record, filename string) string { + return fmt.Sprintf("galleries/%s/%s/%s", item.GalleryID, item.ID, filename) +} + +func resize(source image.Image, maxSide int) image.Image { + bounds := source.Bounds() + width, height := bounds.Dx(), bounds.Dy() + if width <= maxSide && height <= maxSide { + return source + } + + scale := float64(maxSide) / float64(width) + if height > width { + scale = float64(maxSide) / float64(height) + } + newWidth := int(float64(width) * scale) + newHeight := int(float64(height) * scale) + destination := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) + for y := 0; y < newHeight; y++ { + for x := 0; x < newWidth; x++ { + sourceX := bounds.Min.X + x*width/newWidth + sourceY := bounds.Min.Y + y*height/newHeight + destination.Set(x, y, source.At(sourceX, sourceY)) + } + } + return destination +} + +func encodeJPEG(source image.Image, quality int) ([]byte, error) { + var output bytes.Buffer + if err := jpeg.Encode(&output, source, &jpeg.Options{Quality: quality}); err != nil { + return nil, fmt.Errorf("encode preview: %w", err) + } + return output.Bytes(), nil +} diff --git a/backend/internal/media/repository.go b/backend/internal/media/repository.go new file mode 100644 index 0000000..6dd31d1 --- /dev/null +++ b/backend/internal/media/repository.go @@ -0,0 +1,225 @@ +package media + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" +) + +type Repository struct { + db *sql.DB +} + +func NewRepository(db *sql.DB) *Repository { + return &Repository{db: db} +} + +var ErrNotFound = errors.New("media not found") + +func (r *Repository) Create(ctx context.Context, galleryID, id uuid.UUID, filename, mimeType string, fileSize int64, storageKey string) (Record, error) { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO media (id, gallery_id, original_filename, mime_type, file_size, storage_key, processing_status, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE((SELECT MAX(sort_order) + 1 FROM media WHERE gallery_id = $2), 0)) + `, id, galleryID, filename, mimeType, fileSize, storageKey, StatusUploading) + if err != nil { + return Record{}, fmt.Errorf("create media: %w", err) + } + return r.GetByID(ctx, id) +} + +func (r *Repository) GalleryBelongsToUser(ctx context.Context, galleryID, userID uuid.UUID) (bool, error) { + var exists bool + err := r.db.QueryRowContext(ctx, ` + SELECT EXISTS (SELECT 1 FROM galleries WHERE id = $1 AND user_id = $2 AND status <> 'archived') + `, galleryID, userID).Scan(&exists) + return exists, err +} + +func (r *Repository) GetByID(ctx context.Context, id uuid.UUID) (Record, error) { + return r.get(ctx, ` + SELECT id, gallery_id, original_filename, mime_type, file_size, storage_key, + external_url, thumbnail_key, preview_key, processing_status, processing_error, + width, height, duration_seconds, sort_order, created_at, updated_at + FROM media + WHERE id = $1 + `, id) +} + +func (r *Repository) GetForUser(ctx context.Context, userID, mediaID uuid.UUID) (Record, error) { + return r.get(ctx, ` + SELECT m.id, m.gallery_id, m.original_filename, m.mime_type, m.file_size, m.storage_key, + m.external_url, m.thumbnail_key, m.preview_key, m.processing_status, m.processing_error, + m.width, m.height, m.duration_seconds, m.sort_order, m.created_at, m.updated_at + FROM media m + JOIN galleries g ON g.id = m.gallery_id + WHERE m.id = $1 AND g.user_id = $2 + `, mediaID, userID) +} + +func (r *Repository) ListByGallery(ctx context.Context, galleryID uuid.UUID) ([]Record, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, gallery_id, original_filename, mime_type, file_size, storage_key, + external_url, thumbnail_key, preview_key, processing_status, processing_error, + width, height, duration_seconds, sort_order, created_at, updated_at + FROM media + WHERE gallery_id = $1 + ORDER BY sort_order ASC, id ASC + `, galleryID) + if err != nil { + return nil, fmt.Errorf("list media: %w", err) + } + defer rows.Close() + + result := make([]Record, 0) + for rows.Next() { + item, err := scanMedia(rows) + if err != nil { + return nil, err + } + result = append(result, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate media: %w", err) + } + return result, nil +} + +func (r *Repository) Complete(ctx context.Context, userID, mediaID uuid.UUID, fileSize int64) (Record, error) { + result, err := r.db.ExecContext(ctx, ` + UPDATE media + SET file_size = $1, processing_status = $2, processing_error = NULL, updated_at = CURRENT_TIMESTAMP + WHERE id = $3 AND gallery_id IN (SELECT id FROM galleries WHERE user_id = $4) + `, fileSize, StatusProcessing, mediaID, userID) + if err != nil { + return Record{}, fmt.Errorf("complete media upload: %w", err) + } + if count, _ := result.RowsAffected(); count == 0 { + return Record{}, ErrNotFound + } + return r.GetByID(ctx, mediaID) +} + +func (r *Repository) MarkReady(ctx context.Context, mediaID uuid.UUID, previewKey, thumbnailKey string, width, height int) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE media + SET processing_status = $1, processing_error = NULL, preview_key = $2, thumbnail_key = $3, + width = $4, height = $5, updated_at = CURRENT_TIMESTAMP + WHERE id = $6 + `, StatusReady, previewKey, thumbnailKey, width, height, mediaID) + return err +} + +func (r *Repository) MarkFailed(ctx context.Context, mediaID uuid.UUID, message string) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE media + SET processing_status = $1, processing_error = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + `, StatusFailed, message, mediaID) + return err +} + +func (r *Repository) UpdateSortOrder(ctx context.Context, userID, mediaID uuid.UUID, sortOrder int) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE media + SET sort_order = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND gallery_id IN (SELECT id FROM galleries WHERE user_id = $3) + `, sortOrder, mediaID, userID) + if err != nil { + return fmt.Errorf("update media order: %w", err) + } + if count, _ := result.RowsAffected(); count == 0 { + return ErrNotFound + } + return nil +} + +func (r *Repository) Delete(ctx context.Context, userID, mediaID uuid.UUID) (Record, error) { + item, err := r.GetForUser(ctx, userID, mediaID) + if err != nil { + return Record{}, err + } + if _, err := r.db.ExecContext(ctx, `DELETE FROM media WHERE id = $1`, mediaID); err != nil { + return Record{}, fmt.Errorf("delete media: %w", err) + } + return item, nil +} + +func (r *Repository) IsFavorited(ctx context.Context, galleryID, mediaID uuid.UUID, visitorID string) (bool, error) { + var exists bool + err := r.db.QueryRowContext(ctx, ` + SELECT EXISTS (SELECT 1 FROM favorites WHERE gallery_id = $1 AND media_id = $2 AND visitor_id = $3) + `, galleryID, mediaID, visitorID).Scan(&exists) + return exists, err +} + +func (r *Repository) SetFavorite(ctx context.Context, galleryID, mediaID uuid.UUID, visitorID string, favorited bool) error { + if favorited { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO favorites (id, gallery_id, media_id, visitor_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (gallery_id, media_id, visitor_id) DO NOTHING + `, uuid.New(), galleryID, mediaID, visitorID) + return err + } + _, err := r.db.ExecContext(ctx, `DELETE FROM favorites WHERE gallery_id = $1 AND media_id = $2 AND visitor_id = $3`, galleryID, mediaID, visitorID) + return err +} + +func (r *Repository) RecordDownload(ctx context.Context, galleryID uuid.UUID, mediaID *uuid.UUID, visitorID string) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO downloads (id, gallery_id, media_id, visitor_id) + VALUES ($1, $2, $3, $4) + `, uuid.New(), galleryID, mediaID, visitorID) + return err +} + +type rowScanner interface { + Scan(...any) error +} + +func (r *Repository) get(ctx context.Context, query string, args ...any) (Record, error) { + return scanMedia(r.db.QueryRowContext(ctx, query, args...)) +} + +func scanMedia(row rowScanner) (Record, error) { + var ( + item Record + externalURL, thumbnailKey, previewKey, processingError sql.NullString + width, height sql.NullInt64 + duration sql.NullFloat64 + createdAt, updatedAt sql.NullString + ) + err := row.Scan( + &item.ID, &item.GalleryID, &item.OriginalFilename, &item.MimeType, &item.FileSize, + &item.StorageKey, &externalURL, &thumbnailKey, &previewKey, &item.ProcessingStatus, + &processingError, &width, &height, &duration, &item.SortOrder, &createdAt, &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return Record{}, ErrNotFound + } + if err != nil { + return Record{}, fmt.Errorf("scan media: %w", err) + } + item.ExternalURL = externalURL.String + item.ThumbnailKey = thumbnailKey.String + item.PreviewKey = previewKey.String + item.ProcessingError = processingError.String + item.Width = int(width.Int64) + item.Height = int(height.Int64) + item.DurationSeconds = duration.Float64 + item.CreatedAt = createdAt.String + item.UpdatedAt = updatedAt.String + return item, nil +} + +func IsImage(item Record) bool { + return strings.HasPrefix(strings.ToLower(item.MimeType), "image/") +} + +func IsVideo(item Record) bool { + return strings.HasPrefix(strings.ToLower(item.MimeType), "video/") +} diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go new file mode 100644 index 0000000..827305f --- /dev/null +++ b/backend/internal/storage/storage.go @@ -0,0 +1,143 @@ +package storage + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/cors" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type Config struct { + Endpoint string + AccessKey string + SecretKey string + Bucket string + UseSSL bool + CORSOrigins string +} + +type ObjectInfo struct { + Size int64 + ContentType string +} + +// Storage is deliberately S3-shaped so the MinIO implementation can be +// replaced by AWS S3, R2, B2, or another compatible provider later. +type Storage interface { + EnsureBucket(context.Context) error + CreateUploadURL(context.Context, string, string, time.Duration) (string, error) + CreateDownloadURL(context.Context, string, time.Duration) (string, error) + Delete(context.Context, string) error + Stat(context.Context, string) (ObjectInfo, error) + Get(context.Context, string) (io.ReadCloser, error) + Put(context.Context, string, io.Reader, int64, string) error +} + +type MinIO struct { + client *minio.Client + bucket string + corsOrigins string +} + +func NewMinIO(config Config) (*MinIO, error) { + client, err := minio.New(config.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(config.AccessKey, config.SecretKey, ""), + Secure: config.UseSSL, + }) + if err != nil { + return nil, fmt.Errorf("create object storage client: %w", err) + } + if config.Bucket == "" { + return nil, fmt.Errorf("object storage bucket is required") + } + return &MinIO{client: client, bucket: config.Bucket, corsOrigins: config.CORSOrigins}, nil +} + +func (s *MinIO) EnsureBucket(ctx context.Context) error { + exists, err := s.client.BucketExists(ctx, s.bucket) + if err != nil { + return fmt.Errorf("check object storage bucket: %w", err) + } + if exists { + return nil + } + if err := s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil { + response := minio.ToErrorResponse(err) + if response.Code != "BucketAlreadyExists" && response.Code != "BucketAlreadyOwnedByYou" { + return fmt.Errorf("create object storage bucket: %w", err) + } + } + origins := make([]string, 0) + for _, origin := range strings.Split(s.corsOrigins, ",") { + if value := strings.TrimSpace(origin); value != "" { + origins = append(origins, value) + } + } + if len(origins) == 0 { + origins = []string{"*"} + } + if err := s.client.SetBucketCors(ctx, s.bucket, cors.NewConfig([]cors.Rule{{ + ID: "northline-browser-uploads", + AllowedOrigin: origins, + AllowedMethod: []string{"GET", "PUT", "POST", "PATCH", "DELETE", "HEAD"}, + AllowedHeader: []string{"*"}, + ExposeHeader: []string{"ETag", "x-amz-request-id", "x-amz-id-2"}, + MaxAgeSeconds: 3600, + }})); err != nil { + return fmt.Errorf("configure object storage CORS: %w", err) + } + return nil +} + +func (s *MinIO) CreateUploadURL(ctx context.Context, key, _ string, expiry time.Duration) (string, error) { + url, err := s.client.PresignedPutObject(ctx, s.bucket, key, expiry) + if err != nil { + return "", fmt.Errorf("create upload URL: %w", err) + } + return url.String(), nil +} + +func (s *MinIO) CreateDownloadURL(ctx context.Context, key string, expiry time.Duration) (string, error) { + url, err := s.client.PresignedGetObject(ctx, s.bucket, key, expiry, nil) + if err != nil { + return "", fmt.Errorf("create download URL: %w", err) + } + return url.String(), nil +} + +func (s *MinIO) Delete(ctx context.Context, key string) error { + if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil { + return fmt.Errorf("delete object: %w", err) + } + return nil +} + +func (s *MinIO) Stat(ctx context.Context, key string) (ObjectInfo, error) { + info, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{}) + if err != nil { + return ObjectInfo{}, fmt.Errorf("stat object: %w", err) + } + return ObjectInfo{Size: info.Size, ContentType: info.ContentType}, nil +} + +func (s *MinIO) Get(ctx context.Context, key string) (io.ReadCloser, error) { + object, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{}) + if err != nil { + return nil, fmt.Errorf("get object: %w", err) + } + return object, nil +} + +func (s *MinIO) Put(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error { + if _, err := s.client.PutObject(ctx, s.bucket, key, reader, size, minio.PutObjectOptions{ContentType: contentType}); err != nil { + return fmt.Errorf("put object: %w", err) + } + return nil +} + +var _ Storage = (*MinIO)(nil) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..91ee876 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-surprise} + POSTGRES_USER: ${POSTGRES_USER:-surprise} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-surprise_dev_password} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./migrations:/migrations:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + + minio: + image: minio/minio:latest + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY:-minioadmin} + MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_KEY:-minioadmin} + MINIO_API_CORS_ALLOW_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173} + ports: + - "${MINIO_API_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9000/minio/health/live || exit 1"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + postgres_data: + minio_data: diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..de4d1f0 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..47174e4 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..d7fa61c --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,27 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist', 'node_modules'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2022, + globals: { ...globals.browser, ...globals.node }, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + }, + }, +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f59b3d4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + Northline Delivery Studio + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a014b5d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4336 @@ +{ + "name": "little-something-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "little-something-frontend", + "version": "0.1.0", + "dependencies": { + "framer-motion": "^12.23.12", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.8.2" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/node": "^24.3.0", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.2", + "autoprefixer": "^10.4.21", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "postcss": "^8.5.6", + "prettier": "^3.6.2", + "tailwindcss": "^3.4.17", + "typescript": "~5.9.2", + "typescript-eslint": "^8.39.0", + "vite": "^7.1.3" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "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.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "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/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "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.6", + "caniuse-lite": "^1.0.30001806", + "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/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.17.tgz", + "integrity": "sha512-KAUDn1OSS0fmPlGO+NOUMRcOQ/b/shUBH3OgkG73mPgdf+JD/BQ6fHboGxNOxnUmlwcq+lLq3dTkayRPuSfXwg==", + "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/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "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.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "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.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "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/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "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.412", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", + "integrity": "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==", + "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.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "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/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "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/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "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/framer-motion": { + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", + "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.43.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "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/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "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/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "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/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/motion-dom": { + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", + "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, + "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.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/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.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "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.17", + "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.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "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/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/read-cache": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", + "dev": true, + "license": "MIT" + }, + "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/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "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.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "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-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.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/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.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": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/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/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..94183fe --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "little-something-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "engines": { + "node": ">=20.19.0" + }, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "preview": "vite preview" + }, + "dependencies": { + "framer-motion": "^12.23.12", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.8.2" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/node": "^24.3.0", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.2", + "autoprefixer": "^10.4.21", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "postcss": "^8.5.6", + "prettier": "^3.6.2", + "tailwindcss": "^3.4.17", + "typescript": "~5.9.2", + "typescript-eslint": "^8.39.0", + "vite": "^7.1.3" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..43f94e9 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,99 @@ +import { BrowserRouter, Route, Routes } from 'react-router-dom'; + +import { RequireAuth } from './components/app/RequireAuth'; +import { AuthProvider } from './features/auth/AuthContext'; +import GalleryPreviewPage from './pages/GalleryPreviewPage'; +import LoginPage from './pages/LoginPage'; +import PublicGalleryPage from './pages/PublicGalleryPage'; +import RegisterPage from './pages/RegisterPage'; +import DashboardPage from './pages/dashboard/DashboardPage'; +import GalleriesPage from './pages/dashboard/GalleriesPage'; +import GalleryEditorPage from './pages/dashboard/GalleryEditorPage'; +import PlaceholderPage from './pages/dashboard/PlaceholderPage'; +import DevPage from './pages/dashboard/DevPage'; +import NotFoundPage from './pages/NotFoundPage'; + +export default function App() { + return ( + + + + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + + + ); +} diff --git a/frontend/src/components/app/AppLoading.tsx b/frontend/src/components/app/AppLoading.tsx new file mode 100644 index 0000000..e9ea879 --- /dev/null +++ b/frontend/src/components/app/AppLoading.tsx @@ -0,0 +1,8 @@ +export function AppLoading() { + return ( +
+ N +
+ ); +} diff --git a/frontend/src/components/app/AuthLayout.tsx b/frontend/src/components/app/AuthLayout.tsx new file mode 100644 index 0000000..781bde4 --- /dev/null +++ b/frontend/src/components/app/AuthLayout.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react'; + +export function AuthLayout({ children }: { children: ReactNode }) { + return ( +
+ + ); +} diff --git a/frontend/src/components/app/RequireAuth.tsx b/frontend/src/components/app/RequireAuth.tsx new file mode 100644 index 0000000..6b2e807 --- /dev/null +++ b/frontend/src/components/app/RequireAuth.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; + +import { AppLoading } from './AppLoading'; +import { useAuth } from '../../features/auth/useAuth'; + +export function RequireAuth({ children }: { children: ReactNode }) { + const { user, loading } = useAuth(); + const location = useLocation(); + + if (loading) { + return ; + } + if (!user) { + return ; + } + return children; +} diff --git a/frontend/src/components/dashboard/DashboardLayout.tsx b/frontend/src/components/dashboard/DashboardLayout.tsx new file mode 100644 index 0000000..c003559 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardLayout.tsx @@ -0,0 +1,90 @@ +import { NavLink, Link } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +import { useAuth } from '../../features/auth/useAuth'; + +const navItems = [ + { label: 'Overview', path: '/dashboard', end: true }, + { label: 'Galleries', path: '/dashboard/galleries', end: false }, + { label: 'Storage', path: '/dashboard/storage', end: false }, + { label: 'Settings', path: '/dashboard/settings', end: false }, + { label: 'Dev', path: '/dashboard/dev', end: false }, +]; + +export function DashboardLayout({ children }: { children: ReactNode }) { + const { user, signOut } = useAuth(); + const initials = user?.name + .split(' ') + .map((part) => part[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + + return ( +
+ +
+
+ + N + Northline + + +
+ {children} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/GalleryCard.tsx b/frontend/src/components/dashboard/GalleryCard.tsx new file mode 100644 index 0000000..57e194b --- /dev/null +++ b/frontend/src/components/dashboard/GalleryCard.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; + +import type { GallerySummary } from '../../types/gallery'; +import { formatBytes, formatDate } from '../../lib/format'; + +interface GalleryCardProps { + gallery: GallerySummary; + onDelete: (gallery: GallerySummary) => void; +} + +export function GalleryCard({ gallery, onDelete }: GalleryCardProps) { + const [copied, setCopied] = useState(false); + const clientURL = `${window.location.origin}/g/${gallery.slug}`; + + async function copyLink() { + try { + await navigator.clipboard.writeText(clientURL); + setCopied(true); + window.setTimeout(() => setCopied(false), 1800); + } catch { + setCopied(false); + } + } + + return ( +
+ + {gallery.coverUrl ? ( + + ) : ( + + )} + +