From 2b0c683b7bd0aa6fe2946015dd33d75b21a675f6 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Wed, 18 Mar 2026 13:26:50 -0400 Subject: [PATCH] refactor: replace Biome with oxlint + oxfmt Migrate the entire monorepo from Biome 2.4.7 to oxlint 1.56.0 (linter) and oxfmt 0.41.0 (formatter) for faster lint/format and broader rule coverage. - Add `.oxlintrc.json` with React, TypeScript, unicorn, import plugins and correctness/suspicious categories - Add `.oxfmtrc.json` with 2-space indent, import sorting, and Tailwind class sorting (all 30+ custom className attributes migrated) - Add `docs/.oxlintrc.json` and `docs/.oxfmtrc.json` with Next.js plugin - Update all 12 workspace package.json scripts: `oxlint`, `oxfmt`, `oxfmt --check` - Add `format:check` turbo task and CI step - Update VS Code settings/extensions to use `oxc.oxc-vscode` - Update CI path triggers from `biome.json` to new config files - Remove all `biome-ignore` comments and fix shadowed variables - Delete `biome.json` and `docs/biome.json` - Reformat entire codebase with oxfmt Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/claude-code-review.yml | 7 +- .github/workflows/claude.yml | 1 - .github/workflows/docker.yml | 3 +- .github/workflows/test.yml | 8 + .oxfmtrc.json | 54 +++ .oxlintrc.json | 18 + .vscode/extensions.json | 2 +- .vscode/settings.json | 19 +- AGENTS.md | 11 +- README.md | 28 +- apps/native/.oxlintrc.json | 5 + apps/native/metro.config.js | 4 +- apps/native/package.json | 11 +- apps/native/src/app/(auth)/_layout.tsx | 5 +- apps/native/src/app/(auth)/login.tsx | 61 +-- apps/native/src/app/(auth)/register.tsx | 50 +-- apps/native/src/app/(auth)/server-url.tsx | 49 +-- .../native/src/app/(tabs)/(explore)/index.tsx | 44 +- apps/native/src/app/(tabs)/(home)/index.tsx | 39 +- apps/native/src/app/(tabs)/(search)/index.tsx | 34 +- .../src/app/(tabs)/(settings)/index.tsx | 146 +++---- apps/native/src/app/(tabs)/_layout.tsx | 1 + apps/native/src/app/+not-found.tsx | 10 +- apps/native/src/app/_layout.tsx | 23 +- apps/native/src/app/change-password.tsx | 20 +- apps/native/src/app/person/[id].tsx | 128 ++---- apps/native/src/app/title/[id].tsx | 158 ++------ apps/native/src/components/auth-screen.tsx | 24 +- apps/native/src/components/container.tsx | 10 +- .../dashboard/continue-watching-card.tsx | 41 +- .../dashboard/horizontal-poster-row.tsx | 9 +- .../src/components/dashboard/stats-card.tsx | 15 +- .../explore/filterable-title-row.tsx | 30 +- .../src/components/explore/genre-chip.tsx | 7 +- .../src/components/explore/hero-banner.tsx | 45 +-- apps/native/src/components/header-avatar.tsx | 20 +- .../navigation/native-tab-bar.android.tsx | 18 +- .../components/navigation/native-tab-bar.tsx | 16 +- .../src/components/navigation/tab-stack.tsx | 23 +- .../search/recently-viewed-list.ios.tsx | 39 +- .../search/recently-viewed-list.tsx | 33 +- .../search/recently-viewed-row-content.tsx | 29 +- .../components/search/recently-viewed-row.tsx | 1 + .../components/search/search-result-row.tsx | 40 +- .../components/settings/integration-card.tsx | 112 ++--- .../settings/integration-configs.ts | 15 +- .../settings/integrations-section.tsx | 23 +- .../src/components/settings/settings-row.tsx | 10 +- .../components/settings/settings-section.tsx | 22 +- .../src/components/titles/cast-card.tsx | 7 +- .../titles/continue-watching-banner.tsx | 37 +- .../src/components/titles/episode-row.tsx | 23 +- .../components/titles/season-accordion.tsx | 34 +- .../titles/status-action-button.tsx | 17 +- apps/native/src/components/tmdb-logo.tsx | 7 +- apps/native/src/components/ui/button.tsx | 25 +- apps/native/src/components/ui/empty-state.tsx | 13 +- .../src/components/ui/expandable-text.tsx | 3 +- apps/native/src/components/ui/image.tsx | 1 + .../src/components/ui/offline-banner.ios.tsx | 7 +- .../src/components/ui/offline-banner.tsx | 5 +- apps/native/src/components/ui/poster-card.tsx | 74 ++-- apps/native/src/components/ui/scaled-icon.tsx | 7 +- .../src/components/ui/section-header.tsx | 15 +- .../native/src/components/ui/select-modal.tsx | 17 +- .../ui/server-unreachable-banner.ios.tsx | 12 +- .../ui/server-unreachable-banner.tsx | 12 +- apps/native/src/components/ui/skeleton.tsx | 8 +- apps/native/src/components/ui/spinner.tsx | 6 +- apps/native/src/components/ui/star-rating.tsx | 1 + .../src/components/ui/swipeable-row.tsx | 11 +- apps/native/src/components/ui/switch.tsx | 8 +- apps/native/src/components/ui/text-field.tsx | 68 ++-- apps/native/src/hooks/use-press-animation.ts | 4 +- .../native/src/hooks/use-server-connection.ts | 15 +- apps/native/src/hooks/use-title-actions.ts | 33 +- apps/native/src/hooks/use-title-theme.ts | 9 +- apps/native/src/lib/error-messages.ts | 1 + apps/native/src/lib/i18n.ts | 8 +- apps/native/src/lib/intl-polyfills.ts | 5 - apps/native/src/lib/orpc.ts | 5 +- apps/native/src/lib/posthog.ts | 5 +- apps/native/src/lib/query-client.ts | 2 +- apps/native/src/lib/recently-viewed.ts | 12 +- apps/native/src/lib/server.ts | 46 +-- apps/native/src/lib/title-actions.ts | 15 +- apps/native/src/lib/toast.ts | 12 +- apps/native/src/utils/form-errors.ts | 5 +- apps/native/src/utils/haptics.android.ts | 9 +- apps/native/tsconfig.json | 8 +- apps/public-api/.oxlintrc.json | 4 + apps/public-api/package.json | 5 +- apps/public-api/src/app.ts | 37 +- apps/public-api/src/providers/simkl.ts | 35 +- apps/public-api/src/providers/trakt.ts | 26 +- apps/public-api/src/providers/types.ts | 16 +- apps/server/.oxlintrc.json | 4 + apps/server/package.json | 13 +- apps/server/src/cron.ts | 89 +--- apps/server/src/index.ts | 8 +- apps/server/src/orpc/context.ts | 1 + apps/server/src/orpc/handler.ts | 2 + apps/server/src/orpc/middleware.ts | 1 + apps/server/src/orpc/openapi-handler.ts | 11 +- apps/server/src/orpc/openapi-spec.ts | 40 +- apps/server/src/orpc/procedures/account.ts | 36 +- apps/server/src/orpc/procedures/admin.ts | 153 +++---- apps/server/src/orpc/procedures/dashboard.ts | 129 +++--- apps/server/src/orpc/procedures/discover.ts | 127 +++--- apps/server/src/orpc/procedures/episodes.ts | 31 +- apps/server/src/orpc/procedures/explore.ts | 278 ++++++------- apps/server/src/orpc/procedures/imports.ts | 358 ++++++++-------- .../src/orpc/procedures/integrations.ts | 90 ++--- apps/server/src/orpc/procedures/people.ts | 50 +-- apps/server/src/orpc/procedures/search.ts | 12 +- apps/server/src/orpc/procedures/seasons.ts | 29 +- apps/server/src/orpc/procedures/system.ts | 13 +- apps/server/src/orpc/procedures/titles.ts | 129 +++--- apps/server/src/routes/auth.ts | 3 +- apps/server/src/routes/avatars.ts | 4 +- apps/server/src/routes/backups.ts | 4 +- apps/server/src/routes/health.ts | 3 +- apps/server/src/routes/images.ts | 12 +- apps/server/src/routes/lists.ts | 8 +- apps/server/src/routes/webhooks.ts | 9 +- apps/web/.oxlintrc.json | 4 + apps/web/package.json | 9 +- apps/web/src/components/auth-form.tsx | 101 ++--- apps/web/src/components/command-palette.tsx | 71 +--- .../dashboard/continue-watching-card.tsx | 56 +-- .../dashboard/continue-watching-list.tsx | 14 +- .../dashboard/continue-watching-section.tsx | 13 +- .../src/components/dashboard/feed-section.tsx | 4 +- .../components/dashboard/library-section.tsx | 24 +- .../dashboard/recommendations-section.tsx | 8 +- .../components/dashboard/stats-display.tsx | 43 +- .../components/dashboard/stats-section.tsx | 14 +- .../components/dashboard/welcome-header.tsx | 10 +- apps/web/src/components/expandable-text.tsx | 5 +- .../src/components/explore/explore-client.tsx | 22 +- .../explore/filterable-title-row.tsx | 37 +- .../src/components/explore/hero-banner.tsx | 36 +- apps/web/src/components/explore/title-row.tsx | 7 +- apps/web/src/components/landing-page.tsx | 19 +- apps/web/src/components/nav-bar.tsx | 64 ++- .../src/components/navigation-progress.tsx | 3 +- .../components/people/filmography-grid.tsx | 28 +- .../people/person-detail-client.tsx | 34 +- .../web/src/components/people/person-hero.tsx | 20 +- .../components/settings/account-section.tsx | 78 ++-- .../settings/backup-restore-section.tsx | 34 +- .../settings/backup-schedule-section.tsx | 126 ++---- .../components/settings/backup-section.tsx | 68 +--- .../components/settings/danger-section.tsx | 74 +--- apps/web/src/components/settings/icons.tsx | 5 +- .../components/settings/imports-section.tsx | 160 +++----- .../components/settings/integration-card.tsx | 92 ++--- .../settings/integration-configs.tsx | 93 ++--- .../settings/integrations-section.tsx | 27 +- .../components/settings/language-section.tsx | 38 +- .../settings/registration-section.tsx | 21 +- .../components/settings/settings-shell.tsx | 14 +- .../settings/system-health-section.tsx | 165 +++----- .../settings/update-check-section.tsx | 23 +- apps/web/src/components/setup/copy-button.tsx | 3 +- .../src/components/setup/refresh-button.tsx | 15 +- apps/web/src/components/status-dot.tsx | 26 +- apps/web/src/components/title-card.tsx | 61 +-- .../src/components/titles/cast-carousel.tsx | 18 +- .../src/components/titles/genre-collapse.tsx | 15 +- .../web/src/components/titles/star-rating.tsx | 8 +- .../src/components/titles/status-button.tsx | 20 +- .../src/components/titles/title-actions.tsx | 17 +- .../components/titles/title-availability.tsx | 60 +-- apps/web/src/components/titles/title-cast.tsx | 1 + .../src/components/titles/title-context.tsx | 6 +- apps/web/src/components/titles/title-hero.tsx | 41 +- .../titles/title-keyboard-shortcuts.tsx | 6 +- .../src/components/titles/title-provider.tsx | 4 +- .../titles/title-recommendations.tsx | 3 +- .../src/components/titles/title-seasons.tsx | 80 ++-- .../src/components/titles/trailer-dialog.tsx | 3 +- .../components/titles/use-title-actions.ts | 49 +-- apps/web/src/components/ui/accordion.tsx | 26 +- apps/web/src/components/ui/alert-dialog.tsx | 52 +-- apps/web/src/components/ui/alert.tsx | 11 +- apps/web/src/components/ui/avatar.tsx | 34 +- apps/web/src/components/ui/badge.tsx | 8 +- apps/web/src/components/ui/breadcrumb.tsx | 38 +- apps/web/src/components/ui/button-group.tsx | 19 +- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/card.tsx | 27 +- apps/web/src/components/ui/chart.tsx | 64 +-- apps/web/src/components/ui/checkbox.tsx | 3 +- apps/web/src/components/ui/collapsible.tsx | 8 +- apps/web/src/components/ui/combobox.tsx | 62 +-- apps/web/src/components/ui/command.tsx | 35 +- apps/web/src/components/ui/context-menu.tsx | 67 +-- apps/web/src/components/ui/dialog.tsx | 42 +- apps/web/src/components/ui/direction.tsx | 5 +- apps/web/src/components/ui/dropdown-menu.tsx | 44 +- apps/web/src/components/ui/empty.tsx | 19 +- apps/web/src/components/ui/field.tsx | 68 ++-- apps/web/src/components/ui/hover-card.tsx | 11 +- apps/web/src/components/ui/input-group.tsx | 26 +- apps/web/src/components/ui/input.tsx | 2 +- apps/web/src/components/ui/item.tsx | 33 +- apps/web/src/components/ui/kbd.tsx | 2 +- apps/web/src/components/ui/label.tsx | 3 +- apps/web/src/components/ui/menubar.tsx | 50 +-- .../web/src/components/ui/navigation-menu.tsx | 31 +- apps/web/src/components/ui/pagination.tsx | 24 +- apps/web/src/components/ui/popover.tsx | 23 +- apps/web/src/components/ui/progress.tsx | 31 +- apps/web/src/components/ui/radio-group.tsx | 4 +- apps/web/src/components/ui/scroll-area.tsx | 15 +- apps/web/src/components/ui/select.tsx | 45 +-- apps/web/src/components/ui/separator.tsx | 8 +- apps/web/src/components/ui/sheet.tsx | 20 +- apps/web/src/components/ui/sidebar.tsx | 103 ++--- apps/web/src/components/ui/skeleton.tsx | 2 +- apps/web/src/components/ui/slider.tsx | 18 +- apps/web/src/components/ui/spinner.tsx | 1 + apps/web/src/components/ui/switch.tsx | 4 +- apps/web/src/components/ui/table.tsx | 45 +-- apps/web/src/components/ui/tabs.tsx | 17 +- apps/web/src/components/ui/textarea.tsx | 2 +- apps/web/src/components/ui/toggle-group.tsx | 7 +- apps/web/src/components/ui/toggle.tsx | 4 +- apps/web/src/components/ui/tooltip.tsx | 22 +- apps/web/src/components/update-toast.tsx | 7 +- apps/web/src/hooks/use-infinite-scroll.ts | 4 +- apps/web/src/hooks/use-mobile.ts | 4 +- apps/web/src/hooks/use-tilt-effect.ts | 21 +- apps/web/src/hooks/use-time-ago.ts | 8 +- apps/web/src/lib/error-messages.ts | 1 + apps/web/src/lib/i18n.ts | 6 +- apps/web/src/lib/orpc/client.ts | 8 +- apps/web/src/lib/theme.test.ts | 9 +- apps/web/src/lib/theme.ts | 7 +- apps/web/src/lib/thumbhash.ts | 4 +- apps/web/src/main.tsx | 7 +- apps/web/src/routes/__root.tsx | 37 +- apps/web/src/routes/_app.tsx | 5 +- apps/web/src/routes/_app/dashboard.tsx | 1 + apps/web/src/routes/_app/explore.tsx | 1 + apps/web/src/routes/_app/people.$id.tsx | 22 +- apps/web/src/routes/_app/settings.tsx | 67 ++- apps/web/src/routes/_app/titles.$id.tsx | 35 +- apps/web/src/routes/_auth/login.tsx | 1 + apps/web/src/routes/_auth/register.tsx | 16 +- apps/web/src/routes/index.tsx | 1 + apps/web/src/routes/setup.tsx | 62 ++- apps/web/src/styles/globals.css | 2 +- apps/web/vite.config.ts | 4 +- biome.json | 94 ----- bun.lock | 315 ++++++++++----- docs/.oxfmtrc.json | 23 ++ docs/.oxlintrc.json | 14 + docs/README.md | 9 +- docs/biome.json | 48 --- docs/bun.lock | 219 ++++++---- docs/content/docs/admin.mdx | 18 +- docs/content/docs/configuration.mdx | 92 ++--- docs/content/docs/integrations/import.mdx | 20 +- docs/content/docs/integrations/index.mdx | 2 +- docs/content/docs/mobile-app.mdx | 3 +- docs/content/docs/telemetry.mdx | 12 +- docs/content/docs/using-sofa.mdx | 34 +- docs/package.json | 17 +- docs/public/openapi.json | 15 +- docs/scripts/generate-api-docs.ts | 1 + docs/src/app/(home)/layout.tsx | 10 +- docs/src/app/(home)/page.tsx | 56 ++- docs/src/app/(home)/privacy/page.tsx | 161 +++----- docs/src/app/api/search/route.ts | 1 + docs/src/app/docs/[[...slug]]/page.tsx | 11 +- docs/src/app/docs/layout.tsx | 1 + .../app/llms.mdx/docs/[[...slug]]/route.ts | 6 +- docs/src/app/llms.txt/route.ts | 1 + docs/src/app/og/docs/[...slug]/route.tsx | 10 +- docs/src/components/api-page.tsx | 2 + docs/src/components/mdx.tsx | 1 + docs/src/components/scroll-indicator.tsx | 7 +- docs/src/lib/layout.shared.tsx | 1 + docs/src/lib/source.ts | 3 +- docs/src/proxy.ts | 5 +- package.json | 19 +- packages/api/.oxlintrc.json | 4 + packages/api/package.json | 5 +- packages/api/src/contract.ts | 52 +-- packages/api/src/schemas.ts | 382 ++++-------------- packages/auth/.oxlintrc.json | 4 + packages/auth/package.json | 5 +- packages/auth/src/server.ts | 21 +- packages/config/.oxlintrc.json | 4 + packages/config/package.json | 6 + packages/config/src/index.ts | 9 +- packages/core/.oxlintrc.json | 4 + packages/core/package.json | 5 +- packages/core/src/availability.ts | 18 +- packages/core/src/backup.ts | 47 +-- packages/core/src/cache.ts | 18 +- packages/core/src/colors.ts | 17 +- packages/core/src/credits.ts | 9 +- packages/core/src/discovery.ts | 74 +--- packages/core/src/image-cache.ts | 65 +-- packages/core/src/imports/index.ts | 6 +- packages/core/src/imports/parsers.ts | 45 +-- packages/core/src/imports/processor.ts | 125 ++---- packages/core/src/imports/resolve.ts | 8 +- packages/core/src/lists.ts | 7 +- packages/core/src/metadata.ts | 202 ++------- packages/core/src/person.ts | 58 +-- packages/core/src/providers.ts | 5 +- packages/core/src/settings.ts | 6 +- packages/core/src/system-health.ts | 29 +- packages/core/src/telemetry.ts | 11 +- packages/core/src/thumbhash.ts | 52 +-- packages/core/src/tracking.ts | 153 ++----- packages/core/src/update-check.ts | 12 +- packages/core/src/webhooks.ts | 68 +--- packages/core/test/backup.test.ts | 38 +- packages/core/test/credits.test.ts | 8 +- packages/core/test/discovery.test.ts | 2 + packages/core/test/image-cache.test.ts | 1 + packages/core/test/import-parsers.test.ts | 53 +-- packages/core/test/import-processor.test.ts | 18 +- packages/core/test/import-resolve.test.ts | 30 +- packages/core/test/lists.test.ts | 19 +- packages/core/test/metadata-refresh.test.ts | 36 +- packages/core/test/metadata.test.ts | 8 +- packages/core/test/person-detail.test.ts | 29 +- packages/core/test/person.test.ts | 7 +- packages/core/test/preload.ts | 1 + packages/core/test/settings.test.ts | 9 +- packages/core/test/thumbhash.test.ts | 27 +- packages/core/test/tracking.test.ts | 178 ++------ packages/core/test/update-check.test.ts | 1 + packages/db/.oxlintrc.json | 5 + packages/db/drizzle.config.ts | 5 +- packages/db/package.json | 5 +- packages/db/src/client.ts | 7 +- packages/db/src/migrate.ts | 9 +- packages/db/src/schema.ts | 79 +--- packages/db/src/test-utils.ts | 54 +-- packages/i18n/.oxlintrc.json | 5 + packages/i18n/package.json | 5 +- packages/i18n/src/format.ts | 13 +- packages/i18n/src/index.ts | 1 + packages/i18n/src/test-utils.tsx | 1 + packages/logger/.oxlintrc.json | 4 + packages/logger/package.json | 5 +- packages/logger/src/index.ts | 5 +- packages/tmdb/.oxlintrc.json | 5 + packages/tmdb/package.json | 5 +- packages/tmdb/src/client.ts | 110 ++--- packages/tmdb/src/image.ts | 16 +- turbo.json | 1 + 359 files changed, 3894 insertions(+), 7208 deletions(-) create mode 100644 .oxfmtrc.json create mode 100644 .oxlintrc.json create mode 100644 apps/native/.oxlintrc.json create mode 100644 apps/public-api/.oxlintrc.json create mode 100644 apps/server/.oxlintrc.json create mode 100644 apps/web/.oxlintrc.json delete mode 100644 biome.json create mode 100644 docs/.oxfmtrc.json create mode 100644 docs/.oxlintrc.json delete mode 100644 docs/biome.json create mode 100644 packages/api/.oxlintrc.json create mode 100644 packages/auth/.oxlintrc.json create mode 100644 packages/config/.oxlintrc.json create mode 100644 packages/core/.oxlintrc.json create mode 100644 packages/db/.oxlintrc.json create mode 100644 packages/i18n/.oxlintrc.json create mode 100644 packages/logger/.oxlintrc.json create mode 100644 packages/tmdb/.oxlintrc.json diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd..a639c3f 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -36,9 +36,8 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + plugin_marketplaces: "https://github.com/anthropics/claude-code.git" + plugins: "code-review@claude-code-plugins" + prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}" # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267..9471a05 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -47,4 +47,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 886b1b6..18dbef1 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -11,7 +11,8 @@ on: - "turbo.json" - "package.json" - "bun.lock" - - "biome.json" + - ".oxlintrc.json" + - ".oxfmtrc.json" - "tsconfig.json" - ".github/workflows/docker.yml" tags: ["v*"] diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fa4b71d..1b42888 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -25,6 +30,9 @@ jobs: - name: Lint run: bunx turbo run lint + - name: Format check + run: bunx turbo run format:check + - name: Type check run: bunx turbo run check-types diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..b1435fd --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,54 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "sortImports": { + "groups": ["builtin", "external", "internal", "parent", "sibling", "index"], + "internalPattern": ["@/", "@sofa/"], + "newlinesBetween": true, + "order": "asc" + }, + "sortTailwindcss": { + "attributes": [ + "classList", + "headerClassName", + "contentContainerClassName", + "columnWrapperClassName", + "endFillColorClassName", + "imageClassName", + "tintColorClassName", + "ios_backgroundColorClassName", + "thumbColorClassName", + "trackColorOnClassName", + "trackColorOffClassName", + "selectionColorClassName", + "cursorColorClassName", + "underlineColorAndroidClassName", + "placeholderTextColorClassName", + "selectionHandleColorClassName", + "colorsClassName", + "progressBackgroundColorClassName", + "titleColorClassName", + "underlayColorClassName", + "colorClassName", + "backdropColorClassName", + "backgroundColorClassName", + "statusBarBackgroundColorClassName", + "drawerBackgroundColorClassName", + "ListFooterComponentClassName", + "ListHeaderComponentClassName" + ], + "functions": ["cn", "clsx", "cva", "useResolveClassNames"] + }, + "ignorePatterns": [ + "node_modules", + "dist", + "build", + "packages/db/drizzle", + "packages/i18n/src/po/*.ts", + "packages/tmdb/src/schema.d.ts", + "**/routeTree.gen.ts", + "apps/native/.expo/types/**/*.ts", + "apps/native/expo-env.d.ts", + "apps/native/uniwind-types.d.ts", + "docs" + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..01a914b --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,18 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "import"], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "rules": { + "react/react-in-jsx-scope": "off", + "react/style-prop-object": "off", + "import/no-unassigned-import": "off", + "import/no-named-as-default-member": "off", + "unicorn/no-array-sort": "off", + "unicorn/consistent-function-scoping": "off", + "no-new": "off" + }, + "ignorePatterns": ["node_modules", "dist", "build", "docs", "**/routeTree.gen.ts"] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 4470bfb..7f7921d 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,8 @@ { "recommendations": [ - "biomejs.biome", "bradlc.vscode-tailwindcss", "expo.vscode-expo-tools", + "oxc.oxc-vscode", "vercel.turbo-vsc", "vitest.explorer" ] diff --git a/.vscode/settings.json b/.vscode/settings.json index a286461..c47b9d2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,10 @@ { "typescript.tsdk": "node_modules/typescript/lib", - "editor.defaultFormatter": "biomejs.biome", + "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true, "editor.formatOnPaste": true, "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit", - "source.organizeImports.biome": "explicit" + "source.fixAll.oxlint": "explicit" }, "files.readonlyInclude": { "**/routeTree.gen.ts": true @@ -18,25 +17,25 @@ }, "emmet.showExpandedAbbreviation": "never", "[javascript]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[javascriptreact]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[json]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[jsonc]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[css]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "tailwindCSS.classAttributes": [ "class", diff --git a/AGENTS.md b/AGENTS.md index b803005..78caeaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,8 @@ # Root commands (via Turborepo) bun run dev # Start API server + Vite dev server bun run build # Production build (both apps) -bun run lint # Biome lint check -bun run format # Biome format (auto-fix) +bun run lint # Oxlint lint check +bun run format # Oxfmt format (auto-fix) bun run check-types # TypeScript type check bun run test # Run tests bun run generate:openapi # Regenerate OpenAPI spec + docs API pages (run after contract/schema changes) @@ -51,8 +51,9 @@ couch-potato/ │ ├── db/ # @sofa/db — Drizzle schema, client, migrations (JIT) │ ├── logger/ # @sofa/logger — Pino-based structured logging (JIT) │ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper (JIT) +├── .oxlintrc.json +├── .oxfmtrc.json ├── turbo.json -├── biome.json ├── Dockerfile └── package.json ``` @@ -76,7 +77,7 @@ All shared packages are JIT (raw TypeScript exports, no build step). - **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO - **Monorepo**: Turborepo with Bun workspaces - **Docs**: Fumadocs (Next.js), fumadocs-openapi for API reference -- **Linting**: Biome (2-space indent, organized imports) +- **Linting**: Oxlint + Oxfmt (2-space indent, organized imports, Tailwind class sorting) - **External API**: TMDB (The Movie Database) ### Package imports @@ -84,6 +85,7 @@ All shared packages are JIT (raw TypeScript exports, no build step). Path aliases: `@/*` maps to `src/` in both `apps/web/` and `apps/native/`. Cross-package imports: + - `@sofa/api/contract`, `@sofa/api/schemas` — Contract and Zod types - `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/helpers`, `@sofa/db/migrate`, `@sofa/db/test-utils` - `@sofa/tmdb/client`, `@sofa/tmdb/image` @@ -107,6 +109,7 @@ Cross-package imports: Required: `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. Optional: + - `DATA_DIR` — Root for DB + cache (default `./data`). `DATABASE_URL` and `CACHE_DIR` derived from it but overridable. - `TMDB_API_BASE_URL`, `TMDB_IMAGE_BASE_URL` — Override TMDB endpoints. - `PUBLIC_API_URL` — Base URL for centralized public API (default: `https://public-api.sofa.watch`). Used for update checks and OAuth import proxy. diff --git a/README.md b/README.md index 204f367..c76950f 100644 --- a/README.md +++ b/README.md @@ -82,20 +82,20 @@ Set `BETTER_AUTH_URL` to the real external URL of your instance. This especially ## Configuration -| Variable | Required | Notes | -| --- | --- | --- | -| `TMDB_API_READ_ACCESS_TOKEN` | Yes | TMDB metadata and discovery | -| `BETTER_AUTH_SECRET` | Yes | Session and auth secret | -| `BETTER_AUTH_URL` | Yes | Public base URL of the app | -| `DATA_DIR` | No | Root data directory. Defaults to `/data` in the container | -| `IMAGE_CACHE_ENABLED` | No | Defaults to enabled. Set to `false` to use TMDB images directly instead of caching them locally | -| `LOG_LEVEL` | No | `error`, `warn`, `info`, or `debug` | -| `OIDC_CLIENT_ID` | No | Enable OIDC when set with the matching secret and issuer | -| `OIDC_CLIENT_SECRET` | No | OIDC client secret | -| `OIDC_ISSUER_URL` | No | OIDC issuer URL | -| `OIDC_PROVIDER_NAME` | No | Login button label. Defaults to `SSO` | -| `OIDC_AUTO_REGISTER` | No | Defaults to `true` | -| `DISABLE_PASSWORD_LOGIN` | No | Set to `true` to hide email/password login when OIDC is configured | +| Variable | Required | Notes | +| ---------------------------- | -------- | ----------------------------------------------------------------------------------------------- | +| `TMDB_API_READ_ACCESS_TOKEN` | Yes | TMDB metadata and discovery | +| `BETTER_AUTH_SECRET` | Yes | Session and auth secret | +| `BETTER_AUTH_URL` | Yes | Public base URL of the app | +| `DATA_DIR` | No | Root data directory. Defaults to `/data` in the container | +| `IMAGE_CACHE_ENABLED` | No | Defaults to enabled. Set to `false` to use TMDB images directly instead of caching them locally | +| `LOG_LEVEL` | No | `error`, `warn`, `info`, or `debug` | +| `OIDC_CLIENT_ID` | No | Enable OIDC when set with the matching secret and issuer | +| `OIDC_CLIENT_SECRET` | No | OIDC client secret | +| `OIDC_ISSUER_URL` | No | OIDC issuer URL | +| `OIDC_PROVIDER_NAME` | No | Login button label. Defaults to `SSO` | +| `OIDC_AUTO_REGISTER` | No | Defaults to `true` | +| `DISABLE_PASSWORD_LOGIN` | No | Set to `true` to hide email/password login when OIDC is configured | See [`.env.example`](./.env.example) for the full list. diff --git a/apps/native/.oxlintrc.json b/apps/native/.oxlintrc.json new file mode 100644 index 0000000..1f7d4a5 --- /dev/null +++ b/apps/native/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "$schema": "../../node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.json"], + "ignorePatterns": ["node_modules", ".expo/types/**/*.ts", "expo-env.d.ts", "uniwind-types.d.ts"] +} diff --git a/apps/native/metro.config.js b/apps/native/metro.config.js index 4cff2ac..603f010 100644 --- a/apps/native/metro.config.js +++ b/apps/native/metro.config.js @@ -1,8 +1,6 @@ const { getDefaultConfig } = require("expo/metro-config"); const { withUniwindConfig } = require("uniwind/metro"); -const { - wrapWithReanimatedMetroConfig, -} = require("react-native-reanimated/metro-config"); +const { wrapWithReanimatedMetroConfig } = require("react-native-reanimated/metro-config"); /** @type {import('expo/metro-config').MetroConfig} */ const config = getDefaultConfig(__dirname); diff --git a/apps/native/package.json b/apps/native/package.json index 0c5aa15..b996bd2 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -9,8 +9,9 @@ "android": "expo run:android", "ios": "expo run:ios", "prebuild": "expo prebuild", - "lint": "biome check", - "format": "biome format --write", + "lint": "oxlint", + "format": "oxfmt --config ../../.oxfmtrc.json", + "format:check": "oxfmt --check --config ../../.oxfmtrc.json", "check-types": "tsc --noEmit" }, "dependencies": { @@ -35,10 +36,10 @@ "@sofa/api": "workspace:*", "@sofa/i18n": "workspace:*", "@tabler/icons-react-native": "3.40.0", - "@tanstack/query-async-storage-persister": "5.90.24", + "@tanstack/query-async-storage-persister": "5.90.25", "@tanstack/react-form": "1.28.5", "@tanstack/react-query": "catalog:", - "@tanstack/react-query-persist-client": "5.90.24", + "@tanstack/react-query-persist-client": "5.90.25", "better-auth": "catalog:", "burnt": "0.13.0", "expo": "55.0.7", @@ -63,7 +64,7 @@ "expo-system-ui": "55.0.10", "expo-tracking-transparency": "55.0.9", "expo-web-browser": "55.0.10", - "posthog-react-native": "4.37.3", + "posthog-react-native": "4.37.4", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.2", diff --git a/apps/native/src/app/(auth)/_layout.tsx b/apps/native/src/app/(auth)/_layout.tsx index 2f6f436..cff370a 100644 --- a/apps/native/src/app/(auth)/_layout.tsx +++ b/apps/native/src/app/(auth)/_layout.tsx @@ -1,12 +1,11 @@ import { Stack } from "expo-router"; import { useResolveClassNames } from "uniwind"; + import { hasStoredServerUrl } from "@/lib/server"; export const unstable_settings = { initialRouteName: - process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl() - ? "login" - : "server-url", + process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl() ? "login" : "server-url", }; export default function AuthLayout() { diff --git a/apps/native/src/app/(auth)/login.tsx b/apps/native/src/app/(auth)/login.tsx index fa0b39f..037d152 100644 --- a/apps/native/src/app/(auth)/login.tsx +++ b/apps/native/src/app/(auth)/login.tsx @@ -8,6 +8,7 @@ import { Pressable, type TextInput, View } from "react-native"; import Animated, { FadeIn, FadeInDown } from "react-native-reanimated"; import { useCSSVariable } from "uniwind"; import { z } from "zod"; + import { AuthScreen } from "@/components/auth-screen"; import { Button, ButtonLabel } from "@/components/ui/button"; import { ScaledIcon } from "@/components/ui/scaled-icon"; @@ -22,11 +23,7 @@ import { getFormErrors } from "@/utils/form-errors"; import * as Haptics from "@/utils/haptics"; const signInSchema = z.object({ - email: z - .string() - .trim() - .min(1, "Email is required") - .email("Enter a valid email address"), + email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"), password: z.string().min(1, "Password is required"), }); @@ -68,9 +65,7 @@ export default function LoginScreen() { const isSubmitting = useStore(form.store, (s) => s.isSubmitting); const busy = isSubmitting || isSignedIn; - const statusCompletedColor = useCSSVariable( - "--color-status-completed", - ) as string; + const statusCompletedColor = useCSSVariable("--color-status-completed") as string; const serverHost = splitUrl(getServerUrl()).host; const showPasswordLogin = !authConfig.data?.passwordLoginDisabled; @@ -90,10 +85,7 @@ export default function LoginScreen() { return ( {showOidc && ( - + {showPasswordLogin && ( - - + + OR - + )} @@ -148,11 +138,7 @@ export default function LoginScreen() { returnKeyType="next" blurOnSubmit={false} onSubmitEditing={() => passwordRef.current?.focus()} - className={ - errorFields.has("email") - ? "border-destructive" - : undefined - } + className={errorFields.has("email") ? "border-destructive" : undefined} /> )} @@ -181,11 +167,7 @@ export default function LoginScreen() { textContentType="password" returnKeyType="go" onSubmitEditing={form.handleSubmit} - className={ - errorFields.has("password") - ? "border-destructive" - : undefined - } + className={errorFields.has("password") ? "border-destructive" : undefined} /> )} @@ -193,11 +175,7 @@ export default function LoginScreen() { - )} diff --git a/apps/native/src/components/ui/expandable-text.tsx b/apps/native/src/components/ui/expandable-text.tsx index 5efc6bc..1451d4a 100644 --- a/apps/native/src/components/ui/expandable-text.tsx +++ b/apps/native/src/components/ui/expandable-text.tsx @@ -2,6 +2,7 @@ import { useLingui } from "@lingui/react/macro"; import { useCallback, useRef, useState } from "react"; import type { NativeSyntheticEvent, TextLayoutEventData } from "react-native"; import { Platform, Pressable, View } from "react-native"; + import { Text } from "@/components/ui/text"; export function ExpandableText({ @@ -63,7 +64,7 @@ export function ExpandableText({ className="mt-1" > {expanded ? t`Show less` : t`Show more`} diff --git a/apps/native/src/components/ui/image.tsx b/apps/native/src/components/ui/image.tsx index de7ca55..b182f58 100644 --- a/apps/native/src/components/ui/image.tsx +++ b/apps/native/src/components/ui/image.tsx @@ -1,5 +1,6 @@ import { Image as ExpoImage, type ImageProps } from "expo-image"; import { useResolveClassNames } from "uniwind"; + import { resolveUrl } from "@/lib/server"; export function Image({ diff --git a/apps/native/src/components/ui/offline-banner.ios.tsx b/apps/native/src/components/ui/offline-banner.ios.tsx index 3b9abb0..be09599 100644 --- a/apps/native/src/components/ui/offline-banner.ios.tsx +++ b/apps/native/src/components/ui/offline-banner.ios.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react"; import { View } from "react-native"; import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; + import { ScaledIcon } from "@/components/ui/scaled-icon"; import { Text } from "@/components/ui/text"; import * as Haptics from "@/utils/haptics"; @@ -70,14 +71,14 @@ export function OfflineBanner() { }} > - + No internet connection ) : ( - + - + No internet connection diff --git a/apps/native/src/components/ui/offline-banner.tsx b/apps/native/src/components/ui/offline-banner.tsx index 622d5ec..ac9cdc4 100644 --- a/apps/native/src/components/ui/offline-banner.tsx +++ b/apps/native/src/components/ui/offline-banner.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react"; import { View } from "react-native"; import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; + import { ScaledIcon } from "@/components/ui/scaled-icon"; import { Text } from "@/components/ui/text"; import * as Haptics from "@/utils/haptics"; @@ -53,9 +54,9 @@ export function OfflineBanner() { zIndex: 100, }} > - + - + No internet connection diff --git a/apps/native/src/components/ui/poster-card.tsx b/apps/native/src/components/ui/poster-card.tsx index c65d739..ef67720 100644 --- a/apps/native/src/components/ui/poster-card.tsx +++ b/apps/native/src/components/ui/poster-card.tsx @@ -15,6 +15,7 @@ import { Pressable, View } from "react-native"; import { GestureDetector } from "react-native-gesture-handler"; import Animated from "react-native-reanimated"; import { useCSSVariable } from "uniwind"; + import { Image } from "@/components/ui/image"; import { ScaledIcon } from "@/components/ui/scaled-icon"; import { Skeleton } from "@/components/ui/skeleton"; @@ -83,18 +84,13 @@ export function PosterCard({ : userStatus === "watchlist" ? "on watchlist" : undefined; - const cardAccessibilityLabel = [ - title, - type === "movie" ? "movie" : "TV show", - year, - statusLabel, - ] + const cardAccessibilityLabel = [title, type === "movie" ? "movie" : "TV show", year, statusLabel] .filter(Boolean) .join(", "); const cardContent = ( ) : ( - - - {title} - + + {title} )} @@ -136,21 +130,19 @@ export function PosterCard({ )} {/* Episode progress bar */} - {episodeProgress && - episodeProgress.total > 0 && - episodeProgress.watched > 0 && ( + {episodeProgress && episodeProgress.total > 0 && episodeProgress.watched > 0 && ( + - 0 ? (episodeProgress.watched / episodeProgress.total) * 100 : 0}%`, - }} - /> - - )} + className="bg-status-watching h-full" + style={{ + width: `${episodeProgress.total > 0 ? (episodeProgress.watched / episodeProgress.total) * 100 : 0}%`, + }} + /> + + )} {/* Metadata */} @@ -162,10 +154,7 @@ export function PosterCard({ style={{ backgroundColor: statusColors[userStatus] }} /> )} - + {title} @@ -175,19 +164,11 @@ export function PosterCard({ ) : ( )} - {year ? ( - {year} - ) : null} + {year ? {year} : null} {voteAverage != null && voteAverage > 0 && ( - - - {voteAverage.toFixed(1)} - + + {voteAverage.toFixed(1)} )} @@ -207,11 +188,7 @@ export function PosterCard({ className="size-[30px] items-center justify-center rounded-full" style={{ backgroundColor: "rgba(0,0,0,0.5)" }} > - {isAdding ? ( - - ) : ( - - )} + {isAdding ? : } ) : null; @@ -224,10 +201,7 @@ export function PosterCard({ - + {cardContent} @@ -275,7 +249,7 @@ export function PosterCardSkeleton({ width = 140 }: { width?: number }) { const imageHeight = width * 1.5; return ( ; diff --git a/apps/native/src/components/ui/section-header.tsx b/apps/native/src/components/ui/section-header.tsx index d4f722a..8fe81c4 100644 --- a/apps/native/src/components/ui/section-header.tsx +++ b/apps/native/src/components/ui/section-header.tsx @@ -1,6 +1,7 @@ import type { Icon } from "@tabler/icons-react-native"; import { View } from "react-native"; import { useCSSVariable } from "uniwind"; + import { ScaledIcon } from "@/components/ui/scaled-icon"; import { Text } from "@/components/ui/text"; @@ -10,22 +11,14 @@ interface SectionHeaderProps { iconColor?: string; } -export function SectionHeader({ - title, - icon: IconComponent, - iconColor, -}: SectionHeaderProps) { +export function SectionHeader({ title, icon: IconComponent, iconColor }: SectionHeaderProps) { const primaryColor = useCSSVariable("--color-primary") as string; const resolvedColor = iconColor ?? primaryColor; return ( - {IconComponent && ( - - )} - - {title} - + {IconComponent && } + {title} ); } diff --git a/apps/native/src/components/ui/select-modal.tsx b/apps/native/src/components/ui/select-modal.tsx index e1c27cd..2884789 100644 --- a/apps/native/src/components/ui/select-modal.tsx +++ b/apps/native/src/components/ui/select-modal.tsx @@ -3,6 +3,7 @@ import { Modal, Pressable, View } from "react-native"; import { useCSSVariable } from "uniwind"; import { Text } from "@/components/ui/text"; + import { ScaledIcon } from "./scaled-icon"; export interface SelectModalOption { @@ -51,34 +52,30 @@ export function SelectModal({ onPress={() => onOpenChange(false)} > e.stopPropagation()} > - + {Icon && } - - {label} - + {label} {options.map((option) => { const isSelected = option.value === selection; return ( { onOpenChange(false); onSelect(option.value); }} > {option.label} - {isSelected && ( - - )} + {isSelected && } ); })} diff --git a/apps/native/src/components/ui/server-unreachable-banner.ios.tsx b/apps/native/src/components/ui/server-unreachable-banner.ios.tsx index 998574c..f04ee29 100644 --- a/apps/native/src/components/ui/server-unreachable-banner.ios.tsx +++ b/apps/native/src/components/ui/server-unreachable-banner.ios.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; + import { ScaledIcon } from "@/components/ui/scaled-icon"; import { Text } from "@/components/ui/text"; import { queryClient } from "@/lib/query-client"; @@ -62,14 +63,11 @@ export function ServerUnreachableBanner() { const content = ( <> - + Can't reach server - - + + Retry @@ -106,7 +104,7 @@ export function ServerUnreachableBanner() { {content} ) : ( - + {content} )} diff --git a/apps/native/src/components/ui/server-unreachable-banner.tsx b/apps/native/src/components/ui/server-unreachable-banner.tsx index 077b534..e3938e6 100644 --- a/apps/native/src/components/ui/server-unreachable-banner.tsx +++ b/apps/native/src/components/ui/server-unreachable-banner.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; + import { ScaledIcon } from "@/components/ui/scaled-icon"; import { Text } from "@/components/ui/text"; import { queryClient } from "@/lib/query-client"; @@ -68,16 +69,13 @@ export function ServerUnreachableBanner() { zIndex: 100, }} > - + - + Can't reach server - - + + Retry diff --git a/apps/native/src/components/ui/skeleton.tsx b/apps/native/src/components/ui/skeleton.tsx index c2cddf3..60e2720 100644 --- a/apps/native/src/components/ui/skeleton.tsx +++ b/apps/native/src/components/ui/skeleton.tsx @@ -7,7 +7,6 @@ import Animated, { withRepeat, withTiming, } from "react-native-reanimated"; - import { useCSSVariable } from "uniwind"; interface SkeletonProps { @@ -17,12 +16,7 @@ interface SkeletonProps { style?: ViewStyle; } -export function Skeleton({ - width, - height = 14, - borderRadius = 4, - style, -}: SkeletonProps) { +export function Skeleton({ width, height = 14, borderRadius = 4, style }: SkeletonProps) { const secondaryColor = useCSSVariable("--color-secondary") as string; const reduceMotion = useReducedMotion(); const opacity = useSharedValue(reduceMotion ? 0.7 : 0.4); diff --git a/apps/native/src/components/ui/spinner.tsx b/apps/native/src/components/ui/spinner.tsx index 0c15961..72c44b0 100644 --- a/apps/native/src/components/ui/spinner.tsx +++ b/apps/native/src/components/ui/spinner.tsx @@ -8,10 +8,6 @@ interface SpinnerProps extends Omit { export function Spinner({ size = "default", ...props }: SpinnerProps) { return ( - + ); } diff --git a/apps/native/src/components/ui/star-rating.tsx b/apps/native/src/components/ui/star-rating.tsx index eaa0910..3e7bc9a 100644 --- a/apps/native/src/components/ui/star-rating.tsx +++ b/apps/native/src/components/ui/star-rating.tsx @@ -1,6 +1,7 @@ import { IconStar, IconStarFilled } from "@tabler/icons-react-native"; import { Pressable, View } from "react-native"; import { useCSSVariable } from "uniwind"; + import * as Haptics from "@/utils/haptics"; interface StarRatingProps { diff --git a/apps/native/src/components/ui/swipeable-row.tsx b/apps/native/src/components/ui/swipeable-row.tsx index 66d5410..a42b638 100644 --- a/apps/native/src/components/ui/swipeable-row.tsx +++ b/apps/native/src/components/ui/swipeable-row.tsx @@ -3,10 +3,8 @@ import type { PropsWithChildren } from "react"; import { View } from "react-native"; import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable"; import type { SharedValue } from "react-native-reanimated"; -import Animated, { - interpolate, - useAnimatedStyle, -} from "react-native-reanimated"; +import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated"; + import { ScaledIcon } from "@/components/ui/scaled-icon"; import * as Haptics from "@/utils/haptics"; @@ -22,10 +20,7 @@ function RightAction({ drag }: { drag: SharedValue }) { }); return ( - + diff --git a/apps/native/src/components/ui/switch.tsx b/apps/native/src/components/ui/switch.tsx index 6d36d24..e863f18 100644 --- a/apps/native/src/components/ui/switch.tsx +++ b/apps/native/src/components/ui/switch.tsx @@ -24,13 +24,7 @@ export function Switch({ disabled={disabled} accessibilityLabel={accessibilityLabel} ios_backgroundColor={secondaryColor} - thumbColor={ - process.env.EXPO_OS === "ios" - ? undefined - : value - ? "#fff" - : mutedFgColor - } + thumbColor={process.env.EXPO_OS === "ios" ? undefined : value ? "#fff" : mutedFgColor} trackColor={{ false: secondaryColor, true: primaryColor }} style={{ transform: [{ scaleX: switchScale }, { scaleY: switchScale }], diff --git a/apps/native/src/components/ui/text-field.tsx b/apps/native/src/components/ui/text-field.tsx index b5d69d1..52722f9 100644 --- a/apps/native/src/components/ui/text-field.tsx +++ b/apps/native/src/components/ui/text-field.tsx @@ -1,18 +1,7 @@ -import { - createContext, - forwardRef, - type PropsWithChildren, - useContext, - useId, -} from "react"; -import { - TextInput, - type TextInputProps, - type TextProps, - View, -} from "react-native"; -import { Text } from "@/components/ui/text"; +import { createContext, forwardRef, type PropsWithChildren, useContext, useId } from "react"; +import { TextInput, type TextInputProps, type TextProps, View } from "react-native"; +import { Text } from "@/components/ui/text"; import { cn } from "@/utils/cn"; const TextFieldContext = createContext(undefined); @@ -26,40 +15,35 @@ export function TextField({ children }: PropsWithChildren) { ); } -export function Label({ - className, - nativeID, - ...props -}: TextProps & { className?: string }) { +export function Label({ className, nativeID, ...props }: TextProps & { className?: string }) { const ctxId = useContext(TextFieldContext); return ( ); } -export const Input = forwardRef< - TextInput, - TextInputProps & { className?: string } ->(({ className, style, ...props }, ref) => { - const ctxId = useContext(TextFieldContext); - return ( - - ); -}); +export const Input = forwardRef( + ({ className, style, ...props }, ref) => { + const ctxId = useContext(TextFieldContext); + return ( + + ); + }, +); Input.displayName = "Input"; @@ -71,11 +55,7 @@ export function FieldError({ }: PropsWithChildren) { if (!isInvalid) return null; return ( - + {children} ); diff --git a/apps/native/src/hooks/use-press-animation.ts b/apps/native/src/hooks/use-press-animation.ts index 9b1ee37..0791bb6 100644 --- a/apps/native/src/hooks/use-press-animation.ts +++ b/apps/native/src/hooks/use-press-animation.ts @@ -20,9 +20,7 @@ export function usePressAnimation(scale = 0.97) { const animatedStyle = useAnimatedStyle(() => ({ transform: [ { - scale: reduceMotion - ? 1 - : interpolate(pressed.get(), [0, 1], [1, scale]), + scale: reduceMotion ? 1 : interpolate(pressed.get(), [0, 1], [1, scale]), }, ], })); diff --git a/apps/native/src/hooks/use-server-connection.ts b/apps/native/src/hooks/use-server-connection.ts index 8b711de..a4b01b8 100644 --- a/apps/native/src/hooks/use-server-connection.ts +++ b/apps/native/src/hooks/use-server-connection.ts @@ -1,10 +1,7 @@ import { useRouter } from "expo-router"; import { useEffect, useRef, useState } from "react"; -import { - clearStorageScope, - hasScopedStorage, - setStorageScope, -} from "@/lib/mmkv"; + +import { clearStorageScope, hasScopedStorage, setStorageScope } from "@/lib/mmkv"; import { queryClient } from "@/lib/query-client"; import { authClient, @@ -43,8 +40,7 @@ export function useServerConnection() { ); const { data: session, isPending, isRefetching } = authClient.useSession(); - const hasServerUrl = - !!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl(); + const hasServerUrl = !!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl(); // --- Instance ID resolution --- const [instanceId, setInstanceId] = useState(getCurrentInstanceId); @@ -61,10 +57,7 @@ export function useServerConnection() { }, [instanceId, hasServerUrl]); // Re-sync instanceId when server URL changes - useEffect( - () => onServerUrlChange(() => setInstanceId(getCurrentInstanceId())), - [], - ); + useEffect(() => onServerUrlChange(() => setInstanceId(getCurrentInstanceId())), []); // --- Scoped storage (per instance + user) --- const userId = session?.user?.id; diff --git a/apps/native/src/hooks/use-title-actions.ts b/apps/native/src/hooks/use-title-actions.ts index e0635e2..e1b12b3 100644 --- a/apps/native/src/hooks/use-title-actions.ts +++ b/apps/native/src/hooks/use-title-actions.ts @@ -1,6 +1,7 @@ import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { useMutation } from "@tanstack/react-query"; + import { orpc } from "@/lib/orpc"; import { queryClient } from "@/lib/query-client"; import { invalidateTitleQueries } from "@/lib/title-actions"; @@ -40,9 +41,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) { const quickAdd = useMutation( orpc.titles.quickAdd.mutationOptions({ onSuccess: (_data, input) => { - toast.success( - resolveToast(toastOverrides?.quickAdd, t`Added to watchlist`, input), - ); + toast.success(resolveToast(toastOverrides?.quickAdd, t`Added to watchlist`, input)); invalidateTitleQueries(); }, onError: () => { @@ -64,9 +63,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) { const defaultMsg = input.status ? (statusMessages[input.status] ?? t`Status updated`) : t`Removed from library`; - toast.success( - resolveToast(toastOverrides?.updateStatus, defaultMsg, input), - ); + toast.success(resolveToast(toastOverrides?.updateStatus, defaultMsg, input)); invalidateTitleQueries(); }, onError: () => toast.error(t`Failed to update status`), @@ -76,9 +73,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) { const watchMovie = useMutation( orpc.titles.watchMovie.mutationOptions({ onSuccess: (_data, input) => { - toast.success( - resolveToast(toastOverrides?.watchMovie, t`Marked as watched`, input), - ); + toast.success(resolveToast(toastOverrides?.watchMovie, t`Marked as watched`, input)); invalidateTitleQueries(); }, onError: () => toast.error(t`Failed to mark as watched`), @@ -92,9 +87,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) { input.stars > 0 ? t`Rated ${input.stars} ${plural(input.stars, { one: "star", other: "stars" })}` : t`Rating removed`; - toast.success( - resolveToast(toastOverrides?.updateRating, defaultMsg, input), - ); + toast.success(resolveToast(toastOverrides?.updateRating, defaultMsg, input)); // Rating only invalidates title queries, not dashboard queryClient.invalidateQueries({ queryKey: orpc.titles.key() }); }, @@ -105,9 +98,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) { const watchEpisode = useMutation( orpc.episodes.watch.mutationOptions({ onSuccess: (_data, input) => { - toast.success( - resolveToast(toastOverrides?.watchEpisode, t`Episode watched`, input), - ); + toast.success(resolveToast(toastOverrides?.watchEpisode, t`Episode watched`, input)); invalidateTitleQueries(); }, onError: () => toast.error(t`Failed to mark episode`), @@ -117,13 +108,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) { const unwatchEpisode = useMutation( orpc.episodes.unwatch.mutationOptions({ onSuccess: (_data, input) => { - toast.success( - resolveToast( - toastOverrides?.unwatchEpisode, - t`Episode unwatched`, - input, - ), - ); + toast.success(resolveToast(toastOverrides?.unwatchEpisode, t`Episode unwatched`, input)); invalidateTitleQueries(); }, onError: () => toast.error(t`Failed to unmark episode`), @@ -133,9 +118,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) { const watchSeason = useMutation( orpc.seasons.watch.mutationOptions({ onSuccess: (_data, input) => { - toast.success( - resolveToast(toastOverrides?.watchSeason, t`Season watched`, input), - ); + toast.success(resolveToast(toastOverrides?.watchSeason, t`Season watched`, input)); invalidateTitleQueries(); }, onError: () => toast.error(t`Failed to mark some episodes`), diff --git a/apps/native/src/hooks/use-title-theme.ts b/apps/native/src/hooks/use-title-theme.ts index a8e27c8..6778a60 100644 --- a/apps/native/src/hooks/use-title-theme.ts +++ b/apps/native/src/hooks/use-title-theme.ts @@ -1,13 +1,13 @@ -import type { ColorPalette } from "@sofa/api/schemas"; import { useEffect } from "react"; import { Uniwind } from "uniwind"; +import type { ColorPalette } from "@sofa/api/schemas"; + function hexToRelativeLuminance(hex: string): number { const r = Number.parseInt(hex.slice(1, 3), 16) / 255; const g = Number.parseInt(hex.slice(3, 5), 16) / 255; const b = Number.parseInt(hex.slice(5, 7), 16) / 255; - const toLinear = (c: number) => - c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); } @@ -18,8 +18,7 @@ export function useTitleTheme(palette: ColorPalette | null | undefined): void { if (!vibrant) return; const luminance = hexToRelativeLuminance(vibrant); - const foreground = - luminance > 0.3 ? "oklch(0 0 0)" : "oklch(0.93 0.015 80)"; + const foreground = luminance > 0.3 ? "oklch(0 0 0)" : "oklch(0.93 0.015 80)"; Uniwind.updateCSSVariables("dark", { "--color-title-accent": vibrant, diff --git a/apps/native/src/lib/error-messages.ts b/apps/native/src/lib/error-messages.ts index cb1fd5e..1f3ea38 100644 --- a/apps/native/src/lib/error-messages.ts +++ b/apps/native/src/lib/error-messages.ts @@ -1,4 +1,5 @@ import { ORPCError } from "@orpc/client"; + import type { AppErrorCode } from "@sofa/api/errors"; export function getAppErrorCode(error: unknown): AppErrorCode | null { diff --git a/apps/native/src/lib/i18n.ts b/apps/native/src/lib/i18n.ts index 9fe32d4..64a53ef 100644 --- a/apps/native/src/lib/i18n.ts +++ b/apps/native/src/lib/i18n.ts @@ -1,9 +1,7 @@ -import { - activateLocale, - SUPPORTED_LOCALES, - type SupportedLocale, -} from "@sofa/i18n"; import * as Localization from "expo-localization"; + +import { activateLocale, SUPPORTED_LOCALES, type SupportedLocale } from "@sofa/i18n"; + import { globalStorage } from "./mmkv"; const LOCALE_STORAGE_KEY = "sofa:locale"; diff --git a/apps/native/src/lib/intl-polyfills.ts b/apps/native/src/lib/intl-polyfills.ts index bcd888a..b319a56 100644 --- a/apps/native/src/lib/intl-polyfills.ts +++ b/apps/native/src/lib/intl-polyfills.ts @@ -10,10 +10,8 @@ // 1. getCanonicalLocales (no dependencies) import "@formatjs/intl-getcanonicallocales/polyfill"; - // 2. Locale (depends on getCanonicalLocales) import "@formatjs/intl-locale/polyfill"; - // 3. PluralRules (depends on Locale) import "@formatjs/intl-pluralrules/polyfill-force"; import "@formatjs/intl-pluralrules/locale-data/en"; @@ -22,7 +20,6 @@ import "@formatjs/intl-pluralrules/locale-data/de"; import "@formatjs/intl-pluralrules/locale-data/es"; import "@formatjs/intl-pluralrules/locale-data/it"; import "@formatjs/intl-pluralrules/locale-data/pt"; - // 4. NumberFormat (depends on PluralRules, Locale) import "@formatjs/intl-numberformat/polyfill-force"; import "@formatjs/intl-numberformat/locale-data/en"; @@ -31,7 +28,6 @@ import "@formatjs/intl-numberformat/locale-data/de"; import "@formatjs/intl-numberformat/locale-data/es"; import "@formatjs/intl-numberformat/locale-data/it"; import "@formatjs/intl-numberformat/locale-data/pt"; - // 5. DateTimeFormat (depends on Locale) import "@formatjs/intl-datetimeformat/polyfill-force"; import "@formatjs/intl-datetimeformat/locale-data/en"; @@ -41,7 +37,6 @@ import "@formatjs/intl-datetimeformat/locale-data/es"; import "@formatjs/intl-datetimeformat/locale-data/it"; import "@formatjs/intl-datetimeformat/locale-data/pt"; import "@formatjs/intl-datetimeformat/add-all-tz"; - // 6. RelativeTimeFormat (depends on PluralRules, Locale) import "@formatjs/intl-relativetimeformat/polyfill-force"; import "@formatjs/intl-relativetimeformat/locale-data/en"; diff --git a/apps/native/src/lib/orpc.ts b/apps/native/src/lib/orpc.ts index 88bdf8d..5743fb8 100644 --- a/apps/native/src/lib/orpc.ts +++ b/apps/native/src/lib/orpc.ts @@ -2,9 +2,9 @@ import { createORPCClient } from "@orpc/client"; import { RPCLink } from "@orpc/client/fetch"; import type { ContractRouterClient } from "@orpc/contract"; import { createTanstackQueryUtils } from "@orpc/tanstack-query"; -import type { contract } from "@sofa/api/contract"; import { authClient, getServerUrl, serverFetch } from "@/lib/server"; +import type { contract } from "@sofa/api/contract"; export const link = new RPCLink({ url: () => `${getServerUrl()}/rpc`, @@ -26,7 +26,6 @@ export const link = new RPCLink({ }, }); -export const client: ContractRouterClient = - createORPCClient(link); +export const client: ContractRouterClient = createORPCClient(link); export const orpc = createTanstackQueryUtils(client); diff --git a/apps/native/src/lib/posthog.ts b/apps/native/src/lib/posthog.ts index ff40fec..84294ca 100644 --- a/apps/native/src/lib/posthog.ts +++ b/apps/native/src/lib/posthog.ts @@ -81,10 +81,7 @@ export function applyTrackingTransparency(granted: boolean): boolean { // writes to ANALYTICS_ENABLED_KEY from being misidentified as legacy. if (!globalStorage.getBoolean(ATT_MIGRATED_KEY)) { globalStorage.set(ATT_MIGRATED_KEY, true); - if ( - globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined && - !hasExplicitPreference() - ) { + if (globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined && !hasExplicitPreference()) { globalStorage.set(ANALYTICS_EXPLICIT_KEY, true); } } diff --git a/apps/native/src/lib/query-client.ts b/apps/native/src/lib/query-client.ts index eef2d53..b54acf4 100644 --- a/apps/native/src/lib/query-client.ts +++ b/apps/native/src/lib/query-client.ts @@ -1,10 +1,10 @@ import { msg } from "@lingui/core/macro"; -import { i18n } from "@sofa/i18n"; import { QueryCache, QueryClient } from "@tanstack/react-query"; import { posthog } from "@/lib/posthog"; import { getIsReachable, isNetworkError } from "@/lib/server"; import { toast } from "@/lib/toast"; +import { i18n } from "@sofa/i18n"; const ONE_DAY_MS = 1000 * 60 * 60 * 24; diff --git a/apps/native/src/lib/recently-viewed.ts b/apps/native/src/lib/recently-viewed.ts index a8a9db5..00465d7 100644 --- a/apps/native/src/lib/recently-viewed.ts +++ b/apps/native/src/lib/recently-viewed.ts @@ -1,9 +1,6 @@ import { useSyncExternalStore } from "react"; -import { - hasScopedStorage, - onStorageScopeChange, - scopedStorage, -} from "@/lib/mmkv"; + +import { hasScopedStorage, onStorageScopeChange, scopedStorage } from "@/lib/mmkv"; const STORAGE_KEY = "recently_viewed"; const MAX_ITEMS = 50; @@ -60,10 +57,7 @@ onStorageScopeChange(() => { export function addRecentlyViewed(item: Omit) { const filtered = items.filter((i) => i.id !== item.id); - const next = [{ ...item, viewedAt: Date.now() }, ...filtered].slice( - 0, - MAX_ITEMS, - ); + const next = [{ ...item, viewedAt: Date.now() }, ...filtered].slice(0, MAX_ITEMS); persist(next); } diff --git a/apps/native/src/lib/server.ts b/apps/native/src/lib/server.ts index 8828ba6..ab17532 100644 --- a/apps/native/src/lib/server.ts +++ b/apps/native/src/lib/server.ts @@ -46,8 +46,7 @@ interface CachedSessionData { const SERVER_URL_KEY = "sofa_server_url"; const SERVERS_MAP_KEY = "sofa_servers"; const CURRENT_INSTANCE_KEY = "sofa_current_instance_id"; -const DEFAULT_URL = - process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com"; +const DEFAULT_URL = process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com"; // --------------------------------------------------------------------------- // URL helpers @@ -131,9 +130,7 @@ export async function ensureInstanceId(): Promise { const existing = getCurrentInstanceId(); if (existing) return existing; - const serverUrl = hasStoredServerUrl() - ? getServerUrl() - : process.env.EXPO_PUBLIC_SERVER_URL; + const serverUrl = hasStoredServerUrl() ? getServerUrl() : process.env.EXPO_PUBLIC_SERVER_URL; if (!serverUrl) return null; try { @@ -142,8 +139,7 @@ export async function ensureInstanceId(): Promise { }); if (!res.ok) return null; const data = await res.json(); - const instanceId = - typeof data.instanceId === "string" ? data.instanceId : null; + const instanceId = typeof data.instanceId === "string" ? data.instanceId : null; if (instanceId) { registerServer(serverUrl, instanceId); return instanceId; @@ -158,9 +154,7 @@ export async function ensureInstanceId(): Promise { // Validation // --------------------------------------------------------------------------- -export async function validateServerUrl( - url: string, -): Promise { +export async function validateServerUrl(url: string): Promise { const normalized = normalizeUrl(url); if (!normalized || !normalized.includes("://")) { @@ -194,8 +188,7 @@ export async function validateServerUrl( return { status: "error", error: "not_sofa_server" }; } - const instanceId = - typeof data.instanceId === "string" ? data.instanceId : null; + const instanceId = typeof data.instanceId === "string" ? data.instanceId : null; if (!instanceId) { return { status: "error", error: "not_sofa_server" }; } @@ -205,10 +198,7 @@ export async function validateServerUrl( return { status: "error", error: "not_sofa_server" }; } } catch (err) { - if ( - err instanceof Error && - (err.name === "TimeoutError" || err.name === "AbortError") - ) { + if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) { return { status: "error", error: "timeout" }; } return { status: "error", error: "network_unreachable" }; @@ -262,10 +252,7 @@ export function isNetworkError(error: unknown): error is Error { ); } -export async function serverFetch( - input: RequestInfo | URL, - init?: RequestInit, -): Promise { +export async function serverFetch(input: RequestInfo | URL, init?: RequestInit): Promise { try { const response = await fetch(input, init); setReachable(true); @@ -285,9 +272,7 @@ export function getIsReachable(): boolean { return isReachable; } -export function onServerReachabilityChange( - callback: (reachable: boolean) => void, -): () => void { +export function onServerReachabilityChange(callback: (reachable: boolean) => void): () => void { reachabilityListeners.push(callback); return () => { const index = reachabilityListeners.indexOf(callback); @@ -301,15 +286,12 @@ export function startReachabilityMonitor(): () => void { const networkSubscription = Network.addNetworkStateListener(syncOnlineState); - const appStateSubscription = AppState.addEventListener( - "change", - (nextState) => { - syncAppFocus(nextState); - if (nextState === "active") { - void Network.getNetworkStateAsync().then(syncOnlineState); - } - }, - ); + const appStateSubscription = AppState.addEventListener("change", (nextState) => { + syncAppFocus(nextState); + if (nextState === "active") { + void Network.getNetworkStateAsync().then(syncOnlineState); + } + }); const removeServerUrlListener = onServerUrlChange(() => { setReachable(true); diff --git a/apps/native/src/lib/title-actions.ts b/apps/native/src/lib/title-actions.ts index 6d4c7f1..8f34a05 100644 --- a/apps/native/src/lib/title-actions.ts +++ b/apps/native/src/lib/title-actions.ts @@ -1,8 +1,9 @@ import { msg, plural } from "@lingui/core/macro"; -import { i18n } from "@sofa/i18n"; + import { client, orpc } from "@/lib/orpc"; import { queryClient } from "@/lib/query-client"; import { toast } from "@/lib/toast"; +import { i18n } from "@sofa/i18n"; /** Invalidate title + dashboard queries. Used by most title mutations. */ export function invalidateTitleQueries() { @@ -59,9 +60,7 @@ export const titleActions = { try { await client.titles.watchMovie({ id }); toast.success( - titleName - ? i18n._(msg`Marked "${titleName}" as watched`) - : i18n._(msg`Marked as watched`), + titleName ? i18n._(msg`Marked "${titleName}" as watched`) : i18n._(msg`Marked as watched`), ); invalidateTitleQueries(); } catch { @@ -84,9 +83,7 @@ export const titleActions = { await client.titles.updateRating({ id, stars }); toast.success( stars > 0 - ? i18n._( - msg`Rated ${plural(stars, { one: "# star", other: "# stars" })}`, - ) + ? i18n._(msg`Rated ${plural(stars, { one: "# star", other: "# stars" })}`) : i18n._(msg`Rating removed`), ); queryClient.invalidateQueries({ queryKey: orpc.titles.key() }); @@ -119,9 +116,7 @@ export const titleActions = { try { await client.seasons.watch({ id }); toast.success( - seasonName - ? i18n._(msg`Watched all of ${seasonName}`) - : i18n._(msg`Season watched`), + seasonName ? i18n._(msg`Watched all of ${seasonName}`) : i18n._(msg`Season watched`), ); invalidateTitleQueries(); } catch { diff --git a/apps/native/src/lib/toast.ts b/apps/native/src/lib/toast.ts index 1463b84..9a91e00 100644 --- a/apps/native/src/lib/toast.ts +++ b/apps/native/src/lib/toast.ts @@ -22,12 +22,8 @@ function show( } export const toast = { - success: (message: string, options?: ToastOptions) => - show("done", "success", message, options), - error: (message: string, options?: ToastOptions) => - show("error", "error", message, options), - info: (message: string, options?: ToastOptions) => - show("none", "none", message, options), - warning: (message: string, options?: ToastOptions) => - show("none", "warning", message, options), + success: (message: string, options?: ToastOptions) => show("done", "success", message, options), + error: (message: string, options?: ToastOptions) => show("error", "error", message, options), + info: (message: string, options?: ToastOptions) => show("none", "none", message, options), + warning: (message: string, options?: ToastOptions) => show("none", "warning", message, options), }; diff --git a/apps/native/src/utils/form-errors.ts b/apps/native/src/utils/form-errors.ts index df429f5..15253b2 100644 --- a/apps/native/src/utils/form-errors.ts +++ b/apps/native/src/utils/form-errors.ts @@ -8,10 +8,7 @@ export function getFormErrors(error: ZodError): { message: string; fields: Set; } { - const fieldErrors = error.flatten().fieldErrors as Record< - string, - string[] | undefined - >; + const fieldErrors = error.flatten().fieldErrors as Record; const fields = new Set(); let message = ""; diff --git a/apps/native/src/utils/haptics.android.ts b/apps/native/src/utils/haptics.android.ts index 4f68ba5..d5990a4 100644 --- a/apps/native/src/utils/haptics.android.ts +++ b/apps/native/src/utils/haptics.android.ts @@ -16,18 +16,13 @@ const impactStyleToAndroid: Record = { [ImpactFeedbackStyle.Rigid]: AndroidHaptics.Virtual_Key, }; -const notificationTypeToAndroid: Record< - NotificationFeedbackType, - AndroidHaptics -> = { +const notificationTypeToAndroid: Record = { [NotificationFeedbackType.Success]: AndroidHaptics.Confirm, [NotificationFeedbackType.Warning]: AndroidHaptics.Segment_Tick, [NotificationFeedbackType.Error]: AndroidHaptics.Reject, }; -export async function impactAsync( - style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium, -) { +export async function impactAsync(style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium) { return performAndroidHapticsAsync(impactStyleToAndroid[style]); } diff --git a/apps/native/tsconfig.json b/apps/native/tsconfig.json index a9c7ebb..cab70d5 100644 --- a/apps/native/tsconfig.json +++ b/apps/native/tsconfig.json @@ -7,11 +7,5 @@ "@/*": ["./src/*"] } }, - "include": [ - "**/*.ts", - "**/*.tsx", - ".expo/types/**/*.ts", - "expo-env.d.ts", - "uniwind-types.d.ts" - ] + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "uniwind-types.d.ts"] } diff --git a/apps/public-api/.oxlintrc.json b/apps/public-api/.oxlintrc.json new file mode 100644 index 0000000..d38bbcf --- /dev/null +++ b/apps/public-api/.oxlintrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.json"] +} diff --git a/apps/public-api/package.json b/apps/public-api/package.json index 5dd0d0a..0ca19ca 100644 --- a/apps/public-api/package.json +++ b/apps/public-api/package.json @@ -6,8 +6,9 @@ "scripts": { "dev": "PORT=3002 bun --watch src/app.ts", "start": "PORT=3002 bun src/app.ts", - "lint": "biome check", - "format": "biome format --write", + "lint": "oxlint", + "format": "oxfmt --config ../../.oxfmtrc.json", + "format:check": "oxfmt --check --config ../../.oxfmtrc.json", "check-types": "tsc --noEmit" }, "dependencies": { diff --git a/apps/public-api/src/app.ts b/apps/public-api/src/app.ts index b03bbb2..da08bfa 100644 --- a/apps/public-api/src/app.ts +++ b/apps/public-api/src/app.ts @@ -3,10 +3,10 @@ import { checkRateLimit } from "@vercel/firewall"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { z } from "zod"; + import { getProvider, getProviderConfig } from "./providers"; -const GITHUB_RELEASES_URL = - "https://api.github.com/repos/jakejarvis/sofa/releases/latest"; +const GITHUB_RELEASES_URL = "https://api.github.com/repos/jakejarvis/sofa/releases/latest"; const app = new Hono(); @@ -33,20 +33,14 @@ app.get("/v1/version", async (c) => { html_url: string; }; - c.header( - "Cache-Control", - "public, s-maxage=900, stale-while-revalidate=3600", - ); + c.header("Cache-Control", "public, s-maxage=900, stale-while-revalidate=3600"); return c.json({ version: data.tag_name.replace(/^v/, ""), release_url: data.html_url, }); } catch (e) { - return c.json( - { error: e instanceof Error ? e.message : "Failed to fetch version" }, - 502, - ); + return c.json({ error: e instanceof Error ? e.message : "Failed to fetch version" }, 502); } }); @@ -86,7 +80,7 @@ app.post( arch: body.arch, users: body.users, titles: body.titles, - ...(body.features ?? {}), + ...body.features, }, }), signal: AbortSignal.timeout(10_000), @@ -138,10 +132,7 @@ app.post( } try { - const result = await provider.getDeviceCode( - config.clientId, - config.clientSecret, - ); + const result = await provider.getDeviceCode(config.clientId, config.clientSecret); return c.json(result); } catch (e) { return c.json( @@ -191,11 +182,7 @@ app.post( } try { - const result = await provider.pollForToken( - config.clientId, - config.clientSecret, - device_code, - ); + const result = await provider.pollForToken(config.clientId, config.clientSecret, device_code); if (result.status !== "authorized") { return c.json({ status: result.status }); @@ -203,10 +190,7 @@ app.post( // Fetch user data and return it inline try { - const data = await provider.fetchUserData( - result.accessToken, - config.clientId, - ); + const data = await provider.fetchUserData(result.accessToken, config.clientId); return c.json({ status: "authorized", data }); } catch (e) { // Auth succeeded but data fetch failed. Return a distinct status so @@ -217,10 +201,7 @@ app.post( }); } } catch (e) { - return c.json( - { error: e instanceof Error ? e.message : "Poll failed" }, - 502, - ); + return c.json({ error: e instanceof Error ? e.message : "Poll failed" }, 502); } }, ); diff --git a/apps/public-api/src/providers/simkl.ts b/apps/public-api/src/providers/simkl.ts index fadcd82..bf30018 100644 --- a/apps/public-api/src/providers/simkl.ts +++ b/apps/public-api/src/providers/simkl.ts @@ -1,18 +1,11 @@ import { parseSimklPayload } from "@sofa/core/imports/parsers"; -import type { - DeviceCodeResponse, - ImportProvider, - NormalizedImport, - PollResult, -} from "./types"; + +import type { DeviceCodeResponse, ImportProvider, NormalizedImport, PollResult } from "./types"; const API_BASE = "https://api.simkl.com"; const AUTH_BASE = "https://simkl.com"; -function simklHeaders( - clientId: string, - token?: string, -): Record { +function simklHeaders(clientId: string, token?: string): Record { const headers: Record = { "Content-Type": "application/json", "simkl-api-key": clientId, @@ -121,14 +114,8 @@ export const simkl: ImportProvider = { // Fetch movies, shows, and anime in parallel const [moviesRes, showsRes, animeRes] = await Promise.all([ fetch(`${API_BASE}/sync/all-items/movies`, { headers }), - fetch( - `${API_BASE}/sync/all-items/shows?extended=full&episode_watched_at=yes`, - { headers }, - ), - fetch( - `${API_BASE}/sync/all-items/anime?extended=full&episode_watched_at=yes`, - { headers }, - ), + fetch(`${API_BASE}/sync/all-items/shows?extended=full&episode_watched_at=yes`, { headers }), + fetch(`${API_BASE}/sync/all-items/anime?extended=full&episode_watched_at=yes`, { headers }), ]); // If all endpoints failed, throw so the caller gets a clear error @@ -139,15 +126,9 @@ export const simkl: ImportProvider = { } const [moviesData, showsData, animeData] = await Promise.all([ - moviesRes.ok - ? (moviesRes.json() as Promise) - : ([] as SimklApiItem[]), - showsRes.ok - ? (showsRes.json() as Promise) - : ([] as SimklApiItem[]), - animeRes.ok - ? (animeRes.json() as Promise) - : ([] as SimklApiItem[]), + moviesRes.ok ? (moviesRes.json() as Promise) : ([] as SimklApiItem[]), + showsRes.ok ? (showsRes.json() as Promise) : ([] as SimklApiItem[]), + animeRes.ok ? (animeRes.json() as Promise) : ([] as SimklApiItem[]), ]); // Flatten API's nested movie/show objects into the flat format diff --git a/apps/public-api/src/providers/trakt.ts b/apps/public-api/src/providers/trakt.ts index 3241c54..c1ff0be 100644 --- a/apps/public-api/src/providers/trakt.ts +++ b/apps/public-api/src/providers/trakt.ts @@ -1,17 +1,10 @@ import { parseTraktPayload } from "@sofa/core/imports/parsers"; -import type { - DeviceCodeResponse, - ImportProvider, - NormalizedImport, - PollResult, -} from "./types"; + +import type { DeviceCodeResponse, ImportProvider, NormalizedImport, PollResult } from "./types"; const API_BASE = "https://api.trakt.tv"; -function traktHeaders( - clientId: string, - token?: string, -): Record { +function traktHeaders(clientId: string, token?: string): Record { const headers: Record = { "Content-Type": "application/json", "trakt-api-version": "2", @@ -82,13 +75,12 @@ export const trakt: ImportProvider = { ); } - const [moviesData, showsData, watchlistData, ratingsData] = - await Promise.all([ - moviesRes.ok ? moviesRes.json() : [], - showsRes.ok ? showsRes.json() : [], - watchlistRes.ok ? watchlistRes.json() : [], - ratingsRes.ok ? ratingsRes.json() : [], - ]); + const [moviesData, showsData, watchlistData, ratingsData] = await Promise.all([ + moviesRes.ok ? moviesRes.json() : [], + showsRes.ok ? showsRes.json() : [], + watchlistRes.ok ? watchlistRes.json() : [], + ratingsRes.ok ? ratingsRes.json() : [], + ]); // Restructure API response into the format parseTraktPayload expects. // The Trakt API returns the same item shapes as the JSON export format. diff --git a/apps/public-api/src/providers/types.ts b/apps/public-api/src/providers/types.ts index 0c14045..ace64c4 100644 --- a/apps/public-api/src/providers/types.ts +++ b/apps/public-api/src/providers/types.ts @@ -17,17 +17,7 @@ export type PollResult = | { status: "denied" }; export interface ImportProvider { - getDeviceCode( - clientId: string, - clientSecret: string, - ): Promise; - pollForToken( - clientId: string, - clientSecret: string, - deviceCode: string, - ): Promise; - fetchUserData( - accessToken: string, - clientId: string, - ): Promise; + getDeviceCode(clientId: string, clientSecret: string): Promise; + pollForToken(clientId: string, clientSecret: string, deviceCode: string): Promise; + fetchUserData(accessToken: string, clientId: string): Promise; } diff --git a/apps/server/.oxlintrc.json b/apps/server/.oxlintrc.json new file mode 100644 index 0000000..d38bbcf --- /dev/null +++ b/apps/server/.oxlintrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.json"] +} diff --git a/apps/server/package.json b/apps/server/package.json index 99ee859..51b0c7b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -6,16 +6,17 @@ "scripts": { "dev": "bun --env-file=../../.env --watch src/index.ts", "start": "bun --env-file=../../.env src/index.ts", - "lint": "biome check", - "format": "biome format --write", + "lint": "oxlint", + "format": "oxfmt --config ../../.oxfmtrc.json", + "format:check": "oxfmt --check --config ../../.oxfmtrc.json", "check-types": "tsc --noEmit" }, "dependencies": { "@orpc/contract": "catalog:", - "@orpc/json-schema": "1.13.7", - "@orpc/openapi": "1.13.7", - "@orpc/server": "1.13.7", - "@orpc/zod": "1.13.7", + "@orpc/json-schema": "1.13.8", + "@orpc/openapi": "1.13.8", + "@orpc/server": "1.13.8", + "@orpc/zod": "1.13.8", "@sofa/api": "workspace:*", "@sofa/auth": "workspace:*", "@sofa/config": "workspace:*", diff --git a/apps/server/src/cron.ts b/apps/server/src/cron.ts index 327efc1..a2c0b0e 100644 --- a/apps/server/src/cron.ts +++ b/apps/server/src/cron.ts @@ -1,3 +1,5 @@ +import { Cron } from "croner"; + import { refreshAvailability } from "@sofa/core/availability"; import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup"; import { refreshCredits, syncCastProfileThumbHashes } from "@sofa/core/credits"; @@ -16,10 +18,7 @@ import { } from "@sofa/core/metadata"; import { getSetting } from "@sofa/core/settings"; import { performTelemetryReport } from "@sofa/core/telemetry"; -import { - generateTitleBackdropThumbHash, - generateTitlePosterThumbHash, -} from "@sofa/core/thumbhash"; +import { generateTitleBackdropThumbHash, generateTitlePosterThumbHash } from "@sofa/core/thumbhash"; import { performUpdateCheck } from "@sofa/core/update-check"; import { db } from "@sofa/db/client"; import { and, eq, inArray, isNotNull, lt, or, sql } from "@sofa/db/helpers"; @@ -35,7 +34,6 @@ import { } from "@sofa/db/schema"; import { createLogger } from "@sofa/logger"; import { getTvDetails } from "@sofa/tmdb/client"; -import { Cron } from "croner"; export type BackupFrequency = "6h" | "12h" | "1d" | "7d"; @@ -132,14 +130,8 @@ function getThumbhashBackfillTitleIds(): string[] { .from(titles) .where( or( - and( - isNotNull(titles.posterPath), - sql`${titles.posterThumbHash} IS NULL`, - ), - and( - isNotNull(titles.backdropPath), - sql`${titles.backdropThumbHash} IS NULL`, - ), + and(isNotNull(titles.posterPath), sql`${titles.posterThumbHash} IS NULL`), + and(isNotNull(titles.backdropPath), sql`${titles.backdropThumbHash} IS NULL`), ), ) .all() @@ -150,12 +142,7 @@ function getThumbhashBackfillTitleIds(): string[] { db .select({ titleId: seasons.titleId }) .from(seasons) - .where( - and( - isNotNull(seasons.posterPath), - sql`${seasons.posterThumbHash} IS NULL`, - ), - ) + .where(and(isNotNull(seasons.posterPath), sql`${seasons.posterThumbHash} IS NULL`)) .groupBy(seasons.titleId) .all() .map((row) => row.titleId), @@ -166,12 +153,7 @@ function getThumbhashBackfillTitleIds(): string[] { .select({ titleId: seasons.titleId }) .from(episodes) .innerJoin(seasons, eq(episodes.seasonId, seasons.id)) - .where( - and( - isNotNull(episodes.stillPath), - sql`${episodes.stillThumbHash} IS NULL`, - ), - ) + .where(and(isNotNull(episodes.stillPath), sql`${episodes.stillThumbHash} IS NULL`)) .groupBy(seasons.titleId) .all() .map((row) => row.titleId), @@ -182,12 +164,7 @@ function getThumbhashBackfillTitleIds(): string[] { .select({ titleId: titleCast.titleId }) .from(titleCast) .innerJoin(persons, eq(titleCast.personId, persons.id)) - .where( - and( - isNotNull(persons.profilePath), - sql`${persons.profileThumbHash} IS NULL`, - ), - ) + .where(and(isNotNull(persons.profilePath), sql`${persons.profileThumbHash} IS NULL`)) .groupBy(titleCast.titleId) .all() .map((row) => row.titleId), @@ -207,12 +184,7 @@ async function nightlyRefreshLibrary() { const staleLibrary = db .select({ id: titles.id }) .from(titles) - .where( - and( - inArray(titles.id, libraryIds), - lt(titles.lastFetchedAt, libraryStale), - ), - ) + .where(and(inArray(titles.id, libraryIds), lt(titles.lastFetchedAt, libraryStale))) .all(); for (const { id } of staleLibrary) { @@ -224,12 +196,7 @@ async function nightlyRefreshLibrary() { const nonLibrary = db .select() .from(titles) - .where( - and( - isNotNull(titles.lastFetchedAt), - lt(titles.lastFetchedAt, nonLibraryStale), - ), - ) + .where(and(isNotNull(titles.lastFetchedAt), lt(titles.lastFetchedAt, nonLibraryStale))) .limit(50) .all(); @@ -282,9 +249,7 @@ async function refreshAvailabilityJob() { async function refreshRecommendationsJob() { const libraryIds = getLibraryTitleIds(); - log.debug( - `Refreshing recommendations for ${libraryIds.length} library titles`, - ); + log.debug(`Refreshing recommendations for ${libraryIds.length} library titles`); for (const titleId of libraryIds) { await refreshRecommendations(titleId); @@ -316,12 +281,7 @@ async function refreshTvChildrenJob() { ? db .select({ titleId: seasons.titleId }) .from(seasons) - .where( - and( - inArray(seasons.titleId, tvIds), - lt(seasons.lastFetchedAt, stale), - ), - ) + .where(and(inArray(seasons.titleId, tvIds), lt(seasons.lastFetchedAt, stale))) .groupBy(seasons.titleId) .all() .map((r) => r.titleId) @@ -340,17 +300,11 @@ async function refreshTvChildrenJob() { async function cacheImagesJob() { const titleIds = getThumbhashBackfillTitleIds(); - log.debug( - `Caching images for ${titleIds.length} titles needing art backfill`, - ); + log.debug(`Caching images for ${titleIds.length} titles needing art backfill`); for (const titleId of titleIds) { try { - const title = db - .select() - .from(titles) - .where(eq(titles.id, titleId)) - .get(); + const title = db.select().from(titles).where(eq(titles.id, titleId)).get(); if (!title) continue; // Phase 1: warm the image cache so thumbhash generation can read from disk @@ -370,18 +324,14 @@ async function cacheImagesJob() { hashTasks.push(generateTitlePosterThumbHash(titleId, title.posterPath)); } if (!title.backdropThumbHash && title.backdropPath) { - hashTasks.push( - generateTitleBackdropThumbHash(titleId, title.backdropPath), - ); + hashTasks.push(generateTitleBackdropThumbHash(titleId, title.backdropPath)); } if (title.type === "tv") { hashTasks.push(syncTvChildArt(titleId, { warmCache: false })); } - hashTasks.push( - syncCastProfileThumbHashes(titleId, undefined, { warmCache: false }), - ); + hashTasks.push(syncCastProfileThumbHashes(titleId, undefined, { warmCache: false })); await Promise.all(hashTasks); } catch (err) { @@ -404,9 +354,7 @@ async function refreshCreditsJob() { .limit(1) .get(); - const needsRefresh = - !castEntry || - (castEntry.lastFetchedAt && castEntry.lastFetchedAt < stale); + const needsRefresh = !castEntry || (castEntry.lastFetchedAt && castEntry.lastFetchedAt < stale); if (needsRefresh) { await refreshCredits(titleId); @@ -453,8 +401,7 @@ export function buildBackupCron( } function getBackupCronFromSettings(): string { - const frequency = (getSetting("backupScheduleFrequency") ?? - "1d") as BackupFrequency; + const frequency = (getSetting("backupScheduleFrequency") ?? "1d") as BackupFrequency; const time = getSetting("backupScheduleTime") ?? "02:00"; const dayOfWeek = Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10); return buildBackupCron(frequency, time, dayOfWeek); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index ab23d51..9003833 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,3 +1,7 @@ +import { type Context, Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import { cors } from "hono/cors"; + import { CACHE_DIR } from "@sofa/config"; import { ensureBackupDir } from "@sofa/core/backup"; import { ensureImageDirs, imageCacheEnabled } from "@sofa/core/image-cache"; @@ -5,9 +9,7 @@ import { registerJobScheduleProvider } from "@sofa/core/system-health"; import { closeDatabase } from "@sofa/db/client"; import { runMigrations } from "@sofa/db/migrate"; import { createLogger } from "@sofa/logger"; -import { type Context, Hono } from "hono"; -import { serveStatic } from "hono/bun"; -import { cors } from "hono/cors"; + import { getJobSchedules, startJobs, stopJobs } from "./cron"; import { handler as rpcHandler } from "./orpc/handler"; import { openApiHandler } from "./orpc/openapi-handler"; diff --git a/apps/server/src/orpc/context.ts b/apps/server/src/orpc/context.ts index 998927a..9215f53 100644 --- a/apps/server/src/orpc/context.ts +++ b/apps/server/src/orpc/context.ts @@ -1,4 +1,5 @@ import { implement } from "@orpc/server"; + import { contract } from "@sofa/api/contract"; export interface Context { diff --git a/apps/server/src/orpc/handler.ts b/apps/server/src/orpc/handler.ts index 7d5b684..6aced0c 100644 --- a/apps/server/src/orpc/handler.ts +++ b/apps/server/src/orpc/handler.ts @@ -1,6 +1,8 @@ import { onError } from "@orpc/server"; import { RPCHandler } from "@orpc/server/fetch"; + import { createLogger } from "@sofa/logger"; + import { router } from "./router"; const log = createLogger("orpc"); diff --git a/apps/server/src/orpc/middleware.ts b/apps/server/src/orpc/middleware.ts index badfca7..d409c65 100644 --- a/apps/server/src/orpc/middleware.ts +++ b/apps/server/src/orpc/middleware.ts @@ -1,5 +1,6 @@ import { oo } from "@orpc/openapi"; import { os as baseOs, ORPCError } from "@orpc/server"; + import { auth } from "@sofa/auth/server"; const base = baseOs.$context<{ headers: Headers }>(); diff --git a/apps/server/src/orpc/openapi-handler.ts b/apps/server/src/orpc/openapi-handler.ts index 35f9b5a..96e3798 100644 --- a/apps/server/src/orpc/openapi-handler.ts +++ b/apps/server/src/orpc/openapi-handler.ts @@ -2,12 +2,10 @@ import { SmartCoercionPlugin } from "@orpc/json-schema"; import { OpenAPIHandler } from "@orpc/openapi/fetch"; import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"; import { onError } from "@orpc/server"; + import { createLogger } from "@sofa/logger"; -import { - generateOpenApiSpec, - openApiTags, - schemaConverters, -} from "./openapi-spec"; + +import { generateOpenApiSpec, openApiTags, schemaConverters } from "./openapi-spec"; import { implementedRouter } from "./router"; const log = createLogger("openapi"); @@ -81,8 +79,7 @@ export const openApiHandler = new OpenAPIHandler(implementedRouter, { ], interceptors: [ async (options) => { - const requestPathname = - options.request.url.pathname.replace(/\/$/, "") || "/"; + const requestPathname = options.request.url.pathname.replace(/\/$/, "") || "/"; const prefix = options.prefix?.replace(/\/$/, "") || ""; const specPath = `${prefix}/spec.json`.replace(/\/$/, "") || "/"; diff --git a/apps/server/src/orpc/openapi-spec.ts b/apps/server/src/orpc/openapi-spec.ts index 074c584..4e33954 100644 --- a/apps/server/src/orpc/openapi-spec.ts +++ b/apps/server/src/orpc/openapi-spec.ts @@ -1,8 +1,6 @@ -import { - OpenAPIGenerator, - type OpenAPIGeneratorGenerateOptions, -} from "@orpc/openapi"; +import { OpenAPIGenerator, type OpenAPIGeneratorGenerateOptions } from "@orpc/openapi"; import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; + import { BackupSchema, CastMemberSchema, @@ -18,6 +16,7 @@ import { SystemHealthSchema, TmdbBrowseItem, } from "@sofa/api/schemas"; + import { implementedRouter } from "./router"; export const schemaConverters = [new ZodToJsonSchemaConverter()]; @@ -43,16 +42,7 @@ const generator = new OpenAPIGenerator({ schemaConverters, }); -const httpMethods = [ - "get", - "put", - "post", - "delete", - "options", - "head", - "patch", - "trace", -] as const; +const httpMethods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const; type OpenApiSpec = Awaited>; @@ -102,14 +92,10 @@ function normalizeSchema(schema: unknown): unknown { if (isRecord(normalized.properties)) { const nextProperties = Object.fromEntries( - Object.entries(normalized.properties).flatMap( - ([name, propertySchema]) => { - const nextPropertySchema = normalizeSchema(propertySchema); - return nextPropertySchema === undefined - ? [] - : [[name, nextPropertySchema]]; - }, - ), + Object.entries(normalized.properties).flatMap(([name, propertySchema]) => { + const nextPropertySchema = normalizeSchema(propertySchema); + return nextPropertySchema === undefined ? [] : [[name, nextPropertySchema]]; + }), ); if (Object.keys(nextProperties).length > 0) { @@ -121,8 +107,7 @@ function normalizeSchema(schema: unknown): unknown { if (Array.isArray(normalized.required)) { const propertyNames = new Set(Object.keys(nextProperties)); const nextRequired = normalized.required.filter( - (name): name is string => - typeof name === "string" && propertyNames.has(name), + (name): name is string => typeof name === "string" && propertyNames.has(name), ); if (nextRequired.length > 0) { @@ -143,9 +128,7 @@ function normalizeSchema(schema: unknown): unknown { } if (isRecord(normalized.additionalProperties)) { - const nextAdditionalProperties = normalizeSchema( - normalized.additionalProperties, - ); + const nextAdditionalProperties = normalizeSchema(normalized.additionalProperties); if (nextAdditionalProperties === undefined) { delete normalized.additionalProperties; @@ -190,8 +173,7 @@ export function normalizeOpenApiSpec(spec: T): T { const nextContent = normalizeContent(operation.requestBody.content); if (nextContent) { - operation.requestBody.content = - nextContent as typeof operation.requestBody.content; + operation.requestBody.content = nextContent as typeof operation.requestBody.content; } else { delete operation.requestBody; } diff --git a/apps/server/src/orpc/procedures/account.ts b/apps/server/src/orpc/procedures/account.ts index 98958de..984380d 100644 --- a/apps/server/src/orpc/procedures/account.ts +++ b/apps/server/src/orpc/procedures/account.ts @@ -1,7 +1,9 @@ import { mkdir, rename } from "node:fs/promises"; import path from "node:path"; + import { auth } from "@sofa/auth/server"; import { AVATAR_DIR } from "@sofa/config"; + import { os } from "../context"; import { authed } from "../middleware"; @@ -12,14 +14,12 @@ const MIME_TO_EXT: Record = { "image/gif": "gif", }; -export const updateName = os.account.updateName - .use(authed) - .handler(async ({ input, context }) => { - await auth.api.updateUser({ - body: { name: input.name }, - headers: context.headers, - }); +export const updateName = os.account.updateName.use(authed).handler(async ({ input, context }) => { + await auth.api.updateUser({ + body: { name: input.name }, + headers: context.headers, }); +}); export const uploadAvatar = os.account.uploadAvatar .use(authed) @@ -53,16 +53,14 @@ export const uploadAvatar = os.account.uploadAvatar return { imageUrl }; }); -export const removeAvatar = os.account.removeAvatar - .use(authed) - .handler(async ({ context }) => { - const glob = new Bun.Glob(`${context.user.id}.*`); - const matches = await Array.fromAsync(glob.scan(AVATAR_DIR)); - for (const match of matches) { - await Bun.file(path.join(AVATAR_DIR, match)).delete(); - } - await auth.api.updateUser({ - body: { image: "" }, - headers: context.headers, - }); +export const removeAvatar = os.account.removeAvatar.use(authed).handler(async ({ context }) => { + const glob = new Bun.Glob(`${context.user.id}.*`); + const matches = await Array.fromAsync(glob.scan(AVATAR_DIR)); + for (const match of matches) { + await Bun.file(path.join(AVATAR_DIR, match)).delete(); + } + await auth.api.updateUser({ + body: { image: "" }, + headers: context.headers, }); +}); diff --git a/apps/server/src/orpc/procedures/admin.ts b/apps/server/src/orpc/procedures/admin.ts index fada7c0..2becb8b 100644 --- a/apps/server/src/orpc/procedures/admin.ts +++ b/apps/server/src/orpc/procedures/admin.ts @@ -1,5 +1,7 @@ import path from "node:path"; + import { ORPCError } from "@orpc/server"; + import { AppErrorCode } from "@sofa/api/errors"; import { BACKUP_DIR } from "@sofa/config"; import { @@ -16,59 +18,48 @@ import { import { getSetting, setSetting } from "@sofa/core/settings"; import { getSystemHealth } from "@sofa/core/system-health"; import { isTelemetryEnabled } from "@sofa/core/telemetry"; -import { - getCachedUpdateCheck, - isUpdateCheckEnabled, -} from "@sofa/core/update-check"; +import { getCachedUpdateCheck, isUpdateCheckEnabled } from "@sofa/core/update-check"; + import { rescheduleBackup, triggerJob as triggerCronJob } from "../../cron"; import { os } from "../context"; import { admin } from "../middleware"; // ─── Backups ─────────────────────────────────────────────────── -export const backupsList = os.admin.backups.list - .use(admin) - .handler(async () => { - const backups = await listBackups(); - return { backups }; - }); +export const backupsList = os.admin.backups.list.use(admin).handler(async () => { + const backups = await listBackups(); + return { backups }; +}); -export const backupsCreate = os.admin.backups.create - .use(admin) - .handler(async () => { - return await createBackup(); - }); +export const backupsCreate = os.admin.backups.create.use(admin).handler(async () => { + return await createBackup(); +}); -export const backupsDelete = os.admin.backups.delete - .use(admin) - .handler(async ({ input }) => { - try { - await deleteBackup(input.filename); - } catch (err) { - if (err instanceof ORPCError) throw err; - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("not found")) { - throw new ORPCError("NOT_FOUND", { - message: msg, - data: { code: AppErrorCode.BACKUP_NOT_FOUND }, - }); - } - throw new ORPCError("BAD_REQUEST", { +export const backupsDelete = os.admin.backups.delete.use(admin).handler(async ({ input }) => { + try { + await deleteBackup(input.filename); + } catch (err) { + if (err instanceof ORPCError) throw err; + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("not found")) { + throw new ORPCError("NOT_FOUND", { message: msg, - data: { code: AppErrorCode.BACKUP_DELETE_FAILED }, + data: { code: AppErrorCode.BACKUP_NOT_FOUND }, }); } - }); + throw new ORPCError("BAD_REQUEST", { + message: msg, + data: { code: AppErrorCode.BACKUP_DELETE_FAILED }, + }); + } +}); export const backupsRestore = os.admin.backups.restore .use(admin) .handler(async ({ input: file }) => { // Stream upload to disk to avoid buffering the entire file in memory await ensureBackupDir(); - const tmpPath = path.join( - BACKUP_DIR, - `.upload-${Date.now()}-${crypto.randomUUID()}.db`, - ); + const tmpPath = path.join(BACKUP_DIR, `.upload-${Date.now()}-${crypto.randomUUID()}.db`); try { await Bun.write(tmpPath, file); await restoreFromBackup(tmpPath); @@ -85,35 +76,23 @@ export const backupsRestore = os.admin.backups.restore } }); -export const backupsSchedule = os.admin.backups.schedule - .use(admin) - .handler(() => { - return { - enabled: getSetting("scheduledBackups") === "true", - maxRetention: Number.parseInt( - getSetting("maxBackupRetention") ?? "7", - 10, - ), - frequency: (getSetting("backupScheduleFrequency") ?? "1d") as - | "6h" - | "12h" - | "1d" - | "7d", - time: getSetting("backupScheduleTime") ?? "02:00", - dayOfWeek: Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10), - }; - }); +export const backupsSchedule = os.admin.backups.schedule.use(admin).handler(() => { + return { + enabled: getSetting("scheduledBackups") === "true", + maxRetention: Number.parseInt(getSetting("maxBackupRetention") ?? "7", 10), + frequency: (getSetting("backupScheduleFrequency") ?? "1d") as "6h" | "12h" | "1d" | "7d", + time: getSetting("backupScheduleTime") ?? "02:00", + dayOfWeek: Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10), + }; +}); export const backupsUpdateSchedule = os.admin.backups.updateSchedule .use(admin) .handler(({ input }) => { - if (input.enabled !== undefined) - setSetting("scheduledBackups", String(input.enabled)); - if (input.frequency !== undefined) - setSetting("backupScheduleFrequency", input.frequency); + if (input.enabled !== undefined) setSetting("scheduledBackups", String(input.enabled)); + if (input.frequency !== undefined) setSetting("backupScheduleFrequency", input.frequency); if (input.time !== undefined) setSetting("backupScheduleTime", input.time); - if (input.dayOfWeek !== undefined) - setSetting("backupScheduleDow", String(input.dayOfWeek)); + if (input.dayOfWeek !== undefined) setSetting("backupScheduleDow", String(input.dayOfWeek)); if (input.maxRetention !== undefined) setSetting("maxBackupRetention", String(input.maxRetention)); @@ -128,11 +107,9 @@ export const registration = os.admin.registration.use(admin).handler(() => { return { open: getSetting("registrationOpen") === "true" }; }); -export const toggleRegistration = os.admin.toggleRegistration - .use(admin) - .handler(({ input }) => { - setSetting("registrationOpen", String(input.open)); - }); +export const toggleRegistration = os.admin.toggleRegistration.use(admin).handler(({ input }) => { + setSetting("registrationOpen", String(input.open)); +}); // ─── Update Check ────────────────────────────────────────────── @@ -142,11 +119,9 @@ export const updateCheck = os.admin.updateCheck.use(admin).handler(() => { return { enabled, updateCheck: check }; }); -export const toggleUpdateCheck = os.admin.toggleUpdateCheck - .use(admin) - .handler(({ input }) => { - setSetting("updateCheckEnabled", String(input.enabled)); - }); +export const toggleUpdateCheck = os.admin.toggleUpdateCheck.use(admin).handler(({ input }) => { + setSetting("updateCheckEnabled", String(input.enabled)); +}); // ─── Telemetry ──────────────────────────────────────────────── @@ -157,26 +132,22 @@ export const telemetry = os.admin.telemetry.use(admin).handler(() => { }; }); -export const toggleTelemetry = os.admin.toggleTelemetry - .use(admin) - .handler(({ input }) => { - setSetting("telemetryEnabled", String(input.enabled)); - }); +export const toggleTelemetry = os.admin.toggleTelemetry.use(admin).handler(({ input }) => { + setSetting("telemetryEnabled", String(input.enabled)); +}); // ─── Jobs ────────────────────────────────────────────────────── -export const triggerJob = os.admin.triggerJob - .use(admin) - .handler(async ({ input }) => { - const triggered = await triggerCronJob(input.name); - if (!triggered) { - throw new ORPCError("NOT_FOUND", { - message: "Job not found", - data: { code: AppErrorCode.JOB_NOT_FOUND }, - }); - } - return { ok: true as const }; - }); +export const triggerJob = os.admin.triggerJob.use(admin).handler(async ({ input }) => { + const triggered = await triggerCronJob(input.name); + if (!triggered) { + throw new ORPCError("NOT_FOUND", { + message: "Job not found", + data: { code: AppErrorCode.JOB_NOT_FOUND }, + }); + } + return { ok: true as const }; +}); // ─── Purge ──────────────────────────────────────────────────── @@ -190,8 +161,6 @@ export const purgeImageCache = os.admin.purgeImageCache // ─── System Health ─────────────────────────────────────────────── -export const systemHealth = os.admin.systemHealth - .use(admin) - .handler(async () => { - return await getSystemHealth(); - }); +export const systemHealth = os.admin.systemHealth.use(admin).handler(async () => { + return await getSystemHealth(); +}); diff --git a/apps/server/src/orpc/procedures/dashboard.ts b/apps/server/src/orpc/procedures/dashboard.ts index e5ccd45..db6dfb1 100644 --- a/apps/server/src/orpc/procedures/dashboard.ts +++ b/apps/server/src/orpc/procedures/dashboard.ts @@ -7,6 +7,7 @@ import { getWatchHistory, } from "@sofa/core/discovery"; import { tmdbImageUrl } from "@sofa/tmdb/image"; + import { os } from "../context"; import { authed } from "../middleware"; @@ -16,43 +17,59 @@ export const stats = os.dashboard.stats.use(authed).handler(({ context }) => { return getUserStats(context.user.id); }); -export const continueWatching = os.dashboard.continueWatching - .use(authed) - .handler(({ context }) => { - const feed = getContinueWatchingFeed(context.user.id); - const items = feed.map((item) => ({ - title: { - id: item.title.id, - title: item.title.title, - backdropPath: tmdbImageUrl(item.title.backdropPath, "backdrops"), - backdropThumbHash: item.title.backdropThumbHash, - }, - nextEpisode: item.nextEpisode - ? { - seasonNumber: item.nextEpisode.seasonNumber, - episodeNumber: item.nextEpisode.episodeNumber, - name: item.nextEpisode.name, - stillPath: tmdbImageUrl(item.nextEpisode.stillPath, "stills"), - stillThumbHash: item.nextEpisode.stillThumbHash, - } - : null, - totalEpisodes: item.totalEpisodes, - watchedEpisodes: item.watchedEpisodes, - })); - return { items }; - }); +export const continueWatching = os.dashboard.continueWatching.use(authed).handler(({ context }) => { + const feed = getContinueWatchingFeed(context.user.id); + const items = feed.map((item) => ({ + title: { + id: item.title.id, + title: item.title.title, + backdropPath: tmdbImageUrl(item.title.backdropPath, "backdrops"), + backdropThumbHash: item.title.backdropThumbHash, + }, + nextEpisode: item.nextEpisode + ? { + seasonNumber: item.nextEpisode.seasonNumber, + episodeNumber: item.nextEpisode.episodeNumber, + name: item.nextEpisode.name, + stillPath: tmdbImageUrl(item.nextEpisode.stillPath, "stills"), + stillThumbHash: item.nextEpisode.stillThumbHash, + } + : null, + totalEpisodes: item.totalEpisodes, + watchedEpisodes: item.watchedEpisodes, + })); + return { items }; +}); -export const library = os.dashboard.library - .use(authed) - .handler(({ input, context }) => { - const { - items: feed, - page, - totalPages, - totalResults, - } = getLibraryFeed(context.user.id, input.page, input.limit); - const items = feed.map((t) => ({ - id: t.titleId, +export const library = os.dashboard.library.use(authed).handler(({ input, context }) => { + const { + items: feed, + page, + totalPages, + totalResults, + } = getLibraryFeed(context.user.id, input.page, input.limit); + const items = feed.map((t) => ({ + id: t.titleId, + tmdbId: t.tmdbId, + type: t.type, + title: t.title, + posterPath: tmdbImageUrl(t.posterPath, "posters"), + posterThumbHash: t.posterThumbHash ?? null, + releaseDate: t.releaseDate ?? null, + firstAirDate: t.firstAirDate ?? null, + voteAverage: t.voteAverage, + userStatus: t.userStatus, + })); + return { items, page, totalPages, totalResults }; +}); + +export const recommendations = os.dashboard.recommendations.use(authed).handler(({ context }) => { + const feed = getRecommendationsFeed(context.user.id); + const items = feed + .filter((t): t is NonNullable => t != null) + .slice(0, 10) + .map((t) => ({ + id: t.id, tmdbId: t.tmdbId, type: t.type, title: t.title, @@ -61,37 +78,13 @@ export const library = os.dashboard.library releaseDate: t.releaseDate ?? null, firstAirDate: t.firstAirDate ?? null, voteAverage: t.voteAverage, - userStatus: t.userStatus, })); - return { items, page, totalPages, totalResults }; - }); + return { items }; +}); -export const recommendations = os.dashboard.recommendations - .use(authed) - .handler(({ context }) => { - const feed = getRecommendationsFeed(context.user.id); - const items = feed - .filter((t): t is NonNullable => t != null) - .slice(0, 10) - .map((t) => ({ - id: t.id, - tmdbId: t.tmdbId, - type: t.type, - title: t.title, - posterPath: tmdbImageUrl(t.posterPath, "posters"), - posterThumbHash: t.posterThumbHash ?? null, - releaseDate: t.releaseDate ?? null, - firstAirDate: t.firstAirDate ?? null, - voteAverage: t.voteAverage, - })); - return { items }; - }); - -export const watchHistory = os.dashboard.watchHistory - .use(authed) - .handler(({ input, context }) => { - const coreType = watchHistoryTypeMap[input.type]; - const count = getWatchCount(context.user.id, coreType, input.period); - const history = getWatchHistory(context.user.id, coreType, input.period); - return { count, history }; - }); +export const watchHistory = os.dashboard.watchHistory.use(authed).handler(({ input, context }) => { + const coreType = watchHistoryTypeMap[input.type]; + const count = getWatchCount(context.user.id, coreType, input.period); + const history = getWatchHistory(context.user.id, coreType, input.period); + return { count, history }; +}); diff --git a/apps/server/src/orpc/procedures/discover.ts b/apps/server/src/orpc/procedures/discover.ts index 37b04f1..46bb420 100644 --- a/apps/server/src/orpc/procedures/discover.ts +++ b/apps/server/src/orpc/procedures/discover.ts @@ -1,80 +1,77 @@ import { ORPCError } from "@orpc/server"; + import { AppErrorCode } from "@sofa/api/errors"; import { ensureBrowseTitlesExist } from "@sofa/core/metadata"; -import { - getEpisodeProgressByTitleIds, - getUserStatusesByTitleIds, -} from "@sofa/core/tracking"; +import { getEpisodeProgressByTitleIds, getUserStatusesByTitleIds } from "@sofa/core/tracking"; import { discover as discoverTmdb } from "@sofa/tmdb/client"; import { isTmdbConfigured } from "@sofa/tmdb/config"; import { tmdbImageUrl } from "@sofa/tmdb/image"; + import { os } from "../context"; import { authed } from "../middleware"; -export const discover = os.discover - .use(authed) - .handler(async ({ input, context }) => { - if (!isTmdbConfigured()) { - throw new ORPCError("PRECONDITION_FAILED", { - message: "TMDB API key is not configured", - data: { code: AppErrorCode.TMDB_NOT_CONFIGURED }, - }); - } - - const results = await discoverTmdb( - input.type, - { - sort_by: "popularity.desc", - "vote_count.gte": "50", - with_genres: String(input.genreId), - }, - input.page, - ); - - type DiscoverResult = NonNullable[number] & { - title?: string; - name?: string; - release_date?: string; - first_air_date?: string; - }; - - const baseItems = ((results.results ?? []) as DiscoverResult[]) - .filter((r) => r.poster_path) - .map((r) => ({ - tmdbId: r.id, - type: input.type, - title: r.title ?? r.name ?? "", - posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"), - releaseDate: (r.release_date as string | undefined) ?? null, - firstAirDate: (r.first_air_date as string | undefined) ?? null, - voteAverage: r.vote_average ?? null, - })); - - const titleMap = ensureBrowseTitlesExist(baseItems); - const items = baseItems.map((item) => { - const entry = titleMap.get(`${item.tmdbId}-${item.type}`); - return { - ...item, - id: entry?.id ?? "", - posterThumbHash: entry?.posterThumbHash ?? null, - }; +export const discover = os.discover.use(authed).handler(async ({ input, context }) => { + if (!isTmdbConfigured()) { + throw new ORPCError("PRECONDITION_FAILED", { + message: "TMDB API key is not configured", + data: { code: AppErrorCode.TMDB_NOT_CONFIGURED }, }); + } - const titleIds = items.map((r) => r.id); - const [userStatuses, episodeProgress] = - titleIds.length > 0 - ? [ - getUserStatusesByTitleIds(context.user.id, titleIds), - getEpisodeProgressByTitleIds(context.user.id, titleIds), - ] - : [{}, {}]; + const results = await discoverTmdb( + input.type, + { + sort_by: "popularity.desc", + "vote_count.gte": "50", + with_genres: String(input.genreId), + }, + input.page, + ); + type DiscoverResult = NonNullable[number] & { + title?: string; + name?: string; + release_date?: string; + first_air_date?: string; + }; + + const baseItems = ((results.results ?? []) as DiscoverResult[]) + .filter((r) => r.poster_path) + .map((r) => ({ + tmdbId: r.id, + type: input.type, + title: r.title ?? r.name ?? "", + posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"), + releaseDate: (r.release_date as string | undefined) ?? null, + firstAirDate: (r.first_air_date as string | undefined) ?? null, + voteAverage: r.vote_average ?? null, + })); + + const titleMap = ensureBrowseTitlesExist(baseItems); + const items = baseItems.map((item) => { + const entry = titleMap.get(`${item.tmdbId}-${item.type}`); return { - items, - userStatuses, - episodeProgress, - page: results.page ?? input.page, - totalPages: results.total_pages ?? 1, - totalResults: results.total_results ?? 0, + ...item, + id: entry?.id ?? "", + posterThumbHash: entry?.posterThumbHash ?? null, }; }); + + const titleIds = items.map((r) => r.id); + const [userStatuses, episodeProgress] = + titleIds.length > 0 + ? [ + getUserStatusesByTitleIds(context.user.id, titleIds), + getEpisodeProgressByTitleIds(context.user.id, titleIds), + ] + : [{}, {}]; + + return { + items, + userStatuses, + episodeProgress, + page: results.page ?? input.page, + totalPages: results.total_pages ?? 1, + totalResults: results.total_results ?? 0, + }; +}); diff --git a/apps/server/src/orpc/procedures/episodes.ts b/apps/server/src/orpc/procedures/episodes.ts index 1aa44e3..a2cdd4b 100644 --- a/apps/server/src/orpc/procedures/episodes.ts +++ b/apps/server/src/orpc/procedures/episodes.ts @@ -1,25 +1,16 @@ -import { - logEpisodeWatch, - logEpisodeWatchBatch, - unwatchEpisode, -} from "@sofa/core/tracking"; +import { logEpisodeWatch, logEpisodeWatchBatch, unwatchEpisode } from "@sofa/core/tracking"; + import { os } from "../context"; import { authed } from "../middleware"; -export const watch = os.episodes.watch - .use(authed) - .handler(({ input, context }) => { - logEpisodeWatch(context.user.id, input.id); - }); +export const watch = os.episodes.watch.use(authed).handler(({ input, context }) => { + logEpisodeWatch(context.user.id, input.id); +}); -export const unwatch = os.episodes.unwatch - .use(authed) - .handler(({ input, context }) => { - unwatchEpisode(context.user.id, input.id); - }); +export const unwatch = os.episodes.unwatch.use(authed).handler(({ input, context }) => { + unwatchEpisode(context.user.id, input.id); +}); -export const batchWatch = os.episodes.batchWatch - .use(authed) - .handler(({ input, context }) => { - logEpisodeWatchBatch(context.user.id, input.episodeIds); - }); +export const batchWatch = os.episodes.batchWatch.use(authed).handler(({ input, context }) => { + logEpisodeWatchBatch(context.user.id, input.episodeIds); +}); diff --git a/apps/server/src/orpc/procedures/explore.ts b/apps/server/src/orpc/procedures/explore.ts index ffca5d4..74fce68 100644 --- a/apps/server/src/orpc/procedures/explore.ts +++ b/apps/server/src/orpc/procedures/explore.ts @@ -1,13 +1,12 @@ import { ORPCError } from "@orpc/server"; + import { AppErrorCode } from "@sofa/api/errors"; import { ensureBrowseTitlesExist } from "@sofa/core/metadata"; -import { - getEpisodeProgressByTitleIds, - getUserStatusesByTitleIds, -} from "@sofa/core/tracking"; +import { getEpisodeProgressByTitleIds, getUserStatusesByTitleIds } from "@sofa/core/tracking"; import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client"; import { isTmdbConfigured } from "@sofa/tmdb/config"; import { tmdbImageUrl } from "@sofa/tmdb/image"; + import { os } from "../context"; import { authed } from "../middleware"; @@ -20,172 +19,145 @@ function requireTmdb() { } } -export const trending = os.explore.trending - .use(authed) - .handler(async ({ input, context }) => { - requireTmdb(); +export const trending = os.explore.trending.use(authed).handler(async ({ input, context }) => { + requireTmdb(); - const data = await getTrending(input.type, "day", input.page); - const results = (data.results ?? []) as Record[]; + const data = await getTrending(input.type, "day", input.page); + const results = (data.results ?? []) as Record[]; - const baseItems = results - .filter((r) => r.poster_path) - .map((r) => { - const mediaType = - r.media_type === "movie" || r.media_type === "tv" - ? r.media_type - : "movie"; - return { - tmdbId: r.id as number, - type: mediaType as "movie" | "tv", - title: ((r.title ?? r.name) as string) || "", - posterPath: tmdbImageUrl( - (r.poster_path as string) ?? null, - "posters", - ), - releaseDate: (r.release_date as string | undefined) ?? null, - firstAirDate: (r.first_air_date as string | undefined) ?? null, - voteAverage: (r.vote_average as number | undefined) ?? null, - }; - }); - const heroResult = results.find( - (r) => - r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"), - ); - - // Batch-upsert all browse items (+ hero) into the titles table - const allBrowseItems = [ - ...baseItems, - ...(heroResult - ? [ - { - tmdbId: heroResult.id as number, - type: heroResult.media_type as "movie" | "tv", - title: - ((heroResult.title ?? heroResult.name) as string | undefined) ?? - "", - posterPath: tmdbImageUrl( - (heroResult.poster_path as string) ?? null, - "posters", - ), - releaseDate: - (heroResult.release_date as string | undefined) ?? null, - firstAirDate: - (heroResult.first_air_date as string | undefined) ?? null, - voteAverage: - (heroResult.vote_average as number | undefined) ?? null, - }, - ] - : []), - ]; - const titleMap = ensureBrowseTitlesExist(allBrowseItems); - - const items = baseItems.map((item) => { - const entry = titleMap.get(`${item.tmdbId}-${item.type}`); + const baseItems = results + .filter((r) => r.poster_path) + .map((r) => { + const mediaType = r.media_type === "movie" || r.media_type === "tv" ? r.media_type : "movie"; return { - ...item, - id: entry?.id ?? "", - posterThumbHash: entry?.posterThumbHash ?? null, - }; - }); - - const heroEntry = heroResult - ? titleMap.get( - `${heroResult.id as number}-${heroResult.media_type as string}`, - ) - : undefined; - const hero = heroResult - ? { - id: heroEntry?.id ?? "", - tmdbId: heroResult.id as number, - type: heroResult.media_type as "movie" | "tv", - title: - ((heroResult.title ?? heroResult.name) as string | undefined) ?? "", - overview: (heroResult.overview as string | undefined) ?? "", - backdropPath: tmdbImageUrl( - (heroResult.backdrop_path as string) ?? null, - "backdrops", - ), - voteAverage: heroResult.vote_average as number, - } - : null; - - const titleIds = items.map((r) => r.id); - const [userStatuses, episodeProgress] = - titleIds.length > 0 - ? [ - getUserStatusesByTitleIds(context.user.id, titleIds), - getEpisodeProgressByTitleIds(context.user.id, titleIds), - ] - : [{}, {}]; - - return { - items, - hero, - userStatuses, - episodeProgress, - page: (data as { page?: number }).page ?? input.page, - totalPages: (data as { total_pages?: number }).total_pages ?? 1, - totalResults: (data as { total_results?: number }).total_results ?? 0, - }; - }); - -export const popular = os.explore.popular - .use(authed) - .handler(async ({ input, context }) => { - requireTmdb(); - - const data = await getPopular(input.type, input.page); - const baseItems = ((data.results ?? []) as Record[]) - .filter((r) => r.poster_path) - .map((r) => ({ tmdbId: r.id as number, - type: input.type, + type: mediaType as "movie" | "tv", title: ((r.title ?? r.name) as string) || "", posterPath: tmdbImageUrl((r.poster_path as string) ?? null, "posters"), releaseDate: (r.release_date as string | undefined) ?? null, firstAirDate: (r.first_air_date as string | undefined) ?? null, voteAverage: (r.vote_average as number | undefined) ?? null, - })); - - const titleMap = ensureBrowseTitlesExist(baseItems); - const items = baseItems.map((item) => { - const entry = titleMap.get(`${item.tmdbId}-${item.type}`); - return { - ...item, - id: entry?.id ?? "", - posterThumbHash: entry?.posterThumbHash ?? null, }; }); + const heroResult = results.find( + (r) => r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"), + ); - const titleIds = items.map((r) => r.id); - const [userStatuses, episodeProgress] = - titleIds.length > 0 - ? [ - getUserStatusesByTitleIds(context.user.id, titleIds), - getEpisodeProgressByTitleIds(context.user.id, titleIds), - ] - : [{}, {}]; + // Batch-upsert all browse items (+ hero) into the titles table + const allBrowseItems = [ + ...baseItems, + ...(heroResult + ? [ + { + tmdbId: heroResult.id as number, + type: heroResult.media_type as "movie" | "tv", + title: ((heroResult.title ?? heroResult.name) as string | undefined) ?? "", + posterPath: tmdbImageUrl((heroResult.poster_path as string) ?? null, "posters"), + releaseDate: (heroResult.release_date as string | undefined) ?? null, + firstAirDate: (heroResult.first_air_date as string | undefined) ?? null, + voteAverage: (heroResult.vote_average as number | undefined) ?? null, + }, + ] + : []), + ]; + const titleMap = ensureBrowseTitlesExist(allBrowseItems); + const items = baseItems.map((item) => { + const entry = titleMap.get(`${item.tmdbId}-${item.type}`); return { - items, - userStatuses, - episodeProgress, - page: data.page ?? input.page, - totalPages: data.total_pages ?? 1, - totalResults: data.total_results ?? 0, + ...item, + id: entry?.id ?? "", + posterThumbHash: entry?.posterThumbHash ?? null, }; }); -export const genres = os.explore.genres - .use(authed) - .handler(async ({ input }) => { - requireTmdb(); - const data = await getGenres(input.type); + const heroEntry = heroResult + ? titleMap.get(`${heroResult.id as number}-${heroResult.media_type as string}`) + : undefined; + const hero = heroResult + ? { + id: heroEntry?.id ?? "", + tmdbId: heroResult.id as number, + type: heroResult.media_type as "movie" | "tv", + title: ((heroResult.title ?? heroResult.name) as string | undefined) ?? "", + overview: (heroResult.overview as string | undefined) ?? "", + backdropPath: tmdbImageUrl((heroResult.backdrop_path as string) ?? null, "backdrops"), + voteAverage: heroResult.vote_average as number, + } + : null; + + const titleIds = items.map((r) => r.id); + const [userStatuses, episodeProgress] = + titleIds.length > 0 + ? [ + getUserStatusesByTitleIds(context.user.id, titleIds), + getEpisodeProgressByTitleIds(context.user.id, titleIds), + ] + : [{}, {}]; + + return { + items, + hero, + userStatuses, + episodeProgress, + page: (data as { page?: number }).page ?? input.page, + totalPages: (data as { total_pages?: number }).total_pages ?? 1, + totalResults: (data as { total_results?: number }).total_results ?? 0, + }; +}); + +export const popular = os.explore.popular.use(authed).handler(async ({ input, context }) => { + requireTmdb(); + + const data = await getPopular(input.type, input.page); + const baseItems = ((data.results ?? []) as Record[]) + .filter((r) => r.poster_path) + .map((r) => ({ + tmdbId: r.id as number, + type: input.type, + title: ((r.title ?? r.name) as string) || "", + posterPath: tmdbImageUrl((r.poster_path as string) ?? null, "posters"), + releaseDate: (r.release_date as string | undefined) ?? null, + firstAirDate: (r.first_air_date as string | undefined) ?? null, + voteAverage: (r.vote_average as number | undefined) ?? null, + })); + + const titleMap = ensureBrowseTitlesExist(baseItems); + const items = baseItems.map((item) => { + const entry = titleMap.get(`${item.tmdbId}-${item.type}`); return { - genres: (data.genres ?? []).map((g) => ({ - id: g.id, - name: g.name ?? "", - })), + ...item, + id: entry?.id ?? "", + posterThumbHash: entry?.posterThumbHash ?? null, }; }); + + const titleIds = items.map((r) => r.id); + const [userStatuses, episodeProgress] = + titleIds.length > 0 + ? [ + getUserStatusesByTitleIds(context.user.id, titleIds), + getEpisodeProgressByTitleIds(context.user.id, titleIds), + ] + : [{}, {}]; + + return { + items, + userStatuses, + episodeProgress, + page: data.page ?? input.page, + totalPages: data.total_pages ?? 1, + totalResults: data.total_results ?? 0, + }; +}); + +export const genres = os.explore.genres.use(authed).handler(async ({ input }) => { + requireTmdb(); + const data = await getGenres(input.type); + return { + genres: (data.genres ?? []).map((g) => ({ + id: g.id, + name: g.name ?? "", + })), + }; +}); diff --git a/apps/server/src/orpc/procedures/imports.ts b/apps/server/src/orpc/procedures/imports.ts index 545792a..fa1ff57 100644 --- a/apps/server/src/orpc/procedures/imports.ts +++ b/apps/server/src/orpc/procedures/imports.ts @@ -1,4 +1,5 @@ import { ORPCError } from "@orpc/server"; + import { AppErrorCode } from "@sofa/api/errors"; import type { ParseResult } from "@sofa/core/imports"; import { @@ -13,216 +14,195 @@ import { db } from "@sofa/db/client"; import { and, eq, inArray } from "@sofa/db/helpers"; import { importJobs } from "@sofa/db/schema"; import { createLogger } from "@sofa/logger"; + import { os } from "../context"; import { authed } from "../middleware"; const log = createLogger("imports"); -export const parseFile = os.imports.parseFile - .use(authed) - .handler(async ({ input }) => { - const { source, file } = input; - let result: ParseResult; +export const parseFile = os.imports.parseFile.use(authed).handler(async ({ input }) => { + const { source, file } = input; + let result: ParseResult; - switch (source) { - case "letterboxd": - result = await parseLetterboxdExport(file); - break; - case "trakt": { - let json: unknown; - try { - json = await file.json(); - } catch { - throw new ORPCError("BAD_REQUEST", { - message: "Invalid JSON file", - data: { code: AppErrorCode.IMPORT_INVALID_FILE }, - }); - } - result = parseTraktPayload( - json as Parameters[0], - ); - break; - } - case "simkl": { - let json: unknown; - try { - json = await file.json(); - } catch { - throw new ORPCError("BAD_REQUEST", { - message: "Invalid JSON file", - data: { code: AppErrorCode.IMPORT_INVALID_FILE }, - }); - } - result = parseSimklPayload( - json as Parameters[0], - ); - break; - } - } - - return { - data: result.data, - warnings: result.warnings, - diagnostics: result.diagnostics, - stats: { - movies: result.data.movies.length, - episodes: result.data.episodes.length, - watchlist: result.data.watchlist.length, - ratings: result.data.ratings.length, - }, - }; - }); - -export const parsePayload = os.imports.parsePayload - .use(authed) - .handler(({ input }) => { - const { data } = input; - - return { - data, - warnings: [], - diagnostics: { unresolved: countUnresolved(data), unsupported: 0 }, - stats: { - movies: data.movies.length, - episodes: data.episodes.length, - watchlist: data.watchlist.length, - ratings: data.ratings.length, - }, - }; - }); - -export const createJob = os.imports.createJob - .use(authed) - .handler(async ({ input, context }) => { - const { data, options } = input; - - // Guard against oversized payloads - const totalItems = - data.movies.length + - data.episodes.length + - data.watchlist.length + - data.ratings.length; - if (totalItems > 100_000) { - throw new ORPCError("BAD_REQUEST", { - message: "Import payload too large", - data: { code: AppErrorCode.IMPORT_PAYLOAD_TOO_LARGE }, - }); - } - - // Prevent concurrent imports per user. - // Auto-cancel stale *pending* jobs (server crashed before worker started). - // Running jobs are never auto-cancelled — there's no heartbeat to - // distinguish active work from a dead worker, and killing a healthy - // long-running import is worse than making the user manually cancel. - const PENDING_STALE_MS = 5 * 60 * 1000; // 5 minutes - const now = Date.now(); - const existing = db - .select() - .from(importJobs) - .where( - and( - eq(importJobs.userId, context.user.id), - inArray(importJobs.status, ["pending", "running"]), - ), - ) - .get(); - if (existing) { - const isPending = existing.status === "pending"; - const isStale = - isPending && now - existing.createdAt.getTime() > PENDING_STALE_MS; - if (isStale) { - // Mark as cancelled so the worker loop also stops if it starts late - db.update(importJobs) - .set({ - status: "cancelled", - finishedAt: new Date(), - currentMessage: "Import timed out (stale job auto-cancelled)", - }) - .where(eq(importJobs.id, existing.id)) - .run(); - log.warn(`Auto-cancelled stale import job ${existing.id}`); - } else { - throw new ORPCError("CONFLICT", { - message: "An import is already in progress", - data: { code: AppErrorCode.IMPORT_ALREADY_RUNNING }, + switch (source) { + case "letterboxd": + result = await parseLetterboxdExport(file); + break; + case "trakt": { + let json: unknown; + try { + json = await file.json(); + } catch { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid JSON file", + data: { code: AppErrorCode.IMPORT_INVALID_FILE }, }); } + result = parseTraktPayload(json as Parameters[0]); + break; } + case "simkl": { + let json: unknown; + try { + json = await file.json(); + } catch { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid JSON file", + data: { code: AppErrorCode.IMPORT_INVALID_FILE }, + }); + } + result = parseSimklPayload(json as Parameters[0]); + break; + } + } - const job = db - .insert(importJobs) - .values({ - userId: context.user.id, - source: data.source, - status: "pending", - payload: JSON.stringify(data), - importWatches: options.importWatches, - importWatchlist: options.importWatchlist, - importRatings: options.importRatings, - createdAt: new Date(), - }) - .returning() - .get(); + return { + data: result.data, + warnings: result.warnings, + diagnostics: result.diagnostics, + stats: { + movies: result.data.movies.length, + episodes: result.data.episodes.length, + watchlist: result.data.watchlist.length, + ratings: result.data.ratings.length, + }, + }; +}); - // Fire-and-forget processing - processImportJob(job.id).catch((err) => { - log.error(`Import job ${job.id} failed:`, err); +export const parsePayload = os.imports.parsePayload.use(authed).handler(({ input }) => { + const { data } = input; + + return { + data, + warnings: [], + diagnostics: { unresolved: countUnresolved(data), unsupported: 0 }, + stats: { + movies: data.movies.length, + episodes: data.episodes.length, + watchlist: data.watchlist.length, + ratings: data.ratings.length, + }, + }; +}); + +export const createJob = os.imports.createJob.use(authed).handler(async ({ input, context }) => { + const { data, options } = input; + + // Guard against oversized payloads + const totalItems = + data.movies.length + data.episodes.length + data.watchlist.length + data.ratings.length; + if (totalItems > 100_000) { + throw new ORPCError("BAD_REQUEST", { + message: "Import payload too large", + data: { code: AppErrorCode.IMPORT_PAYLOAD_TOO_LARGE }, }); + } - return readImportJob(job.id); - }); - -export const getJob = os.imports.getJob - .use(authed) - .handler(({ input, context }) => { - return readImportJob(input.id, context.user.id); - }); - -export const cancelJob = os.imports.cancelJob - .use(authed) - .handler(({ input, context }) => { - const job = readImportJob(input.id, context.user.id); - if (job.status !== "pending" && job.status !== "running") { - throw new ORPCError("BAD_REQUEST", { - message: "Can only cancel pending or running jobs", - data: { code: AppErrorCode.IMPORT_CANNOT_CANCEL }, + // Prevent concurrent imports per user. + // Auto-cancel stale *pending* jobs (server crashed before worker started). + // Running jobs are never auto-cancelled — there's no heartbeat to + // distinguish active work from a dead worker, and killing a healthy + // long-running import is worse than making the user manually cancel. + const PENDING_STALE_MS = 5 * 60 * 1000; // 5 minutes + const now = Date.now(); + const existing = db + .select() + .from(importJobs) + .where( + and( + eq(importJobs.userId, context.user.id), + inArray(importJobs.status, ["pending", "running"]), + ), + ) + .get(); + if (existing) { + const isPending = existing.status === "pending"; + const isStale = isPending && now - existing.createdAt.getTime() > PENDING_STALE_MS; + if (isStale) { + // Mark as cancelled so the worker loop also stops if it starts late + db.update(importJobs) + .set({ + status: "cancelled", + finishedAt: new Date(), + currentMessage: "Import timed out (stale job auto-cancelled)", + }) + .where(eq(importJobs.id, existing.id)) + .run(); + log.warn(`Auto-cancelled stale import job ${existing.id}`); + } else { + throw new ORPCError("CONFLICT", { + message: "An import is already in progress", + data: { code: AppErrorCode.IMPORT_ALREADY_RUNNING }, }); } - db.update(importJobs) - .set({ status: "cancelled" }) - .where(eq(importJobs.id, input.id)) - .run(); - return readImportJob(input.id); + } + + const job = db + .insert(importJobs) + .values({ + userId: context.user.id, + source: data.source, + status: "pending", + payload: JSON.stringify(data), + importWatches: options.importWatches, + importWatchlist: options.importWatchlist, + importRatings: options.importRatings, + createdAt: new Date(), + }) + .returning() + .get(); + + // Fire-and-forget processing + processImportJob(job.id).catch((err) => { + log.error(`Import job ${job.id} failed:`, err); }); -export const jobEvents = os.imports.jobEvents - .use(authed) - .handler(async function* ({ input, context }) { - readImportJob(input.id, context.user.id); + return readImportJob(job.id); +}); - const JOB_POLL_INTERVAL = 500; - const MAX_POLL_DURATION_MS = 30 * 60 * 1000; // 30 minutes - const startedAt = Date.now(); +export const getJob = os.imports.getJob.use(authed).handler(({ input, context }) => { + return readImportJob(input.id, context.user.id); +}); - while (true) { - const job = readImportJob(input.id); - const isTerminal = - job.status === "success" || - job.status === "error" || - job.status === "cancelled"; +export const cancelJob = os.imports.cancelJob.use(authed).handler(({ input, context }) => { + const job = readImportJob(input.id, context.user.id); + if (job.status !== "pending" && job.status !== "running") { + throw new ORPCError("BAD_REQUEST", { + message: "Can only cancel pending or running jobs", + data: { code: AppErrorCode.IMPORT_CANNOT_CANCEL }, + }); + } + db.update(importJobs).set({ status: "cancelled" }).where(eq(importJobs.id, input.id)).run(); + return readImportJob(input.id); +}); - yield { - type: (isTerminal ? "complete" : "progress") as "complete" | "progress", - job, - }; +export const jobEvents = os.imports.jobEvents.use(authed).handler(async function* ({ + input, + context, +}) { + readImportJob(input.id, context.user.id); - if (isTerminal) return; + const JOB_POLL_INTERVAL = 500; + const MAX_POLL_DURATION_MS = 30 * 60 * 1000; // 30 minutes + const startedAt = Date.now(); - if (Date.now() - startedAt > MAX_POLL_DURATION_MS) { - yield { type: "timeout" as const, job }; - return; - } + while (true) { + const job = readImportJob(input.id); + const isTerminal = + job.status === "success" || job.status === "error" || job.status === "cancelled"; - await new Promise((resolve) => setTimeout(resolve, JOB_POLL_INTERVAL)); + yield { + type: (isTerminal ? "complete" : "progress") as "complete" | "progress", + job, + }; + + if (isTerminal) return; + + if (Date.now() - startedAt > MAX_POLL_DURATION_MS) { + yield { type: "timeout" as const, job }; + return; } - }); + + await new Promise((resolve) => setTimeout(resolve, JOB_POLL_INTERVAL)); + } +}); diff --git a/apps/server/src/orpc/procedures/integrations.ts b/apps/server/src/orpc/procedures/integrations.ts index efd84b5..a59ef50 100644 --- a/apps/server/src/orpc/procedures/integrations.ts +++ b/apps/server/src/orpc/procedures/integrations.ts @@ -1,8 +1,10 @@ import { ORPCError } from "@orpc/server"; + import { AppErrorCode } from "@sofa/api/errors"; import { db } from "@sofa/db/client"; import { and, desc, eq } from "@sofa/db/helpers"; import { integrationEvents, integrations } from "@sofa/db/schema"; + import { os } from "../context"; import { authed } from "../middleware"; @@ -13,9 +15,7 @@ function integrationTypeFor(provider: string): "webhook" | "list" { } function generateToken() { - return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString( - "hex", - ); + return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"); } function serializeIntegration(row: { @@ -41,10 +41,7 @@ export const list = os.integrations.list.use(authed).handler(({ context }) => { .where(eq(integrations.userId, context.user.id)) .all(); - const eventsByIntegration = new Map< - string, - (typeof integrationEvents.$inferSelect)[] - >(); + const eventsByIntegration = new Map(); for (const integration of userIntegrations) { const events = db .select() @@ -74,58 +71,48 @@ export const list = os.integrations.list.use(authed).handler(({ context }) => { return { integrations: result }; }); -export const create = os.integrations.create - .use(authed) - .handler(({ input, context }) => { - const existing = db - .select() - .from(integrations) - .where( - and( - eq(integrations.userId, context.user.id), - eq(integrations.provider, input.provider), - ), - ) - .get(); +export const create = os.integrations.create.use(authed).handler(({ input, context }) => { + const existing = db + .select() + .from(integrations) + .where(and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider))) + .get(); - if (existing) { - if (input.enabled !== undefined) { - const row = db - .update(integrations) - .set({ enabled: input.enabled }) - .where(eq(integrations.id, existing.id)) - .returning() - .get(); - return serializeIntegration(row); - } - return serializeIntegration(existing); + if (existing) { + if (input.enabled !== undefined) { + const row = db + .update(integrations) + .set({ enabled: input.enabled }) + .where(eq(integrations.id, existing.id)) + .returning() + .get(); + return serializeIntegration(row); } + return serializeIntegration(existing); + } - const row = db - .insert(integrations) - .values({ - userId: context.user.id, - provider: input.provider, - type: integrationTypeFor(input.provider), - token: generateToken(), - enabled: input.enabled ?? true, - createdAt: new Date(), - }) - .returning() - .get(); + const row = db + .insert(integrations) + .values({ + userId: context.user.id, + provider: input.provider, + type: integrationTypeFor(input.provider), + token: generateToken(), + enabled: input.enabled ?? true, + createdAt: new Date(), + }) + .returning() + .get(); - return serializeIntegration(row); - }); + return serializeIntegration(row); +}); export const deleteIntegration = os.integrations.delete .use(authed) .handler(({ input, context }) => { db.delete(integrations) .where( - and( - eq(integrations.userId, context.user.id), - eq(integrations.provider, input.provider), - ), + and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider)), ) .run(); }); @@ -137,10 +124,7 @@ export const regenerateToken = os.integrations.regenerateToken .update(integrations) .set({ token: generateToken() }) .where( - and( - eq(integrations.userId, context.user.id), - eq(integrations.provider, input.provider), - ), + and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider)), ) .returning() .get(); diff --git a/apps/server/src/orpc/procedures/people.ts b/apps/server/src/orpc/procedures/people.ts index 6dd8bfe..d4a4ecb 100644 --- a/apps/server/src/orpc/procedures/people.ts +++ b/apps/server/src/orpc/procedures/people.ts @@ -1,36 +1,36 @@ import { ORPCError } from "@orpc/server"; + import { AppErrorCode } from "@sofa/api/errors"; import { fetchFullFilmography, getOrFetchPerson } from "@sofa/core/person"; import { getUserStatusesByTitleIds } from "@sofa/core/tracking"; + import { os } from "../context"; import { authed } from "../middleware"; -export const detail = os.people.detail - .use(authed) - .handler(async ({ input, context }) => { - const person = await getOrFetchPerson(input.id); - if (!person) - throw new ORPCError("NOT_FOUND", { - message: "Person not found", - data: { code: AppErrorCode.PERSON_NOT_FOUND }, - }); +export const detail = os.people.detail.use(authed).handler(async ({ input, context }) => { + const person = await getOrFetchPerson(input.id); + if (!person) + throw new ORPCError("NOT_FOUND", { + message: "Person not found", + data: { code: AppErrorCode.PERSON_NOT_FOUND }, + }); - const allCredits = await fetchFullFilmography(person.id); + const allCredits = await fetchFullFilmography(person.id); - const start = (input.page - 1) * input.limit; - const pageCredits = allCredits.slice(start, start + input.limit); + const start = (input.page - 1) * input.limit; + const pageCredits = allCredits.slice(start, start + input.limit); - const userStatuses = getUserStatusesByTitleIds( - context.user.id, - pageCredits.map((c) => c.titleId), - ); + const userStatuses = getUserStatusesByTitleIds( + context.user.id, + pageCredits.map((c) => c.titleId), + ); - return { - person, - filmography: pageCredits, - userStatuses, - page: input.page, - totalPages: Math.max(1, Math.ceil(allCredits.length / input.limit)), - totalResults: allCredits.length, - }; - }); + return { + person, + filmography: pageCredits, + userStatuses, + page: input.page, + totalPages: Math.max(1, Math.ceil(allCredits.length / input.limit)), + totalResults: allCredits.length, + }; +}); diff --git a/apps/server/src/orpc/procedures/search.ts b/apps/server/src/orpc/procedures/search.ts index 9fc1eb9..978566c 100644 --- a/apps/server/src/orpc/procedures/search.ts +++ b/apps/server/src/orpc/procedures/search.ts @@ -1,15 +1,12 @@ import { ORPCError } from "@orpc/server"; + import { AppErrorCode } from "@sofa/api/errors"; import { ensureBrowseTitlesExist } from "@sofa/core/metadata"; import { ensureBrowsePersonsExist } from "@sofa/core/person"; -import { - searchMovies, - searchMulti, - searchPerson, - searchTv, -} from "@sofa/tmdb/client"; +import { searchMovies, searchMulti, searchPerson, searchTv } from "@sofa/tmdb/client"; import { isTmdbConfigured } from "@sofa/tmdb/config"; import { tmdbImageUrl } from "@sofa/tmdb/image"; + import { os } from "../context"; import { authed } from "../middleware"; @@ -105,8 +102,7 @@ export const search = os.search.use(authed).handler(async ({ input }) => { }; } - const mediaType = - r.media_type === "movie" || r.media_type === "tv" ? r.media_type : type; + const mediaType = r.media_type === "movie" || r.media_type === "tv" ? r.media_type : type; if (!mediaType) return null; return { diff --git a/apps/server/src/orpc/procedures/seasons.ts b/apps/server/src/orpc/procedures/seasons.ts index 85c35a0..1ddbaba 100644 --- a/apps/server/src/orpc/procedures/seasons.ts +++ b/apps/server/src/orpc/procedures/seasons.ts @@ -2,25 +2,18 @@ import { logEpisodeWatchBatch, unwatchSeason } from "@sofa/core/tracking"; import { db } from "@sofa/db/client"; import { eq } from "@sofa/db/helpers"; import { episodes } from "@sofa/db/schema"; + import { os } from "../context"; import { authed } from "../middleware"; -export const watch = os.seasons.watch - .use(authed) - .handler(({ input, context }) => { - const seasonEps = db - .select() - .from(episodes) - .where(eq(episodes.seasonId, input.id)) - .all(); - logEpisodeWatchBatch( - context.user.id, - seasonEps.map((ep) => ep.id), - ); - }); +export const watch = os.seasons.watch.use(authed).handler(({ input, context }) => { + const seasonEps = db.select().from(episodes).where(eq(episodes.seasonId, input.id)).all(); + logEpisodeWatchBatch( + context.user.id, + seasonEps.map((ep) => ep.id), + ); +}); -export const unwatch = os.seasons.unwatch - .use(authed) - .handler(({ input, context }) => { - unwatchSeason(context.user.id, input.id); - }); +export const unwatch = os.seasons.unwatch.use(authed).handler(({ input, context }) => { + unwatchSeason(context.user.id, input.id); +}); diff --git a/apps/server/src/orpc/procedures/system.ts b/apps/server/src/orpc/procedures/system.ts index 2dd3ba3..975fb48 100644 --- a/apps/server/src/orpc/procedures/system.ts +++ b/apps/server/src/orpc/procedures/system.ts @@ -1,15 +1,8 @@ -import { - getOidcProviderName, - isOidcConfigured, - isPasswordLoginDisabled, -} from "@sofa/auth/config"; -import { - getInstanceId, - getUserCount, - isRegistrationOpen, -} from "@sofa/core/settings"; +import { getOidcProviderName, isOidcConfigured, isPasswordLoginDisabled } from "@sofa/auth/config"; +import { getInstanceId, getUserCount, isRegistrationOpen } from "@sofa/core/settings"; import { isTmdbConfigured } from "@sofa/tmdb/config"; import { tmdbImageUrl } from "@sofa/tmdb/image"; + import { os } from "../context"; // Well-known TMDB poster paths for the background collage diff --git a/apps/server/src/orpc/procedures/titles.ts b/apps/server/src/orpc/procedures/titles.ts index e2bc891..954a032 100644 --- a/apps/server/src/orpc/procedures/titles.ts +++ b/apps/server/src/orpc/procedures/titles.ts @@ -1,4 +1,5 @@ import { ORPCError } from "@orpc/server"; + import { AppErrorCode } from "@sofa/api/errors"; import { getRecommendationsForTitle } from "@sofa/core/discovery"; import { getOrFetchTitle, getOrFetchTitleByTmdbId } from "@sofa/core/metadata"; @@ -14,54 +15,43 @@ import { import { db } from "@sofa/db/client"; import { and, eq } from "@sofa/db/helpers"; import { titles, userTitleStatus } from "@sofa/db/schema"; + import { os } from "../context"; import { authed } from "../middleware"; -export const detail = os.titles.detail - .use(authed) - .handler(async ({ input }) => { - const result = await getOrFetchTitle(input.id); - if (!result) - throw new ORPCError("NOT_FOUND", { - message: "Title not found", - data: { code: AppErrorCode.TITLE_NOT_FOUND }, - }); - return result; - }); +export const detail = os.titles.detail.use(authed).handler(async ({ input }) => { + const result = await getOrFetchTitle(input.id); + if (!result) + throw new ORPCError("NOT_FOUND", { + message: "Title not found", + data: { code: AppErrorCode.TITLE_NOT_FOUND }, + }); + return result; +}); -export const updateStatus = os.titles.updateStatus - .use(authed) - .handler(({ input, context }) => { - if (input.status === null) { - removeTitleStatus(context.user.id, input.id); - } else { - setTitleStatus(context.user.id, input.id, input.status); - } - }); +export const updateStatus = os.titles.updateStatus.use(authed).handler(({ input, context }) => { + if (input.status === null) { + removeTitleStatus(context.user.id, input.id); + } else { + setTitleStatus(context.user.id, input.id, input.status); + } +}); -export const updateRating = os.titles.updateRating - .use(authed) - .handler(({ input, context }) => { - rateTitleStars(context.user.id, input.id, input.stars); - }); +export const updateRating = os.titles.updateRating.use(authed).handler(({ input, context }) => { + rateTitleStars(context.user.id, input.id, input.stars); +}); -export const watchMovie = os.titles.watchMovie - .use(authed) - .handler(({ input, context }) => { - logMovieWatch(context.user.id, input.id); - }); +export const watchMovie = os.titles.watchMovie.use(authed).handler(({ input, context }) => { + logMovieWatch(context.user.id, input.id); +}); -export const watchAll = os.titles.watchAll - .use(authed) - .handler(({ input, context }) => { - markAllEpisodesWatched(context.user.id, input.id); - }); +export const watchAll = os.titles.watchAll.use(authed).handler(({ input, context }) => { + markAllEpisodesWatched(context.user.id, input.id); +}); -export const userInfo = os.titles.userInfo - .use(authed) - .handler(({ input, context }) => { - return getUserTitleInfo(context.user.id, input.id); - }); +export const userInfo = os.titles.userInfo.use(authed).handler(({ input, context }) => { + return getUserTitleInfo(context.user.id, input.id); +}); export const recommendations = os.titles.recommendations .use(authed) @@ -74,41 +64,32 @@ export const recommendations = os.titles.recommendations return { recommendations: recs, userStatuses }; }); -export const quickAdd = os.titles.quickAdd - .use(authed) - .handler(async ({ input, context }) => { - // Look up the title (it exists as a shell from browse/search import) - const title = db - .select({ id: titles.id, tmdbId: titles.tmdbId, type: titles.type }) - .from(titles) - .where(eq(titles.id, input.id)) - .get(); - if (!title) { - throw new ORPCError("NOT_FOUND", { - message: "Title not found", - data: { code: AppErrorCode.TITLE_NOT_FOUND }, - }); - } +export const quickAdd = os.titles.quickAdd.use(authed).handler(async ({ input, context }) => { + // Look up the title (it exists as a shell from browse/search import) + const title = db + .select({ id: titles.id, tmdbId: titles.tmdbId, type: titles.type }) + .from(titles) + .where(eq(titles.id, input.id)) + .get(); + if (!title) { + throw new ORPCError("NOT_FOUND", { + message: "Title not found", + data: { code: AppErrorCode.TITLE_NOT_FOUND }, + }); + } - // Trigger full TMDB import if still a shell - getOrFetchTitleByTmdbId(title.tmdbId, title.type as "movie" | "tv").catch( - () => {}, - ); + // Trigger full TMDB import if still a shell + getOrFetchTitleByTmdbId(title.tmdbId, title.type as "movie" | "tv").catch(() => {}); - const existing = db - .select() - .from(userTitleStatus) - .where( - and( - eq(userTitleStatus.userId, context.user.id), - eq(userTitleStatus.titleId, title.id), - ), - ) - .get(); + const existing = db + .select() + .from(userTitleStatus) + .where(and(eq(userTitleStatus.userId, context.user.id), eq(userTitleStatus.titleId, title.id))) + .get(); - if (!existing) { - setTitleStatus(context.user.id, title.id, "watchlist"); - } + if (!existing) { + setTitleStatus(context.user.id, title.id, "watchlist"); + } - return { id: title.id, alreadyAdded: !!existing }; - }); + return { id: title.id, alreadyAdded: !!existing }; +}); diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index 3baff89..084f9d0 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -1,6 +1,7 @@ -import { auth } from "@sofa/auth/server"; import { Hono } from "hono"; +import { auth } from "@sofa/auth/server"; + const app = new Hono(); app.all("/*", (c) => auth.handler(c.req.raw)); diff --git a/apps/server/src/routes/avatars.ts b/apps/server/src/routes/avatars.ts index d2d4742..55eac9b 100644 --- a/apps/server/src/routes/avatars.ts +++ b/apps/server/src/routes/avatars.ts @@ -1,7 +1,9 @@ import path from "node:path"; + +import { Hono } from "hono"; + import { auth } from "@sofa/auth/server"; import { AVATAR_DIR } from "@sofa/config"; -import { Hono } from "hono"; const app = new Hono(); diff --git a/apps/server/src/routes/backups.ts b/apps/server/src/routes/backups.ts index 63b96a2..ccd1e97 100644 --- a/apps/server/src/routes/backups.ts +++ b/apps/server/src/routes/backups.ts @@ -1,7 +1,9 @@ import path from "node:path"; + +import { Hono } from "hono"; + import { auth } from "@sofa/auth/server"; import { getBackupPath } from "@sofa/core/backup"; -import { Hono } from "hono"; const app = new Hono(); diff --git a/apps/server/src/routes/health.ts b/apps/server/src/routes/health.ts index 9c4a17d..451d46c 100644 --- a/apps/server/src/routes/health.ts +++ b/apps/server/src/routes/health.ts @@ -1,6 +1,7 @@ +import { Hono } from "hono"; + import { getInstanceId } from "@sofa/core/settings"; import { createLogger } from "@sofa/logger"; -import { Hono } from "hono"; const log = createLogger("health"); diff --git a/apps/server/src/routes/images.ts b/apps/server/src/routes/images.ts index dbc7c07..07b4d4e 100644 --- a/apps/server/src/routes/images.ts +++ b/apps/server/src/routes/images.ts @@ -1,15 +1,11 @@ import path from "node:path"; -import { fetchAndMaybeCache, imageCacheEnabled } from "@sofa/core/image-cache"; + import { Hono } from "hono"; import { z } from "zod"; -const categorySchema = z.enum([ - "posters", - "backdrops", - "stills", - "logos", - "profiles", -]); +import { fetchAndMaybeCache, imageCacheEnabled } from "@sofa/core/image-cache"; + +const categorySchema = z.enum(["posters", "backdrops", "stills", "logos", "profiles"]); const app = new Hono(); diff --git a/apps/server/src/routes/lists.ts b/apps/server/src/routes/lists.ts index 98340c7..c68285b 100644 --- a/apps/server/src/routes/lists.ts +++ b/apps/server/src/routes/lists.ts @@ -1,11 +1,7 @@ -import { - getRadarrList, - getSonarrList, - parseStatusParam, - resolveListToken, -} from "@sofa/core/lists"; import { Hono } from "hono"; +import { getRadarrList, getSonarrList, parseStatusParam, resolveListToken } from "@sofa/core/lists"; + const app = new Hono(); app.get("/:token", async (c) => { diff --git a/apps/server/src/routes/webhooks.ts b/apps/server/src/routes/webhooks.ts index da75a3a..fb27018 100644 --- a/apps/server/src/routes/webhooks.ts +++ b/apps/server/src/routes/webhooks.ts @@ -1,3 +1,5 @@ +import { Hono } from "hono"; + import type { WebhookEvent } from "@sofa/core/webhooks"; import { parseEmbyPayload, @@ -9,7 +11,6 @@ import { db } from "@sofa/db/client"; import { eq } from "@sofa/db/helpers"; import { integrations } from "@sofa/db/schema"; import { createLogger } from "@sofa/logger"; -import { Hono } from "hono"; const log = createLogger("webhooks"); @@ -19,11 +20,7 @@ app.post("/:token", async (c) => { const token = c.req.param("token"); // Look up connection by token — this IS the auth - const connection = db - .select() - .from(integrations) - .where(eq(integrations.token, token)) - .get(); + const connection = db.select().from(integrations).where(eq(integrations.token, token)).get(); if (!connection || !connection.enabled) { // Always return 200 to avoid retry storms from media servers diff --git a/apps/web/.oxlintrc.json b/apps/web/.oxlintrc.json new file mode 100644 index 0000000..d38bbcf --- /dev/null +++ b/apps/web/.oxlintrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.json"] +} diff --git a/apps/web/package.json b/apps/web/package.json index 48ed786..4d8cdb0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,8 +7,9 @@ "dev": "vite --host 0.0.0.0", "build": "vite build", "preview": "vite preview", - "lint": "biome check", - "format": "biome format --write", + "lint": "oxlint", + "format": "oxfmt --config ../../.oxfmtrc.json", + "format:check": "oxfmt --check --config ../../.oxfmtrc.json", "check-types": "tsc --noEmit" }, "dependencies": { @@ -21,7 +22,7 @@ "@orpc/contract": "catalog:", "@orpc/tanstack-query": "catalog:", "@player.style/sutro": "0.2.1", - "@rolldown/plugin-babel": "0.2.1", + "@rolldown/plugin-babel": "0.2.2", "@sofa/api": "workspace:*", "@sofa/i18n": "workspace:*", "@tabler/icons-react": "3.40.0", @@ -48,7 +49,7 @@ "devDependencies": { "@lingui/babel-plugin-lingui-macro": "catalog:", "@lingui/vite-plugin": "catalog:", - "@tailwindcss/vite": "0.0.0-insiders.f302fce", + "@tailwindcss/vite": "4.2.2", "@tanstack/devtools-vite": "0.6.0", "@tanstack/react-devtools": "0.10.0", "@tanstack/react-query-devtools": "5.91.3", diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx index e4a3184..1e80e13 100644 --- a/apps/web/src/components/auth-form.tsx +++ b/apps/web/src/components/auth-form.tsx @@ -3,6 +3,7 @@ import { IconKey } from "@tabler/icons-react"; import { Link, useNavigate } from "@tanstack/react-router"; import { AnimatePresence, motion } from "motion/react"; import { useState } from "react"; + import { SofaLogo } from "@/components/sofa-logo"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -93,24 +94,20 @@ export function AuthForm({ return (
{/* Subtle glow behind card */} -
+
- + -

- {isRegister ? ( - Create your account - ) : ( - Welcome back - )} +

+ {isRegister ? Create your account : Welcome back}

{isRegister ? ( @@ -136,15 +133,13 @@ export function AuthForm({ variant="outline" onClick={handleOidcLogin} disabled={oidcLoading} - className="h-11 w-full gap-2 rounded-lg border-border/50 bg-background/50 text-sm hover:bg-accent hover:text-foreground" + className="border-border/50 bg-background/50 hover:bg-accent hover:text-foreground h-11 w-full gap-2 rounded-lg text-sm" > {oidcLoading ? ( Redirecting… ) : ( - - Sign in with {authConfig?.oidcProviderName || "SSO"} - + Sign in with {authConfig?.oidcProviderName || "SSO"} )} @@ -153,11 +148,11 @@ export function AuthForm({ {showOidc && showPasswordForm && (

-
+
or -
+
)} @@ -174,10 +169,7 @@ export function AuthForm({ > {isRegister && ( -
); diff --git a/apps/web/src/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx index 0bb84cc..bd2c15f 100644 --- a/apps/web/src/components/command-palette.tsx +++ b/apps/web/src/components/command-palette.tsx @@ -13,6 +13,7 @@ import { skipToken, useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useAtom } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; + import { useProgress } from "@/components/navigation-progress"; import { Command, @@ -59,10 +60,7 @@ const SHORTCUT_DESCRIPTIONS = [ { scope: "Title", description: "Rate 5 stars", keys: ["5"] }, ] as const; -const groupedShortcuts: Record< - string, - { description: string; keys: readonly string[] }[] -> = {}; +const groupedShortcuts: Record = {}; for (const entry of SHORTCUT_DESCRIPTIONS) { if (!groupedShortcuts[entry.scope]) groupedShortcuts[entry.scope] = []; groupedShortcuts[entry.scope].push(entry); @@ -85,9 +83,7 @@ export function CommandPalette() { const { t } = useLingui(); const navigate = useNavigate(); const progress = useProgress(); - const [commandPaletteOpen, setCommandPaletteOpen] = useAtom( - commandPaletteOpenAtom, - ); + const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(commandPaletteOpenAtom); const [helpOpen, setHelpOpen] = useAtom(helpOpenAtom); const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom); const [query, setQuery] = useState(""); @@ -200,7 +196,6 @@ export function CommandPalette() { {hasQuery && loading && (
{Array.from({ length: 3 }).map((_, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
@@ -227,7 +222,7 @@ export function CommandPalette() { className="flex items-center gap-3 py-2" > {r.type === "person" ? ( -
+
{r.profilePath ? (
)}
) : ( -
+
{r.posterPath ? ( ) : ( -
+
?
)}
)}
-

- {r.title} -

-
+

{r.title}

+
{r.type === "person" ? ( - + ) : r.type === "movie" ? ( - + ) : ( - + )} {r.type} {r.type !== "person" && r.releaseDate && ( {r.releaseDate.slice(0, 4)} )} - {r.type === "person" && - r.knownFor && - r.knownFor.length > 0 && ( - - {r.knownFor.join(", ")} - - )} + {r.type === "person" && r.knownFor && r.knownFor.length > 0 && ( + {r.knownFor.join(", ")} + )}
@@ -317,7 +297,7 @@ export function CommandPalette() { @@ -332,13 +312,10 @@ export function CommandPalette() { > {q} - + @@ -407,7 +384,7 @@ export function CommandPalette() {
{Object.entries(groupedShortcuts).map(([scope, items]) => (
-

+

{scope}

@@ -416,16 +393,12 @@ export function CommandPalette() { key={item.description} className="flex items-center justify-between rounded-md px-2 py-1.5" > - - {item.description} - + {item.description}
{item.keys.map((key, i) => ( {i > 0 && ( - - then - + then )} {formatKey(key)} diff --git a/apps/web/src/components/dashboard/continue-watching-card.tsx b/apps/web/src/components/dashboard/continue-watching-card.tsx index 87dca4b..ea61806 100644 --- a/apps/web/src/components/dashboard/continue-watching-card.tsx +++ b/apps/web/src/components/dashboard/continue-watching-card.tsx @@ -1,8 +1,8 @@ import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { IconPlayerPlay } from "@tabler/icons-react"; - import { Link } from "@tanstack/react-router"; + import { thumbHashToUrl } from "@/lib/thumbhash"; export interface ContinueWatchingItemProps { @@ -23,34 +23,23 @@ export interface ContinueWatchingItemProps { watchedEpisodes: number; } -export function ContinueWatchingCard({ - item, -}: { - item: ContinueWatchingItemProps; -}) { +export function ContinueWatchingCard({ item }: { item: ContinueWatchingItemProps }) { const { t } = useLingui(); - const stillUrl = - item.nextEpisode?.stillPath ?? item.title.backdropPath ?? null; - const progress = - item.totalEpisodes > 0 - ? (item.watchedEpisodes / item.totalEpisodes) * 100 - : 0; + const stillUrl = item.nextEpisode?.stillPath ?? item.title.backdropPath ?? null; + const progress = item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0; return (
{ - const hash = - item.nextEpisode?.stillThumbHash ?? item.title.backdropThumbHash; + const hash = item.nextEpisode?.stillThumbHash ?? item.title.backdropThumbHash; const url = thumbHashToUrl(hash); - return url - ? { backgroundImage: `url(${url})`, backgroundSize: "cover" } - : undefined; + return url ? { backgroundImage: `url(${url})`, backgroundSize: "cover" } : undefined; })()} > {stillUrl ? ( @@ -62,24 +51,20 @@ export function ContinueWatchingCard({ className="absolute inset-0 h-full w-full object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105" /> ) : ( -
- +
+
)}
{item.nextEpisode && (
-

- +

+ {t`Up next`}

-

- - S{item.nextEpisode.seasonNumber} E - {item.nextEpisode.episodeNumber} +

+ + S{item.nextEpisode.seasonNumber} E{item.nextEpisode.episodeNumber} {" "} {item.nextEpisode.name}

@@ -88,21 +73,18 @@ export function ContinueWatchingCard({
-

{item.title.title}

+

{item.title.title}

{t`${item.watchedEpisodes}/${item.totalEpisodes} ${plural(item.totalEpisodes, { one: "episode", other: "episodes" })}`}

-
+
{progress > 0 && ( -
-
+
+
)} diff --git a/apps/web/src/components/dashboard/continue-watching-list.tsx b/apps/web/src/components/dashboard/continue-watching-list.tsx index de19799..e174c5f 100644 --- a/apps/web/src/components/dashboard/continue-watching-list.tsx +++ b/apps/web/src/components/dashboard/continue-watching-list.tsx @@ -1,13 +1,11 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; -import { - ContinueWatchingCard, - type ContinueWatchingItemProps, -} from "./continue-watching-card"; + +import { ContinueWatchingCard, type ContinueWatchingItemProps } from "./continue-watching-card"; function ContinueWatchingSkeleton() { return ( -
+
@@ -37,11 +35,7 @@ export function ContinueWatchingSectionSkeleton() { ); } -export function ContinueWatchingList({ - items, -}: { - items: ContinueWatchingItemProps[]; -}) { +export function ContinueWatchingList({ items }: { items: ContinueWatchingItemProps[] }) { return ( } + icon={} > diff --git a/apps/web/src/components/dashboard/feed-section.tsx b/apps/web/src/components/dashboard/feed-section.tsx index f09c3cf..306ca84 100644 --- a/apps/web/src/components/dashboard/feed-section.tsx +++ b/apps/web/src/components/dashboard/feed-section.tsx @@ -13,9 +13,7 @@ export function FeedSection({
{icon} -

- {title} -

+

{title}

{children}
diff --git a/apps/web/src/components/dashboard/library-section.tsx b/apps/web/src/components/dashboard/library-section.tsx index d6532a4..4b8a7f7 100644 --- a/apps/web/src/components/dashboard/library-section.tsx +++ b/apps/web/src/components/dashboard/library-section.tsx @@ -1,21 +1,22 @@ import { useLingui } from "@lingui/react/macro"; import { IconBooks } from "@tabler/icons-react"; import { useInfiniteQuery } from "@tanstack/react-query"; + import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; import { orpc } from "@/lib/orpc/client"; + import { FeedSection } from "./feed-section"; import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid"; export function LibrarySection() { - const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = - useInfiniteQuery( - orpc.dashboard.library.infiniteOptions({ - input: (pageParam: number) => ({ page: pageParam }), - initialPageParam: 1, - getNextPageParam: (lastPage) => - lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, - }), - ); + const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery( + orpc.dashboard.library.infiniteOptions({ + input: (pageParam: number) => ({ page: pageParam }), + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, + }), + ); const { t } = useLingui(); @@ -31,10 +32,7 @@ export function LibrarySection() { if (items.length === 0) return null; return ( - } - > + }>
{isFetchingNextPage && } diff --git a/apps/web/src/components/dashboard/recommendations-section.tsx b/apps/web/src/components/dashboard/recommendations-section.tsx index 4effd3d..99d1db3 100644 --- a/apps/web/src/components/dashboard/recommendations-section.tsx +++ b/apps/web/src/components/dashboard/recommendations-section.tsx @@ -1,14 +1,14 @@ import { useLingui } from "@lingui/react/macro"; import { IconThumbUp } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; + import { orpc } from "@/lib/orpc/client"; + import { FeedSection } from "./feed-section"; import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid"; export function RecommendationsSection() { - const { data, isPending } = useQuery( - orpc.dashboard.recommendations.queryOptions(), - ); + const { data, isPending } = useQuery(orpc.dashboard.recommendations.queryOptions()); const { t } = useLingui(); @@ -20,7 +20,7 @@ export function RecommendationsSection() { return ( } + icon={} > diff --git a/apps/web/src/components/dashboard/stats-display.tsx b/apps/web/src/components/dashboard/stats-display.tsx index ff41264..1fd5f7c 100644 --- a/apps/web/src/components/dashboard/stats-display.tsx +++ b/apps/web/src/components/dashboard/stats-display.tsx @@ -1,17 +1,8 @@ import { Trans, useLingui } from "@lingui/react/macro"; -import type { - DashboardStats, - HistoryBucket, - TimePeriod, -} from "@sofa/api/schemas"; -import { - IconCheck, - IconLibrary, - IconMovie, - IconPlayerPlay, -} from "@tabler/icons-react"; +import { IconCheck, IconLibrary, IconMovie, IconPlayerPlay } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; + import { Select, SelectContent, @@ -21,11 +12,13 @@ import { } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { orpc } from "@/lib/orpc/client"; +import type { DashboardStats, HistoryBucket, TimePeriod } from "@sofa/api/schemas"; + import { Sparkline } from "./sparkline"; function StatCardSkeleton() { return ( -
+
@@ -70,23 +63,21 @@ function StatCard({ }: StatCardProps) { return (
{sparklineData && }
-
+
- + {label}

{value}

@@ -112,9 +103,7 @@ function PeriodSelect({ onValueChange={(v) => v && onPeriodChange(v as TimePeriod)} modal={false} > - + {(value: TimePeriod | null) => (value ? periodLabels[value] : null)} @@ -158,11 +147,7 @@ function PeriodSelector({ return ( - {type === "movies" ? ( - Movies {select} - ) : ( - Episodes {select} - )} + {type === "movies" ? Movies {select} : Episodes {select}} ); } @@ -199,11 +184,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) { index={0} sparklineData={movieHistory} label={ - + } /> ; if (!stats) return null; @@ -23,9 +23,9 @@ export function StatsSection() { <> {isEmpty && ( -
-
- +
+
+

@@ -37,7 +37,7 @@ export function StatsSection() {

Start exploring diff --git a/apps/web/src/components/dashboard/welcome-header.tsx b/apps/web/src/components/dashboard/welcome-header.tsx index e86df79..91dae3b 100644 --- a/apps/web/src/components/dashboard/welcome-header.tsx +++ b/apps/web/src/components/dashboard/welcome-header.tsx @@ -3,14 +3,10 @@ import { Trans } from "@lingui/react/macro"; export function WelcomeHeader({ name }: { name?: string | null }) { return (
-

- {name ? ( - Welcome back, {name} - ) : ( - Welcome back - )} +

+ {name ? Welcome back, {name} : Welcome back}

-

+

Here's what's happening with your library

diff --git a/apps/web/src/components/expandable-text.tsx b/apps/web/src/components/expandable-text.tsx index 0376d31..fa03dce 100644 --- a/apps/web/src/components/expandable-text.tsx +++ b/apps/web/src/components/expandable-text.tsx @@ -1,5 +1,6 @@ import { Trans } from "@lingui/react/macro"; import { useEffect, useRef, useState } from "react"; + import { cn } from "@/lib/utils"; interface ExpandableTextProps { @@ -35,7 +36,7 @@ export function ExpandableText({

setExpanded(!expanded)} - className="mt-1 font-medium text-primary text-xs transition-colors hover:text-primary/80" + className="text-primary hover:text-primary/80 mt-1 text-xs font-medium transition-colors" > {expanded ? Show less : Read more} diff --git a/apps/web/src/components/explore/explore-client.tsx b/apps/web/src/components/explore/explore-client.tsx index 1c6f34f..98bd532 100644 --- a/apps/web/src/components/explore/explore-client.tsx +++ b/apps/web/src/components/explore/explore-client.tsx @@ -2,8 +2,10 @@ import { useLingui } from "@lingui/react/macro"; import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; + import { Skeleton } from "@/components/ui/skeleton"; import { orpc } from "@/lib/orpc/client"; + import { FilterableTitleRow } from "./filterable-title-row"; import { HeroBanner } from "./hero-banner"; import { TitleRow } from "./title-row"; @@ -20,11 +22,7 @@ function ExploreSkeletons() {

{Array.from({ length: 8 }).map((_, j) => ( -
+
))} @@ -35,9 +33,7 @@ function ExploreSkeletons() { ); } -function mergeMaps( - ...maps: (Record | undefined)[] -): Record { +function mergeMaps(...maps: (Record | undefined)[]): Record { return Object.assign({}, ...maps.filter(Boolean)); } @@ -110,9 +106,7 @@ export function ExploreClient() {
- } + icon={} items={trendingItems} userStatuses={userStatuses} episodeProgress={episodeProgress} @@ -124,7 +118,7 @@ export function ExploreClient() { } + icon={} mediaType="movie" defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)} genres={movieGenreData?.genres ?? []} @@ -134,9 +128,7 @@ export function ExploreClient() { - } + icon={} mediaType="tv" defaultItems={(popularTvData?.items ?? []).slice(0, 20)} genres={tvGenreData?.genres ?? []} diff --git a/apps/web/src/components/explore/filterable-title-row.tsx b/apps/web/src/components/explore/filterable-title-row.tsx index 727d1c0..f678b44 100644 --- a/apps/web/src/components/explore/filterable-title-row.tsx +++ b/apps/web/src/components/explore/filterable-title-row.tsx @@ -1,6 +1,7 @@ import { Trans } from "@lingui/react/macro"; import { skipToken, useInfiniteQuery } from "@tanstack/react-query"; import { useMemo, useRef, useState } from "react"; + import { TitleCard, TitleCardSkeleton } from "@/components/title-card"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -74,27 +75,25 @@ export function FilterableTitleRow({ ); const discoverStatuses = useMemo( () => - Object.assign( - {}, - ...(discoverData?.pages.map((p) => p.userStatuses) ?? []), - ) as Record, + Object.assign({}, ...(discoverData?.pages.map((p) => p.userStatuses) ?? [])) as Record< + string, + TitleStatus + >, [discoverData?.pages], ); const discoverProgress = useMemo( () => - Object.assign( - {}, - ...(discoverData?.pages.map((p) => p.episodeProgress) ?? []), - ) as Record, + Object.assign({}, ...(discoverData?.pages.map((p) => p.episodeProgress) ?? [])) as Record< + string, + { watched: number; total: number } + >, [discoverData?.pages], ); const isLoading = selectedGenre !== null && isPending; const items = selectedGenre === null ? defaultItems : discoverItems; - const userStatuses = - selectedGenre === null ? initialStatuses : discoverStatuses; - const episodeProgress = - selectedGenre === null ? initialProgress : discoverProgress; + const userStatuses = selectedGenre === null ? initialStatuses : discoverStatuses; + const episodeProgress = selectedGenre === null ? initialProgress : discoverProgress; function toggleGenre(genreId: number) { setSelectedGenre(genreId === selectedGenre ? null : genreId); @@ -104,9 +103,7 @@ export function FilterableTitleRow({
{icon} -

- {heading} -

+

{heading}

{/* Genre chips */} @@ -134,11 +131,7 @@ export function FilterableTitleRow({ {isLoading && (
{Array.from({ length: 8 }).map((_, i) => ( -
+
))} @@ -147,7 +140,7 @@ export function FilterableTitleRow({ {/* Empty state */} {!isLoading && selectedGenre !== null && items.length === 0 && ( -

+

No titles found for this genre.

)} @@ -199,7 +192,7 @@ export function FilterableTitleRow({ ))} {isFetchingNextPage && (
-
+
)}
diff --git a/apps/web/src/components/explore/hero-banner.tsx b/apps/web/src/components/explore/hero-banner.tsx index 1d95ff5..205d476 100644 --- a/apps/web/src/components/explore/hero-banner.tsx +++ b/apps/web/src/components/explore/hero-banner.tsx @@ -1,10 +1,5 @@ import { Trans } from "@lingui/react/macro"; -import { - IconDeviceTv, - IconMovie, - IconPlus, - IconStar, -} from "@tabler/icons-react"; +import { IconDeviceTv, IconMovie, IconPlus, IconStar } from "@tabler/icons-react"; import { Link } from "@tanstack/react-router"; interface HeroBannerProps { @@ -25,7 +20,7 @@ export function HeroBanner({ voteAverage, }: HeroBannerProps) { return ( -
+
{backdropPath ? ( ) : ( -
+
)} {/* Gradient overlays */} -
-
+
+
{/* Content */}
@@ -52,7 +47,7 @@ export function HeroBanner({ style={{ "--stagger-index": 3 } as React.CSSProperties} >
- + {type === "movie" ? ( <> @@ -66,11 +61,8 @@ export function HeroBanner({ )} {voteAverage > 0 && ( - - + + {voteAverage.toFixed(1)} )} @@ -78,22 +70,18 @@ export function HeroBanner({ Trending today
- -

+ +

{title}

-

+

{overview}

Add to Library diff --git a/apps/web/src/components/explore/title-row.tsx b/apps/web/src/components/explore/title-row.tsx index 468cf23..2dbbd66 100644 --- a/apps/web/src/components/explore/title-row.tsx +++ b/apps/web/src/components/explore/title-row.tsx @@ -1,4 +1,5 @@ import { useRef } from "react"; + import { TitleCard } from "@/components/title-card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll"; @@ -43,9 +44,7 @@ export function TitleRow({
{icon} -

- {heading} -

+

{heading}

-
+
)}
diff --git a/apps/web/src/components/landing-page.tsx b/apps/web/src/components/landing-page.tsx index 104066c..a36edd3 100644 --- a/apps/web/src/components/landing-page.tsx +++ b/apps/web/src/components/landing-page.tsx @@ -1,6 +1,7 @@ import { Trans } from "@lingui/react/macro"; import { Link } from "@tanstack/react-router"; import { motion } from "motion/react"; + import { SofaLogo } from "@/components/sofa-logo"; // Poster positions arranged in angled columns behind the hero @@ -77,11 +78,11 @@ export function LandingPage({
{/* Radial fade over posters to keep center clear */} -
+
{/* Warm primary glow */}
Self-hosted movie & TV tracker Get Started @@ -161,7 +162,7 @@ export function LandingPage({ <> Sign In @@ -171,7 +172,7 @@ export function LandingPage({ {registrationOpen && ( Register @@ -182,7 +183,7 @@ export function LandingPage({ {/* Bottom fade */} -
+
); } diff --git a/apps/web/src/components/nav-bar.tsx b/apps/web/src/components/nav-bar.tsx index b4f6f21..8766f97 100644 --- a/apps/web/src/components/nav-bar.tsx +++ b/apps/web/src/components/nav-bar.tsx @@ -1,15 +1,10 @@ import { Trans, useLingui } from "@lingui/react/macro"; -import { - IconCompass, - IconHome, - IconLogout, - IconSearch, - IconSettings, -} from "@tabler/icons-react"; +import { IconCompass, IconHome, IconLogout, IconSearch, IconSettings } from "@tabler/icons-react"; import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useSetAtom } from "jotai"; import { motion } from "motion/react"; import { useLayoutEffect, useRef, useState } from "react"; + import { SofaLogo } from "@/components/sofa-logo"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; @@ -81,12 +76,7 @@ function useActiveIndicator( const item = itemRefs.current[activeIndex]; const container = containerRef.current; if (item && container && container.offsetWidth > 0) { - setValue( - measure( - item.getBoundingClientRect(), - container.getBoundingClientRect(), - ), - ); + setValue(measure(item.getBoundingClientRect(), container.getBoundingClientRect())); } else { setValue(null); } @@ -128,9 +118,7 @@ export function NavBar({ const initial = userName?.charAt(0).toUpperCase() ?? "?"; - const activeIndex = navLinks.findIndex((link) => - isLinkActive(pathname, link.href), - ); + const activeIndex = navLinks.findIndex((link) => isLinkActive(pathname, link.href)); const navRef = useRef(null); const linkRefs = useRef<(HTMLAnchorElement | null)[]>([]); const { value: indicator, instant: desktopInstant } = useActiveIndicator( @@ -141,12 +129,12 @@ export function NavBar({ ); return ( -
+
@@ -165,7 +153,7 @@ export function NavBar({ }} to={link.href} aria-current={isActive ? "page" : undefined} - className="relative inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40" + className="text-muted-foreground hover:text-foreground focus-visible:text-foreground focus-visible:ring-primary/40 relative inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none" > {link.label} @@ -173,7 +161,7 @@ export function NavBar({ })} {indicator && ( setCommandPaletteOpen(true)} - className="flex flex-1 items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:hidden" + className="border-border/50 bg-card/50 text-muted-foreground hover:border-primary/20 hover:bg-card flex flex-1 items-center gap-2 rounded-lg border px-3 py-1.5 text-[13px] transition-colors sm:hidden" > {t`Search…`} @@ -196,7 +184,7 @@ export function NavBar({ {/* User avatar dropdown */} @@ -228,17 +216,15 @@ export function NavBar({
-

+

{userName} {userRole === "admin" && ( - + Admin )}

-

- {userEmail} -

+

{userEmail}

@@ -266,12 +252,12 @@ export function NavBar({ {/* Mobile: simple avatar link to settings */} - + {initial} @@ -292,9 +278,7 @@ export function MobileTabBar() { { href: "/settings", label: t`Settings`, icon: IconSettings }, ] as const; - const activeIndex = mobileTabs.findIndex((tab) => - isLinkActive(pathname, tab.href), - ); + const activeIndex = mobileTabs.findIndex((tab) => isLinkActive(pathname, tab.href)); const containerRef = useRef(null); const tabRefs = useRef<(HTMLAnchorElement | null)[]>([]); const { value: indicatorLeft, instant: mobileInstant } = useActiveIndicator( @@ -307,7 +291,7 @@ export function MobileTabBar() { return (