Compare commits

..

97 Commits

Author SHA1 Message Date
b842106e44 fix: match pagination button size to admin filter buttons (default size)
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m11s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 12:07:44 +01:00
9abcd715d7 feat: add subtitles to /play/buttplug and /play/recordings page headers
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 12:04:24 +01:00
ab0af9a773 feat: extract Pagination component and use it on all paginated pages
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m13s
- New lib/components/pagination/pagination.svelte with numbered pages,
  ellipsis for large ranges, and prev/next buttons
- All 6 admin pages (users, articles, videos, recordings, comments,
  queues) now show enumerated page numbers next to the "Showing X–Y of Z"
  label; offset is derived from page number * limit
- Public pages (videos, models, magazine) replace their inline
  totalPages/pageNumbers derived state with the shared component
- Removes ~80 lines of duplicated pagination logic across 9 files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 12:01:13 +01:00
fbd2efa994 feat: server-side pagination and filtering for admin queues page
- Move queue, status, and offset to URL search params (?queue=&status=&offset=)
- Load jobs server-side in +page.server.ts with auth token (matches other admin pages)
- Derive total from adminQueues counts (waiting+active+completed+failed+delayed)
  so pagination knows total without an extra query
- Add fetchFn/token params to getAdminQueueJobs for server-side use
- Retry/remove/pause/resume actions now use invalidateAll() instead of local state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 11:49:50 +01:00
79932157bf fix: revoke points when a comment is deleted
All checks were successful
Build and Push Backend Image / build (push) Successful in 43s
- revokePoints now accepts optional recordingId; when absent it deletes
  one matching row (for actions like COMMENT_CREATE that have no recording)
- deleteComment queues revokePoints + checkAchievements so leaderboard
  and social achievements stay in sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 11:16:18 +01:00
04b0ec1a71 fix: revoke gamification points on recording delete + fix comment collection
- deleteRecording now queues revokePoints for RECORDING_CREATE (and
  RECORDING_FEATURED if applicable) before deleting a published recording,
  so leaderboard points are correctly removed
- Fix comment stat/achievement queries using collection "recordings" instead
  of "videos" — comments are stored under collection "videos", so the count
  was always 0, breaking COMMENT_CREATE stats and social achievements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 11:13:01 +01:00
cc693d8be7 fix: prevent achievement points from being re-awarded on republish
All checks were successful
Build and Push Backend Image / build (push) Successful in 1m2s
Once an achievement is unlocked, preserve date_unlocked permanently
instead of clearing it to null when the user drops below the threshold
(e.g. on unpublish). This prevents the wasUnlocked check from returning
false on republish, which was causing achievement points to be re-awarded
on every publish/unpublish cycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 20:04:20 +01:00
52aa00dd13 fix: embed DECAY_LAMBDA as SQL literal to avoid pg type inference failure
All checks were successful
Build and Push Backend Image / build (push) Successful in 45s
Build and Push Frontend Image / build (push) Successful in 1m12s
PostgreSQL cannot resolve the type of a parameterized $1 = 0.005 in
-$1 * EXTRACT(EPOCH ...) and fails with an operator type error. Using
sql.raw() embeds the constant directly in the query string so userId
is the only parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:55:45 +01:00
8085b40af8 fix: use NOW() in weighted score query instead of JS Date parameter
Passing a JS Date to a Drizzle sql template serializes it as a locale
string (e.g. "Mon Mar 09 2026 19:51:22 GMT+0100") which PostgreSQL
cannot parse as timestamptz, causing the gamification worker to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:52:03 +01:00
5f40a812d3 feat: gamification queue with deduplication and unpublish revoke
- Add migration 0004: partial unique index on user_points (user_id, action, recording_id)
  for RECORDING_CREATE and RECORDING_FEATURED to prevent earn-on-republish farming
- Add revokePoints() to gamification lib; awardPoints() now uses onConflictDoNothing
- Add gamificationQueue (BullMQ) with 3-attempt exponential backoff
- Add gamification worker handling awardPoints, revokePoints, checkAchievements jobs
- Move all inline gamification calls in recordings + comments resolvers to queue
- Revoke RECORDING_CREATE points when a recording is unpublished (published → draft)
- Register gamification worker at server startup alongside mail worker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:50:33 +01:00
1b724e86c9 chore: lint and format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:38:37 +01:00
a9e4ed6049 feat: refactor play area into sidebar layout with buttplug, recordings, and leaderboard sub-pages
- Add /play sidebar layout (mobile nav + desktop sidebar) with SexyBackground
- Move buttplug device control to /play/buttplug with Empty component and scan button
- Move recordings from /me/recordings to /play/recordings
- Move leaderboard to /play/leaderboard; redirect /leaderboard → /play/leaderboard
- Redirect /me/recordings → /play/recordings and /play → /play/buttplug
- Remove recordings entry from /me sidebar nav
- Rename "SexyPlay" → "Play", swap bluetooth icon for rocket, remove subtitle
- Add play.nav i18n keys (play, recordings, leaderboard, back_to_site, back_mobile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:33:28 +01:00
66179d7ba8 style: streamline draft/published badge across recording card and admin table
- Replace custom inline span+getStatusColor with Badge component in recording card
- Align admin recordings table badge to same style (outline, green/yellow)
- Use i18n label in admin table instead of raw status string
- Remove unused cn import and getStatusColor helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:43:18 +01:00
3a8fa7d8ce style: refine admin edit forms and fix mobile padding
- Remove back button from admin entity edit pages (sidebar handles navigation)
- Remove cancel button from video/article forms, make submit button full-width
- Show actual entity title + subtitle on video/article edit pages
- Remove asterisks from Title/Slug field labels in i18n
- Remove px-3 sm:px-0 from all admin list page headers/filters (fixes mobile padding)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:38:38 +01:00
fddc3f15d0 feat: fix recording save and add publish/unpublish support
- Fix broken fetch("/api/sexy/recordings") → use createRecording GraphQL service
- Round duration to integer before sending (GraphQL Int type)
- Add updateRecording mutation to services
- Add publish/unpublish buttons to RecordingCard (draft ↔ published)
- Remove "Go to Play" button from recordings page header
- Add publish/unpublish i18n keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:26:42 +01:00
d9a60f0572 style: refine admin & me UI — card forms, back arrows, avatar in admin sidebar, Empty component
- Replace ← text with icon-[ri--arrow-left-line] in admin and me layouts
- Add avatar + admin shield badge to admin sidebar header
- Wrap all admin edit forms in Card (bg-card/50 border-primary/20) with styled inputs
- Fix sm:pl-6 → lg:pl-6 so extra left padding only applies when sidebar is visible
- Update security form submit button to gradient style matching profile
- Remove "View Public Profile" button from me/profile
- Use shadcn-svelte Empty component for recordings empty state
- Install empty component via shadcn-svelte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:16:39 +01:00
ba648c796a feat: refactor /me into admin-style layout with profile, security, recordings, analytics sub-pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:47:00 +01:00
27e2ff5f66 feat: add Meta title tags to all admin pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:23:35 +01:00
b7a29c55b3 style: fix header nav gap
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m16s
2026-03-09 10:24:14 +01:00
99b2ed7f2b feat: show login/signup buttons on mobile header
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 10:00:02 +01:00
8357aecf98 feat: add ring border to logo icon matching avatar style
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 09:58:27 +01:00
ab3d9f4118 feat: show auth icon strip on mobile header, move burger outside pill
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 09:50:25 +01:00
5219fae36a feat: add structured logging to BullMQ queues and workers
All checks were successful
Build and Push Backend Image / build (push) Successful in 43s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 09:33:43 +01:00
7de1bf7a03 style: fix header
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m46s
2026-03-09 08:56:49 +01:00
a4fd1ff18b fix: soften header shadow glow
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 20:32:17 +01:00
6605980a43 style: move page gradient to global background so it shows behind header
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m19s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:32:58 +01:00
15d9708072 Revert "feat: fixed header with hero section extending behind it"
This reverts commit fc97c1b84b.
2026-03-08 19:25:15 +01:00
89c4c390fa Revert "feat: extend page-hero behind fixed header on all pages"
This reverts commit f5ff59b910.
2026-03-08 19:25:15 +01:00
f5ff59b910 feat: extend page-hero behind fixed header on all pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:24:22 +01:00
fc97c1b84b feat: fixed header with hero section extending behind it
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:22:41 +01:00
e2abb0794a style: use text-foreground color for burger menu lines
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:18:39 +01:00
2644e033b4 style: remove underline from logout button hover
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:16:43 +01:00
ee1cea6d01 style: match logout button hover to other icon buttons, destructive color on hover
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:16:04 +01:00
1496399b96 style: remove header border, keep glow shadow only
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:09:55 +01:00
075f64f4e3 style: single crisp primary border with soft glow on header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:06:43 +01:00
8c6c98d612 style: edgy glowing bottom border on header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:05:41 +01:00
28be084781 style: transparent header, no scroll tracking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:04:25 +01:00
21b8d2c223 feat: transparent header at top, solid on scroll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:03:39 +01:00
b315062d43 revert: restore original header styling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:02:42 +01:00
5bef996dbc fix: use derived override pattern for selectedQueue to avoid captured state warning
All checks were successful
Build and Push Backend Image / build (push) Successful in 43s
Build and Push Frontend Image / build (push) Successful in 1m38s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:36:50 +01:00
da2484d232 fix: replace nested button with div[role=button] on queue cards
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:35:16 +01:00
722392d19e chore: lint and format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:32:39 +01:00
a07a5cb091 fix: suppress false-positive svelte state warning on queues page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:31:35 +01:00
ea23233645 feat: add BullMQ job queue with admin monitoring UI
All checks were successful
Build and Push Backend Image / build (push) Successful in 48s
Build and Push Buttplug Image / build (push) Successful in 3m26s
Build and Push Frontend Image / build (push) Successful in 1m11s
- Add BullMQ to backend; mail jobs (verification, password reset) now enqueued instead of sent inline
- Mail worker processes jobs with 3-attempt exponential backoff retry
- Admin GraphQL resolvers: adminQueues, adminQueueJobs, adminRetryJob, adminRemoveJob, adminPauseQueue, adminResumeQueue
- Admin frontend page at /admin/queues: queue cards with counts, job table with status filter, retry/remove/pause actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:25:09 +01:00
6dcdc0130b chore: streamline package.json files 2026-03-08 17:46:04 +01:00
8508e1f6e9 style: reduce header background opacity for softer appearance
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 17:39:47 +01:00
6abcfc7363 chore: remove unused super-sitemap dependency
All checks were successful
Build and Push Buttplug Image / build (push) Successful in 3m38s
Build and Push Frontend Image / build (push) Successful in 1m12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 17:22:55 +01:00
d4b3968518 fix: suppress Rust compiler warnings in buttplug
- #[allow(dead_code)] on FFICallbackContextWrapper, Connected variant,
  Unsubscribe variant, and device_event_receiver field
- let _ = for unused Result from callback.call1() (x2) and .send().await

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 17:21:58 +01:00
8f4999f127 chore: upgrade pnpm from 10.19.0 to 10.31.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 17:19:39 +01:00
4b53a25fa3 fix: proper build script calls in package.json 2026-03-08 17:16:46 +01:00
4f85637875 fix: upgrade Node.js to 22.14.0, add svelte-kit sync before build
All checks were successful
Build and Push Backend Image / build (push) Successful in 1m9s
Build and Push Buttplug Image / build (push) Successful in 4m21s
Build and Push Frontend Image / build (push) Successful in 1m15s
- Node 22.11.0 is below Vite's minimum requirement of 22.12+
- svelte-kit sync must run before vite build to generate
  .svelte-kit/tsconfig.json which tsconfig.json extends

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 17:14:51 +01:00
1175b4d0e6 chore: update @internationalized/date to 3.12.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 17:12:57 +01:00
2afa3c6e9b fix: replace raw HTML buttons with Button component in admin, remove vite-plugin-wasm
- Use Button component for photo remove, editor tab toggle, and model
  pill buttons across admin/users, admin/articles, admin/videos
- Remove vite-plugin-wasm from frontend devDependencies (no longer
  needed since WASM is served by the buttplug nginx container)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 17:11:24 +01:00
b55cebea4e ci: add path filters to all workflow triggers
Each image only builds when its relevant source changes:
- backend: packages/backend/**, packages/types/**, Dockerfile.backend
- frontend: packages/frontend/**, packages/types/**, Dockerfile
- buttplug: packages/buttplug/**, Dockerfile.buttplug, nginx.buttplug.conf

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 16:49:48 +01:00
9845553d49 fix: switch buttplug WASM to --target web for browser compatibility
All checks were successful
Build and Push Backend Image / build (push) Successful in 16s
Build and Push Buttplug Image / build (push) Successful in 3m37s
Build and Push Frontend Image / build (push) Successful in 1m15s
--target bundler generates static WASM ESM imports that only work
through a bundler. --target web generates fetch-based WASM loading
via import.meta.url which browsers handle natively.

- Change wasm-pack build target from bundler to web
- Call wasmModule.default() (init) after import in maybeLoadWasm
- Add .gitignore to exclude dist/ and wasm/ build outputs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 14:12:01 +01:00
ced0a08da3 fix: serve buttplug locally in dev instead of Docker
Add a minimal Node.js static server (serve.mjs) to the buttplug package
that serves dist/ and wasm/ on port 8080 with correct MIME types.
Update dev:buttplug to use it instead of docker compose, avoiding a
full Rust/WASM Docker build on every dev start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 13:55:31 +01:00
f880aa5957 feat: externalize buttplug as separate nginx container
- Add Dockerfile.buttplug: builds Rust/WASM + TS, serves via nginx
- Add nginx.buttplug.conf: serves /dist and /wasm with correct MIME types
- Add .gitea/workflows/docker-build-buttplug.yml: path-filtered CI workflow
- Strip Rust toolchain and buttplug build from frontend Dockerfile
- Move buttplug to devDependencies (types only at build time)
- Remove vite-plugin-wasm from frontend (WASM now served by nginx)
- Add /buttplug proxy in vite.config (dev: localhost:8080)
- Add buttplug service to compose.yml
- Load buttplug dynamically in play page via runtime import
- Fix faq page: suppress no-unnecessary-state-wrap for reassigned SvelteSet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 13:49:38 +01:00
239128bf5e fix: buttplug lint errors
All checks were successful
Build and Push Backend Image / build (push) Successful in 17s
Build and Push Frontend Image / build (push) Successful in 4m34s
2026-03-08 13:28:59 +01:00
0a50c3efd8 fix: remove sitemap.xml
All checks were successful
Build and Push Backend Image / build (push) Successful in 16s
Build and Push Frontend Image / build (push) Successful in 4m29s
2026-03-08 12:04:50 +01:00
af4a11b73c style: apply prettier formatting to svelte and ts files
Some checks failed
Build and Push Backend Image / build (push) Successful in 1m3s
Build and Push Frontend Image / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:49:43 +01:00
627ce75719 fix: restore \$state on SvelteMap in device-mapping-dialog
The variable is fully reassigned in an \$effect, so \$state is required
for reactivity. Suppress the no-unnecessary-state-wrap lint rule with a
comment explaining the reason.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:47:21 +01:00
446e9f835b fix: use writable \$derived for search inputs, remove unnecessary \$state wrap
- Replace \$state + \$effect pattern with writable \$derived (Svelte 5.25+)
  for all searchValue instances across list pages — cleaner and lint-compliant
- Remove now-unused untrack imports from those files
- Drop \$state() wrapper around SvelteMap in device-mapping-dialog
  (SvelteMap is already reactive)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:46:15 +01:00
422f97417e fix: resolve vite-plugin-svelte warnings
- image-viewer: replace backdrop div with button for a11y
- file-drop-zone: wrap prop check in \$effect to avoid state_referenced_locally
- about: use \$derived for stats array
- magazine: use \$derived for featuredArticle
- play: add role/keyboard support to seek bar slider; fix \$state on SvelteMap in device-mapping-dialog
- admin/videos/[id]: add <track kind="captions"> to video element

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:41:58 +01:00
edee98b552 fix: use untrack() in \$state initialisers to silence state_referenced_locally warnings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:31:51 +01:00
b9b98f178f fix: sync reactive state with data prop using \$effect
Replaces bare \$state(data.x) initialisers (which only capture the
initial value) with \$state + \$effect pairs so that state stays in sync
whenever page data is invalidated or the URL changes. Affects all list
pages (searchValue) and all edit/detail pages (form fields).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:06:30 +01:00
dc1850126b fix: run DB migrations automatically at backend startup
Instead of relying on a manual `pnpm db:migrate` step (which was
connecting to a different postgres than the Docker container), the
backend now calls drizzle migrate() before the server starts. This
ensures migrations always run against the correct database on startup.

Also fixes the Dockerfile to copy migrations into dist/migrations so
the path resolves correctly in the compiled output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:00:36 +01:00
4d81266cb1 feat: add dedicated model photo separate from avatar
Adds a `photo` field to the users table (and a migration) that serves
as a dedicated profile/card image for models. This is now used in model
cards and on the model single page, while `avatar` is reserved for
comments, article authors, and the user profile page.

- DB: `photo` column on `users` with FK to `files`
- GraphQL: exposed on ModelType, UserType, AdminUserDetailType; photoId arg on adminUpdateUser
- Services: photo field in MODELS_QUERY, MODEL_BY_SLUG_QUERY, ADMIN_GET/UPDATE_USER
- Frontend: model cards and single page use `photo ?? avatar` fallback
- Admin: model photo upload section in user edit page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 10:54:27 +01:00
2980c0b637 fix: remove brand name text from mobile flyout header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 10:42:22 +01:00
7af9c0d7ca fix: fix admin mobile nav overflow breaking layout on small screens
Mobile nav now scrolls horizontally with hidden scrollbar; nav items
don't shrink and show icon-only on xs, icon+label on sm and up.
Added scrollbar-none utility to app.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 10:39:12 +01:00
76d71ee7c3 fix: remove comments list timestamp gap
All checks were successful
Build and Push Backend Image / build (push) Successful in 17s
Build and Push Frontend Image / build (push) Successful in 4m25s
2026-03-07 19:37:52 +01:00
90497e9e7c fix: resolve TypeScript build errors from leftJoin nullable types
Some checks failed
Build and Push Backend Image / build (push) Successful in 42s
Build and Push Frontend Image / build (push) Has been cancelled
Non-null assert photo/achievement ids that are structurally non-null
due to FK constraints but nullable in Drizzle's leftJoin return type.
Add missing description field to enrichVideo model select and map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 19:33:16 +01:00
a558449964 chore: remove eslint warn rule
Some checks failed
Build and Push Backend Image / build (push) Failing after 29s
Build and Push Frontend Image / build (push) Successful in 4m12s
2026-03-07 19:28:17 +01:00
e236ced12a refactor: replace all explicit any types with proper TypeScript types
Backend resolvers: typed enrichArticle/enrichVideo/enrichModel with DB
and $inferSelect types, SQL<unknown>[] for conditions arrays, proper
enum casts for status/role fields, $inferInsert for .set() updates,
typed raw SQL result rows in gamification, ReplyLike interface for
ctx.reply in auth. Frontend: typed catch blocks with Error/interface
casts, isActiveLink param, adminGetUser response, tags filter callback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 19:25:04 +01:00
8313664d70 chore: fix all lint errors and format codebase
- Remove unused `or` import in comments resolver
- Remove unused `users` import in recordings resolver
- Add index keys to pagination {#each} loops in videos, models, magazine
- Remove stale svelte-ignore comment in header (a11y warnings no longer fired)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 19:06:57 +01:00
ae0929ad06 fix: replace arrow symbol with icon css in author profile link
All checks were successful
Build and Push Backend Image / build (push) Successful in 43s
Build and Push Frontend Image / build (push) Successful in 4m12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 19:03:41 +01:00
b78831231d fix: select description from users in article enrichArticle query
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 19:02:53 +01:00
f90b045ca5 fix: add description to VideoModel type and GraphQL schema
Requesting description on the article author caused a GraphQL error
which the page.server.ts caught as a 404.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 19:01:52 +01:00
d2cbb1004f fix: show author description on magazine article page
Add description field to ARTICLE_BY_SLUG_QUERY and render it in the
author bio card below the name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:58:42 +01:00
77ebccf6fa feat: redesign avatar upload as circular click-to-change UI
Replace generic file drop zone + tiny thumbnail with a 96px circular
avatar that shows a camera overlay on hover, upgrades preview to
thumbnail quality, and adds a compact remove button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:55:56 +01:00
1c101406f6 fix: match admin background gradient to rest of the app
All checks were successful
Build and Push Backend Image / build (push) Successful in 44s
Build and Push Frontend Image / build (push) Successful in 4m4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:51:40 +01:00
cb7720ca9c fix: smooth hero-to-content transition with transparent gradient fade
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:47:24 +01:00
df099b2700 fix: remove extra closing div in models pagination
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:42:39 +01:00
291f72381f feat: improve UX across all listing pages and homepage
- Make model/video cards fully clickable on homepage, models, videos, magazine, and tags pages
- Replace inline blob divs with SexyBackground component on magazine and play pages
- Replace magazine hero section with PageHero component for consistency
- Remove redundant action buttons from cards (cards are now the link targets)
- Fix nested anchor/button invalid HTML in magazine featured article
- Convert inner overlay anchors to aria-hidden divs to avoid nested <a> elements
- Add bg-muted skeleton placeholder to all card images
- Update magazine pagination to smart numbered style with ellipsis (matching videos/models)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:41:39 +01:00
1a2fab3e37 refactor: UX and styling improvements across frontend
- Fix login spinner (isLoading never set to true before await)
- Extract PageHero component, replace copy-pasted hero sections on videos/models/tags pages
- Replace inline plasma blobs with SexyBackground on videos/models/tags pages
- Make video/model/tag cards fully clickable (wrap in <a>), remove redundant Watch/View Profile buttons
- Convert inner overlay anchors to divs to avoid nested <a> elements
- Fix home page model avatar preset: mini → thumbnail (correct size for 96px display)
- Reduce home hero height: min-h-screen → min-h-[70vh]
- Remove dead hideName prop from Logo, simplify component
- Add brand name to mobile flyout panel header with gradient styling
- Remove dead _relatedVideos array, isBookmarked state, _handleBookmark from video detail page
- Clean up commented-out code blocks in video detail and models pages
- Note: tag card inner tag links converted to spans to avoid nested anchors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:33:32 +01:00
56b57486dc fix: add upload/delete file endpoints and wire avatar update through profile
- Add POST /upload and DELETE /assets/:id routes to backend (session auth via session_token cookie)
- Add avatar arg to updateProfile GraphQL mutation and resolver
- Fix frontend to pass avatarId correctly on save, preserve existing avatar when unchanged
- Ignore 404 on file delete (already gone is fine)
- Remove broken folder lookup (getFolders is a stub, backend has no folder concept)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:22:22 +01:00
a050e886cb feat: replace slide-to-logout with avatar + name + logout button in header
Removes the drag-to-logout widget in favour of a clean inline layout:
- Desktop: avatar (links to /me), artist name, and a logout icon button
- Mobile flyout: user card with avatar, name, email, and logout button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:12:18 +01:00
519fd45d8d feat: rebrand to Sexy, restyle logo with gradient icon and updated assets
- Rename brand from SexyArt to Sexy throughout i18n locale
- Apply primary→accent gradient to SVG icon stroke
- Remove brand name text from logo, icon-only display
- Switch logo font to Noto Sans bold (default), drop Dancing Script
- Update favicons, app icons, webmanifest, and add logo.svg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:04:14 +01:00
0592d27a15 fix: redirect authenticated users away from login, signup, and password pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 15:58:12 +01:00
a38883e631 fix: align admin filter toggle buttons with search input height
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 15:51:13 +01:00
798495c3d6 fix: remove archived badge from recording card and i18n
All checks were successful
Build and Push Backend Image / build (push) Successful in 17s
Build and Push Frontend Image / build (push) Successful in 4m19s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:57:40 +01:00
fde0d63271 feat: remove archived status from recordings, deletions are now immediate
All checks were successful
Build and Push Backend Image / build (push) Successful in 45s
Build and Push Frontend Image / build (push) Successful in 4m3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:42:13 +01:00
754a236e51 feat: add admin tables for comments and recordings
All checks were successful
Build and Push Backend Image / build (push) Successful in 44s
Build and Push Frontend Image / build (push) Successful in 4m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:29:48 +01:00
dfe49b5882 feat: allow users to delete their own comments on videos
All checks were successful
Build and Push Backend Image / build (push) Successful in 16s
Build and Push Frontend Image / build (push) Successful in 4m7s
Shows a delete button on each comment for the comment author and admins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:19:50 +01:00
9ba848372a fix: make gamification calls non-blocking so errors don't fail core mutations
Some checks failed
Build and Push Backend Image / build (push) Successful in 43s
Build and Push Frontend Image / build (push) Has been cancelled
awardPoints/checkAchievements were awaited inline, so any gamification error
(DB constraint, missing table, etc.) would propagate as INTERNAL_SERVER_ERROR
on comment creation, recording plays, etc. Now they run fire-and-forget with
error logging, so the core action always succeeds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:16:27 +01:00
dcf2fbd3d4 feat: enhance session security and freshness
All checks were successful
Build and Push Backend Image / build (push) Successful in 43s
Build and Push Frontend Image / build (push) Successful in 4m15s
- Sliding expiration: reset 24h TTL on every Redis session access
- SameSite=Strict on login and logout cookies (was Lax)
- Secure flag on logout cookie in production (was missing)
- Re-fetch user from DB on every request in buildContext so role/avatar/
  admin changes take effect immediately without requiring re-login

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:10:01 +01:00
bff354094e fix: add adminGetVideo/adminGetArticle queries to fix 404 on edit pages
Some checks failed
Build and Push Backend Image / build (push) Successful in 43s
Build and Push Frontend Image / build (push) Has been cancelled
The edit page loaders were calling adminListVideos/adminListArticles with the
old pre-pagination signatures and filtering by ID client-side, which broke
after pagination limited results to 50. Now fetches the single item by ID
directly via new adminGetVideo and adminGetArticle backend queries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:05:21 +01:00
6f2f3b3529 fix: deduplicate model photos in public resolver to match admin behavior
All checks were successful
Build and Push Backend Image / build (push) Successful in 43s
Build and Push Frontend Image / build (push) Successful in 4m10s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 10:54:29 +01:00
147 changed files with 6612 additions and 3546 deletions

View File

@@ -7,9 +7,17 @@ on:
- develop
tags:
- "v*.*.*"
paths:
- "packages/backend/**"
- "packages/types/**"
- "Dockerfile.backend"
pull_request:
branches:
- main
paths:
- "packages/backend/**"
- "packages/types/**"
- "Dockerfile.backend"
workflow_dispatch:
env:

View File

@@ -0,0 +1,68 @@
name: Build and Push Buttplug Image
on:
push:
branches:
- main
- develop
tags:
- "v*.*.*"
paths:
- "packages/buttplug/**"
- "Dockerfile.buttplug"
- "nginx.buttplug.conf"
pull_request:
branches:
- main
paths:
- "packages/buttplug/**"
- "Dockerfile.buttplug"
- "nginx.buttplug.conf"
workflow_dispatch:
env:
REGISTRY: dev.pivoine.art
IMAGE_NAME: valknar/sexy-buttplug
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=sha,prefix={{branch}}-
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.buttplug
platforms: linux/amd64
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

View File

@@ -7,9 +7,17 @@ on:
- develop
tags:
- "v*.*.*"
paths:
- "packages/frontend/**"
- "packages/types/**"
- "Dockerfile"
pull_request:
branches:
- main
paths:
- "packages/frontend/**"
- "packages/types/**"
- "Dockerfile"
workflow_dispatch:
env:

View File

@@ -3,7 +3,7 @@
# ============================================================================
# Base stage - shared dependencies
# ============================================================================
FROM node:22.11.0-slim AS base
FROM node:22.14.0-slim AS base
# Enable corepack for pnpm
RUN npm install -g corepack@latest && corepack enable
@@ -20,48 +20,22 @@ RUN mkdir -p ./packages/frontend && \
printf 'PUBLIC_API_URL=\nPUBLIC_URL=\nPUBLIC_UMAMI_ID=\nPUBLIC_UMAMI_SCRIPT=\n' > ./packages/frontend/.env
# ============================================================================
# Builder stage - compile application with Rust/WASM support
# Builder stage - compile frontend
# ============================================================================
FROM base AS builder
ARG CI=false
ENV CI=$CI
# Install build dependencies for Rust and native modules
RUN apt-get update && apt-get install -y \
curl \
build-essential \
pkg-config \
libssl-dev \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Install Rust toolchain
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
--default-toolchain stable \
--profile minimal \
--target wasm32-unknown-unknown
# Add Rust to PATH
ENV PATH="/root/.cargo/bin:${PATH}"
# Install wasm-bindgen-cli
RUN cargo install wasm-bindgen-cli
# Copy source files
COPY packages ./packages
# Install all dependencies
RUN pnpm install --frozen-lockfile
# Build packages in correct order with WASM support
# 1. Build buttplug WASM
RUN RUSTFLAGS='--cfg getrandom_backend="wasm_js" --cfg=web_sys_unstable_apis' \
pnpm --filter @sexy.pivoine.art/buttplug build:wasm
# Generate SvelteKit type definitions (creates .svelte-kit/tsconfig.json)
RUN pnpm --filter @sexy.pivoine.art/frontend exec svelte-kit sync
# 2. Build buttplug TypeScript
RUN pnpm --filter @sexy.pivoine.art/buttplug build
# 3. Build frontend
# Build frontend
RUN pnpm --filter @sexy.pivoine.art/frontend build
# Prune dev dependencies for production
@@ -70,7 +44,7 @@ RUN CI=true pnpm install -rP
# ============================================================================
# Runner stage - minimal production image
# ============================================================================
FROM node:22.11.0-slim AS runner
FROM node:22.14.0-slim AS runner
# Install dumb-init for proper signal handling
RUN apt-get update && apt-get install -y \
@@ -91,19 +65,14 @@ COPY --from=builder --chown=node:node /app/package.json ./package.json
COPY --from=builder --chown=node:node /app/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=builder --chown=node:node /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
# Create package directories
RUN mkdir -p packages/frontend packages/buttplug
# Create package directory
RUN mkdir -p packages/frontend
# Copy frontend artifacts
COPY --from=builder --chown=node:node /app/packages/frontend/build ./packages/frontend/build
COPY --from=builder --chown=node:node /app/packages/frontend/node_modules ./packages/frontend/node_modules
COPY --from=builder --chown=node:node /app/packages/frontend/package.json ./packages/frontend/package.json
# Copy buttplug artifacts
COPY --from=builder --chown=node:node /app/packages/buttplug/dist ./packages/buttplug/dist
COPY --from=builder --chown=node:node /app/packages/buttplug/node_modules ./packages/buttplug/node_modules
COPY --from=builder --chown=node:node /app/packages/buttplug/package.json ./packages/buttplug/package.json
# Switch to non-root user
USER node

View File

@@ -3,7 +3,7 @@
# ============================================================================
# Builder stage
# ============================================================================
FROM node:22.11.0-slim AS builder
FROM node:22.14.0-slim AS builder
RUN npm install -g corepack@latest && corepack enable
@@ -34,7 +34,7 @@ RUN pnpm rebuild argon2 sharp
# ============================================================================
# Runner stage
# ============================================================================
FROM node:22.11.0-slim AS runner
FROM node:22.14.0-slim AS runner
RUN apt-get update && apt-get install -y \
dumb-init \
@@ -55,7 +55,7 @@ COPY --from=builder --chown=node:node /app/package.json ./package.json
COPY --from=builder --chown=node:node /app/packages/backend/dist ./packages/backend/dist
COPY --from=builder --chown=node:node /app/packages/backend/node_modules ./packages/backend/node_modules
COPY --from=builder --chown=node:node /app/packages/backend/package.json ./packages/backend/package.json
COPY --from=builder --chown=node:node /app/packages/backend/src/migrations ./packages/backend/migrations
COPY --from=builder --chown=node:node /app/packages/backend/src/migrations ./packages/backend/dist/migrations
RUN mkdir -p /data/uploads && chown node:node /data/uploads

65
Dockerfile.buttplug Normal file
View File

@@ -0,0 +1,65 @@
# syntax=docker/dockerfile:1
# ============================================================================
# Builder stage - compile Rust/WASM and TypeScript
# ============================================================================
FROM node:22.14.0-slim AS builder
# Install build dependencies for Rust
RUN apt-get update && apt-get install -y \
curl \
build-essential \
pkg-config \
libssl-dev \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Enable corepack for pnpm
RUN npm install -g corepack@latest && corepack enable
# Install Rust toolchain
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
--default-toolchain stable \
--profile minimal \
--target wasm32-unknown-unknown
ENV PATH="/root/.cargo/bin:${PATH}"
# Install wasm-bindgen-cli
RUN cargo install wasm-bindgen-cli
WORKDIR /app
# Copy workspace configuration
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY packages/buttplug ./packages/buttplug
# Install dependencies
RUN pnpm install --frozen-lockfile --filter @sexy.pivoine.art/buttplug
# Build WASM
RUN RUSTFLAGS='--cfg getrandom_backend="wasm_js" --cfg=web_sys_unstable_apis' \
pnpm --filter @sexy.pivoine.art/buttplug build:wasm
# Build TypeScript
RUN pnpm --filter @sexy.pivoine.art/buttplug build
# ============================================================================
# Runner stage - nginx serving dist/ and wasm/
# ============================================================================
FROM nginx:1.27-alpine AS runner
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
# Copy nginx config
COPY nginx.buttplug.conf /etc/nginx/conf.d/buttplug.conf
# Copy built artifacts
COPY --from=builder /app/packages/buttplug/dist /usr/share/nginx/html/dist
COPY --from=builder /app/packages/buttplug/wasm /usr/share/nginx/html/wasm
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/dist/index.js || exit 1

View File

@@ -64,6 +64,21 @@ services:
timeout: 10s
retries: 3
start_period: 20s
buttplug:
build:
context: .
dockerfile: Dockerfile.buttplug
container_name: sexy_buttplug
restart: unless-stopped
ports:
- "8080:80"
healthcheck:
test:
["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/dist/index.js"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
frontend:
build:
context: .
@@ -78,9 +93,12 @@ services:
HOST: 0.0.0.0
PUBLIC_API_URL: http://sexy_backend:4000
PUBLIC_URL: http://localhost:3000
BUTTPLUG_URL: http://sexy_buttplug:80
depends_on:
backend:
condition: service_healthy
buttplug:
condition: service_healthy
volumes:
uploads_data:

View File

@@ -33,8 +33,6 @@ export default ts.config(
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
// Allow explicit any sparingly — we're adults here
"@typescript-eslint/no-explicit-any": "warn",
// Enforce consistent type imports
"@typescript-eslint/consistent-type-imports": [
"error",
@@ -53,7 +51,7 @@ export default ts.config(
"**/dist/",
"**/node_modules/",
"**/migrations/",
"packages/buttplug/**",
"**/wasm/",
],
},
);

23
nginx.buttplug.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
# WASM MIME type
include /etc/nginx/mime.types;
types {
application/wasm wasm;
}
# Cache JS and WASM aggressively (content-addressed by build)
location ~* \.(js|wasm)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Cross-Origin-Resource-Policy "cross-origin";
add_header Cross-Origin-Embedder-Policy "require-corp";
}
location / {
try_files $uri =404;
}
}

View File

@@ -5,11 +5,12 @@
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build:frontend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/frontend build",
"build:backend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/backend build",
"build:frontend": "pnpm --filter @sexy.pivoine.art/frontend build",
"build:backend": "pnpm --filter @sexy.pivoine.art/backend build",
"dev:buttplug": "pnpm --filter @sexy.pivoine.art/buttplug serve",
"dev:data": "docker compose up -d postgres redis",
"dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev",
"dev": "pnpm dev:data && pnpm dev:backend & pnpm --filter @sexy.pivoine.art/frontend dev",
"dev": "pnpm dev:data && pnpm dev:backend & pnpm dev:buttplug & pnpm --filter @sexy.pivoine.art/frontend dev",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
@@ -22,7 +23,7 @@
"email": "valknar@pivoine.art"
},
"license": "MIT",
"packageManager": "pnpm@10.19.0",
"packageManager": "pnpm@10.31.0",
"pnpm": {
"onlyBuiltDependencies": [
"argon2",

View File

@@ -14,14 +14,15 @@
"check": "tsc --noEmit"
},
"dependencies": {
"@sexy.pivoine.art/types": "workspace:*",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^10.0.2",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.1.1",
"@pothos/core": "^4.4.0",
"@pothos/plugin-errors": "^4.2.0",
"@sexy.pivoine.art/types": "workspace:*",
"argon2": "^0.43.0",
"bullmq": "^5.70.4",
"drizzle-orm": "^0.44.1",
"fastify": "^5.4.0",
"fluent-ffmpeg": "^2.1.3",

View File

@@ -8,6 +8,7 @@ import {
pgEnum,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { users } from "./users";
import { recordings } from "./recordings";
@@ -68,6 +69,11 @@ export const user_points = pgTable(
(t) => [
index("user_points_user_idx").on(t.user_id),
index("user_points_date_idx").on(t.date_created),
uniqueIndex("user_points_unique_action_recording")
.on(t.user_id, t.action, t.recording_id)
.where(
sql`"action" IN ('RECORDING_CREATE', 'RECORDING_FEATURED') AND "recording_id" IS NOT NULL`,
),
],
);

View File

@@ -12,7 +12,7 @@ import {
import { users } from "./users";
import { videos } from "./videos";
export const recordingStatusEnum = pgEnum("recording_status", ["draft", "published", "archived"]);
export const recordingStatusEnum = pgEnum("recording_status", ["draft", "published"]);
export const recordings = pgTable(
"recordings",

View File

@@ -29,6 +29,7 @@ export const users = pgTable(
role: roleEnum("role").notNull().default("viewer"),
avatar: text("avatar").references(() => files.id, { onDelete: "set null" }),
banner: text("banner").references(() => files.id, { onDelete: "set null" }),
photo: text("photo").references(() => files.id, { onDelete: "set null" }),
is_admin: boolean("is_admin").notNull().default(false),
email_verified: boolean("email_verified").notNull().default(false),
email_verify_token: text("email_verify_token"),

View File

@@ -1,9 +1,11 @@
import type { YogaInitialContext } from "graphql-yoga";
import type { FastifyRequest, FastifyReply } from "fastify";
import type { Context } from "./builder";
import { getSession } from "../lib/auth";
import { getSession, setSession } from "../lib/auth";
import { db } from "../db/connection";
import { redis } from "../lib/auth";
import { users } from "../db/schema/index";
import { eq } from "drizzle-orm";
type ServerContext = {
req: FastifyRequest;
@@ -25,7 +27,34 @@ export async function buildContext(ctx: YogaInitialContext & ServerContext): Pro
);
const token = cookies["session_token"];
const currentUser = token ? await getSession(token) : null;
let currentUser = null;
if (token) {
const session = await getSession(token); // also slides TTL
if (session) {
const dbInstance = ctx.db || db;
const [dbUser] = await dbInstance
.select()
.from(users)
.where(eq(users.id, session.id))
.limit(1);
if (dbUser) {
currentUser = {
id: dbUser.id,
email: dbUser.email,
role: (dbUser.role === "admin" ? "viewer" : dbUser.role) as "model" | "viewer",
is_admin: dbUser.is_admin,
first_name: dbUser.first_name,
last_name: dbUser.last_name,
artist_name: dbUser.artist_name,
slug: dbUser.slug,
avatar: dbUser.avatar,
};
// Refresh cached session with up-to-date data
await setSession(token, currentUser);
}
}
}
return {
db: ctx.db || db,

View File

@@ -9,6 +9,7 @@ import "./resolvers/recordings.js";
import "./resolvers/comments.js";
import "./resolvers/gamification.js";
import "./resolvers/stats.js";
import "./resolvers/queues.js";
import { builder } from "./builder";
export const schema = builder.toSchema();

View File

@@ -1,10 +1,11 @@
import { builder } from "../builder";
import { ArticleType, ArticleListType, AdminArticleListType } from "../types/index";
import { articles, users } from "../../db/schema/index";
import { eq, and, lte, desc, asc, ilike, or, count, arrayContains } from "drizzle-orm";
import { eq, and, lte, desc, asc, ilike, or, count, arrayContains, type SQL } from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
import type { DB } from "../../db/connection";
async function enrichArticle(db: any, article: any) {
async function enrichArticle(db: DB, article: typeof articles.$inferSelect) {
let author = null;
if (article.author) {
const authorUser = await db
@@ -13,6 +14,7 @@ async function enrichArticle(db: any, article: any) {
artist_name: users.artist_name,
slug: users.slug,
avatar: users.avatar,
description: users.description,
})
.from(users)
.where(eq(users.id, article.author))
@@ -38,7 +40,7 @@ builder.queryField("articles", (t) =>
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
const conditions: any[] = [lte(articles.publish_date, new Date())];
const conditions: SQL<unknown>[] = [lte(articles.publish_date, new Date())];
if (args.featured !== null && args.featured !== undefined) {
conditions.push(eq(articles.featured, args.featured));
}
@@ -49,28 +51,24 @@ builder.queryField("articles", (t) =>
or(
ilike(articles.title, `%${args.search}%`),
ilike(articles.excerpt, `%${args.search}%`),
),
) as SQL<unknown>,
);
}
const orderArgs =
args.sortBy === "name"
? [asc(articles.title)]
: args.sortBy === "featured"
? [desc(articles.featured), desc(articles.publish_date)]
: [desc(articles.publish_date)];
const where = and(...conditions);
const baseQuery = ctx.db.select().from(articles).where(where);
const ordered =
args.sortBy === "name"
? baseQuery.orderBy(asc(articles.title))
: args.sortBy === "featured"
? baseQuery.orderBy(desc(articles.featured), desc(articles.publish_date))
: baseQuery.orderBy(desc(articles.publish_date));
const [articleList, totalRows] = await Promise.all([
(ctx.db.select().from(articles).where(where) as any)
.orderBy(...orderArgs)
.limit(pageSize)
.offset(offset),
ordered.limit(pageSize).offset(offset),
ctx.db.select({ total: count() }).from(articles).where(where),
]);
const items = await Promise.all(
articleList.map((article: any) => enrichArticle(ctx.db, article)),
);
const items = await Promise.all(articleList.map((article) => enrichArticle(ctx.db, article)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
@@ -96,6 +94,22 @@ builder.queryField("article", (t) =>
}),
);
builder.queryField("adminGetArticle", (t) =>
t.field({
type: ArticleType,
nullable: true,
args: {
id: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const article = await ctx.db.select().from(articles).where(eq(articles.id, args.id)).limit(1);
if (!article[0]) return null;
return enrichArticle(ctx.db, article[0]);
},
}),
);
// ─── Admin queries & mutations ────────────────────────────────────────────────
builder.queryField("adminListArticles", (t) =>
@@ -113,13 +127,13 @@ builder.queryField("adminListArticles", (t) =>
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
const conditions: any[] = [];
const conditions: SQL<unknown>[] = [];
if (args.search) {
conditions.push(
or(
ilike(articles.title, `%${args.search}%`),
ilike(articles.excerpt, `%${args.search}%`),
),
) as SQL<unknown>,
);
}
if (args.category) conditions.push(eq(articles.category, args.category));
@@ -137,9 +151,7 @@ builder.queryField("adminListArticles", (t) =>
.offset(offset),
ctx.db.select({ total: count() }).from(articles).where(where),
]);
const items = await Promise.all(
articleList.map((article: any) => enrichArticle(ctx.db, article)),
);
const items = await Promise.all(articleList.map((article) => enrichArticle(ctx.db, article)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
@@ -215,7 +227,7 @@ builder.mutationField("updateArticle", (t) =>
const updated = await ctx.db
.update(articles)
.set(updates as any)
.set(updates as Partial<typeof articles.$inferInsert>)
.where(eq(articles.id, args.id))
.returning();
if (!updated[0]) return null;

View File

@@ -3,9 +3,13 @@ import { builder } from "../builder";
import { CurrentUserType } from "../types/index";
import { users } from "../../db/schema/index";
import { eq } from "drizzle-orm";
interface ReplyLike {
header?: (name: string, value: string) => void;
}
import { hash, verify as verifyArgon } from "../../lib/argon";
import { setSession, deleteSession } from "../../lib/auth";
import { sendVerification, sendPasswordReset } from "../../lib/email";
import { enqueueVerification, enqueuePasswordReset } from "../../lib/email";
import { slugify } from "../../lib/slugify";
import { nanoid } from "nanoid";
@@ -45,13 +49,8 @@ builder.mutationField("login", (t) =>
// Set session cookie
const isProduction = process.env.NODE_ENV === "production";
const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=86400${isProduction ? "; Secure" : ""}`;
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
// For graphql-yoga response
if ((ctx as any).serverResponse) {
(ctx as any).serverResponse.setHeader("Set-Cookie", cookieValue);
}
const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400${isProduction ? "; Secure" : ""}`;
(ctx.reply as ReplyLike).header?.("Set-Cookie", cookieValue);
return user[0];
},
@@ -74,8 +73,9 @@ builder.mutationField("logout", (t) =>
await deleteSession(token);
}
// Clear cookie
const cookieValue = "session_token=; HttpOnly; Path=/; Max-Age=0";
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
const isProduction = process.env.NODE_ENV === "production";
const cookieValue = `session_token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0${isProduction ? "; Secure" : ""}`;
(ctx.reply as ReplyLike).header?.("Set-Cookie", cookieValue);
return true;
},
}),
@@ -131,9 +131,9 @@ builder.mutationField("register", (t) =>
});
try {
await sendVerification(args.email, verifyToken);
await enqueueVerification(args.email, verifyToken);
} catch (e) {
console.warn("Failed to send verification email:", (e as Error).message);
console.warn("Failed to enqueue verification email:", (e as Error).message);
}
return true;
},
@@ -190,9 +190,9 @@ builder.mutationField("requestPasswordReset", (t) =>
.where(eq(users.id, user[0].id));
try {
await sendPasswordReset(args.email, token);
await enqueuePasswordReset(args.email, token);
} catch (e) {
console.warn("Failed to send password reset email:", (e as Error).message);
console.warn("Failed to enqueue password reset email:", (e as Error).message);
}
return true;
},

View File

@@ -1,10 +1,10 @@
import { GraphQLError } from "graphql";
import { builder } from "../builder";
import { CommentType } from "../types/index";
import { CommentType, AdminCommentListType } from "../types/index";
import { comments, users } from "../../db/schema/index";
import { eq, and, desc } from "drizzle-orm";
import { awardPoints, checkAchievements } from "../../lib/gamification";
import { requireOwnerOrAdmin } from "../../lib/acl";
import { eq, and, desc, ilike, count } from "drizzle-orm";
import { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl";
import { gamificationQueue } from "../../queues/index";
builder.queryField("commentsForVideo", (t) =>
t.field({
@@ -20,7 +20,7 @@ builder.queryField("commentsForVideo", (t) =>
.orderBy(desc(comments.date_created));
return Promise.all(
commentList.map(async (c: any) => {
commentList.map(async (c) => {
const user = await ctx.db
.select({
id: users.id,
@@ -59,9 +59,16 @@ builder.mutationField("createCommentForVideo", (t) =>
})
.returning();
// Gamification
await awardPoints(ctx.db, ctx.currentUser.id, "COMMENT_CREATE");
await checkAchievements(ctx.db, ctx.currentUser.id, "social");
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "COMMENT_CREATE",
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "social",
});
const user = await ctx.db
.select({
@@ -91,7 +98,68 @@ builder.mutationField("deleteComment", (t) =>
if (!comment[0]) throw new GraphQLError("Comment not found");
requireOwnerOrAdmin(ctx, comment[0].user_id);
await ctx.db.delete(comments).where(eq(comments.id, args.id));
await gamificationQueue.add("revokePoints", {
job: "revokePoints",
userId: comment[0].user_id,
action: "COMMENT_CREATE",
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: comment[0].user_id,
category: "social",
});
return true;
},
}),
);
builder.queryField("adminListComments", (t) =>
t.field({
type: AdminCommentListType,
args: {
search: t.arg.string(),
limit: t.arg.int(),
offset: t.arg.int(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
const conditions = args.search ? [ilike(comments.comment, `%${args.search}%`)] : [];
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [commentList, totalRows] = await Promise.all([
ctx.db
.select()
.from(comments)
.where(where)
.orderBy(desc(comments.date_created))
.limit(limit)
.offset(offset),
ctx.db.select({ total: count() }).from(comments).where(where),
]);
const items = await Promise.all(
commentList.map(async (c) => {
const user = await ctx.db
.select({
id: users.id,
first_name: users.first_name,
last_name: users.last_name,
artist_name: users.artist_name,
avatar: users.avatar,
})
.from(users)
.where(eq(users.id, c.user_id))
.limit(1);
return { ...c, user: user[0] || null };
}),
);
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
);

View File

@@ -37,7 +37,7 @@ builder.queryField("leaderboard", (t) =>
.limit(limit)
.offset(offset);
return entries.map((e: any, i: number) => ({ ...e, rank: offset + i + 1 }));
return entries.map((e, i) => ({ ...e, rank: offset + i + 1 }));
},
}),
);
@@ -101,8 +101,15 @@ builder.queryField("userGamification", (t) =>
return {
stats: stats[0] ? { ...stats[0], rank } : null,
achievements: userAchievements.map((a: any) => ({
...a,
achievements: userAchievements.map((a) => ({
id: a.id!,
code: a.code!,
name: a.name!,
description: a.description!,
icon: a.icon!,
category: a.category!,
required_count: a.required_count!,
progress: a.progress!,
date_unlocked: a.date_unlocked!,
})),
recent_points: recentPoints,

View File

@@ -1,9 +1,10 @@
import { builder } from "../builder";
import { ModelType, ModelListType } from "../types/index";
import { users, user_photos, files } from "../../db/schema/index";
import { eq, and, desc, asc, ilike, count, arrayContains } from "drizzle-orm";
import { eq, and, desc, asc, ilike, count, arrayContains, type SQL } from "drizzle-orm";
import type { DB } from "../../db/connection";
async function enrichModel(db: any, user: any) {
async function enrichModel(db: DB, user: typeof users.$inferSelect) {
// Fetch photos
const photoRows = await db
.select({ id: files.id, filename: files.filename })
@@ -12,10 +13,12 @@ async function enrichModel(db: any, user: any) {
.where(eq(user_photos.user_id, user.id))
.orderBy(user_photos.sort);
return {
...user,
photos: photoRows.map((p: any) => ({ id: p.id, filename: p.filename })),
};
const seen = new Set<string>();
const photos = photoRows
.filter((p) => p.id !== null && !seen.has(p.id!) && seen.add(p.id!))
.map((p) => ({ id: p.id!, filename: p.filename! }));
return { ...user, photos };
}
builder.queryField("models", (t) =>
@@ -33,7 +36,7 @@ builder.queryField("models", (t) =>
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
const conditions: any[] = [eq(users.role, "model")];
const conditions: SQL<unknown>[] = [eq(users.role, "model")];
if (args.search) conditions.push(ilike(users.artist_name, `%${args.search}%`));
if (args.tag) conditions.push(arrayContains(users.tags, [args.tag]));
@@ -44,7 +47,7 @@ builder.queryField("models", (t) =>
ctx.db.select().from(users).where(where).orderBy(order).limit(pageSize).offset(offset),
ctx.db.select({ total: count() }).from(users).where(where),
]);
const items = await Promise.all(modelList.map((m: any) => enrichModel(ctx.db, m)));
const items = await Promise.all(modelList.map((m) => enrichModel(ctx.db, m)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),

View File

@@ -0,0 +1,151 @@
import { GraphQLError } from "graphql";
import type { Job } from "bullmq";
import { builder } from "../builder.js";
import { JobType, QueueInfoType } from "../types/index.js";
import { queues } from "../../queues/index.js";
import { requireAdmin } from "../../lib/acl.js";
const JOB_STATUSES = ["waiting", "active", "completed", "failed", "delayed"] as const;
type JobStatus = (typeof JOB_STATUSES)[number];
async function toJobData(job: Job, queueName: string) {
const status = await job.getState();
return {
id: job.id ?? "",
name: job.name,
queue: queueName,
status,
data: job.data as unknown,
result: job.returnvalue as unknown,
failedReason: job.failedReason ?? null,
attemptsMade: job.attemptsMade,
createdAt: new Date(job.timestamp),
processedAt: job.processedOn ? new Date(job.processedOn) : null,
finishedAt: job.finishedOn ? new Date(job.finishedOn) : null,
progress: typeof job.progress === "number" ? job.progress : null,
};
}
builder.queryField("adminQueues", (t) =>
t.field({
type: [QueueInfoType],
resolve: async (_root, _args, ctx) => {
requireAdmin(ctx);
return Promise.all(
Object.entries(queues).map(async ([name, queue]) => {
const counts = await queue.getJobCounts(
"waiting",
"active",
"completed",
"failed",
"delayed",
"paused",
);
const isPaused = await queue.isPaused();
return {
name,
counts: {
waiting: counts.waiting ?? 0,
active: counts.active ?? 0,
completed: counts.completed ?? 0,
failed: counts.failed ?? 0,
delayed: counts.delayed ?? 0,
paused: counts.paused ?? 0,
},
isPaused,
};
}),
);
},
}),
);
builder.queryField("adminQueueJobs", (t) =>
t.field({
type: [JobType],
args: {
queue: t.arg.string({ required: true }),
status: t.arg.string(),
limit: t.arg.int(),
offset: t.arg.int(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
const limit = args.limit ?? 25;
const offset = args.offset ?? 0;
const statuses: JobStatus[] = args.status ? [args.status as JobStatus] : [...JOB_STATUSES];
const jobs = await queue.getJobs(statuses, offset, offset + limit - 1);
return Promise.all(jobs.map((job) => toJobData(job, args.queue)));
},
}),
);
builder.mutationField("adminRetryJob", (t) =>
t.field({
type: "Boolean",
args: {
queue: t.arg.string({ required: true }),
jobId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
const job = await queue.getJob(args.jobId);
if (!job) throw new GraphQLError(`Job "${args.jobId}" not found`);
await job.retry();
return true;
},
}),
);
builder.mutationField("adminRemoveJob", (t) =>
t.field({
type: "Boolean",
args: {
queue: t.arg.string({ required: true }),
jobId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
const job = await queue.getJob(args.jobId);
if (!job) throw new GraphQLError(`Job "${args.jobId}" not found`);
await job.remove();
return true;
},
}),
);
builder.mutationField("adminPauseQueue", (t) =>
t.field({
type: "Boolean",
args: { queue: t.arg.string({ required: true }) },
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
await queue.pause();
return true;
},
}),
);
builder.mutationField("adminResumeQueue", (t) =>
t.field({
type: "Boolean",
args: { queue: t.arg.string({ required: true }) },
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
await queue.resume();
return true;
},
}),
);

View File

@@ -1,10 +1,11 @@
import { GraphQLError } from "graphql";
import { builder } from "../builder";
import { RecordingType } from "../types/index";
import { RecordingType, AdminRecordingListType } from "../types/index";
import { recordings, recording_plays } from "../../db/schema/index";
import { eq, and, desc, ne } from "drizzle-orm";
import { eq, and, desc, ilike, count, type SQL } from "drizzle-orm";
import { slugify } from "../../lib/slugify";
import { awardPoints, checkAchievements } from "../../lib/gamification";
import { requireAdmin } from "../../lib/acl";
import { gamificationQueue } from "../../queues/index";
builder.queryField("recordings", (t) =>
t.field({
@@ -20,8 +21,7 @@ builder.queryField("recordings", (t) =>
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
const conditions = [eq(recordings.user_id, ctx.currentUser.id)];
if (args.status) conditions.push(eq(recordings.status, args.status as any));
else conditions.push(ne(recordings.status, "archived" as any));
if (args.status) conditions.push(eq(recordings.status, args.status as "draft" | "published"));
if (args.linkedVideoId) conditions.push(eq(recordings.linked_video, args.linkedVideoId));
const limit = args.limit || 50;
@@ -115,17 +115,25 @@ builder.mutationField("createRecording", (t) =>
user_id: ctx.currentUser.id,
tags: args.tags || [],
linked_video: args.linkedVideoId || null,
status: (args.status as any) || "draft",
status: (args.status as "draft" | "published") || "draft",
public: false,
})
.returning();
const recording = newRecording[0];
// Gamification: award points if published
if (recording.status === "published") {
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id);
await checkAchievements(ctx.db, ctx.currentUser.id, "recordings");
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_CREATE",
recordingId: recording.id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "recordings",
});
}
return recording;
@@ -173,20 +181,51 @@ builder.mutationField("updateRecording", (t) =>
const updated = await ctx.db
.update(recordings)
.set(updates as any)
.set(updates as Partial<typeof recordings.$inferInsert>)
.where(eq(recordings.id, args.id))
.returning();
const recording = updated[0];
// Gamification: if newly published
if (args.status === "published" && existing[0].status !== "published") {
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id);
await checkAchievements(ctx.db, ctx.currentUser.id, "recordings");
}
if (args.status === "published" && recording.featured && !existing[0].featured) {
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_FEATURED", recording.id);
await checkAchievements(ctx.db, ctx.currentUser.id, "recordings");
// draft → published: award creation points
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_CREATE",
recordingId: recording.id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "recordings",
});
} else if (args.status === "draft" && existing[0].status === "published") {
// published → draft: revoke creation points
await gamificationQueue.add("revokePoints", {
job: "revokePoints",
userId: ctx.currentUser.id,
action: "RECORDING_CREATE",
recordingId: recording.id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "recordings",
});
} else if (args.status === "published" && recording.featured && !existing[0].featured) {
// newly featured while published: award featured bonus
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_FEATURED",
recordingId: recording.id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "recordings",
});
}
return recording;
@@ -212,6 +251,28 @@ builder.mutationField("deleteRecording", (t) =>
if (!existing[0]) throw new GraphQLError("Recording not found");
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
if (existing[0].status === "published") {
await gamificationQueue.add("revokePoints", {
job: "revokePoints",
userId: ctx.currentUser.id,
action: "RECORDING_CREATE",
recordingId: args.id,
});
if (existing[0].featured) {
await gamificationQueue.add("revokePoints", {
job: "revokePoints",
userId: ctx.currentUser.id,
action: "RECORDING_FEATURED",
recordingId: args.id,
});
}
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "content",
});
}
await ctx.db.delete(recordings).where(eq(recordings.id, args.id));
return true;
@@ -288,10 +349,18 @@ builder.mutationField("recordRecordingPlay", (t) =>
})
.returning({ id: recording_plays.id });
// Gamification
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_PLAY", args.recordingId);
await checkAchievements(ctx.db, ctx.currentUser.id, "playback");
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_PLAY",
recordingId: args.recordingId,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "playback",
});
}
return { success: true, play_id: play[0].id };
@@ -327,16 +396,69 @@ builder.mutationField("updateRecordingPlay", (t) =>
.where(eq(recording_plays.id, args.playId));
if (args.completed && !wasCompleted && ctx.currentUser) {
await awardPoints(
ctx.db,
ctx.currentUser.id,
"RECORDING_COMPLETE",
existing[0].recording_id,
);
await checkAchievements(ctx.db, ctx.currentUser.id, "playback");
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_COMPLETE",
recordingId: existing[0].recording_id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "playback",
});
}
return true;
},
}),
);
builder.queryField("adminListRecordings", (t) =>
t.field({
type: AdminRecordingListType,
args: {
search: t.arg.string(),
status: t.arg.string(),
limit: t.arg.int(),
offset: t.arg.int(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
const conditions: SQL<unknown>[] = [];
if (args.search) conditions.push(ilike(recordings.title, `%${args.search}%`));
if (args.status) conditions.push(eq(recordings.status, args.status as "draft" | "published"));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [rows, totalRows] = await Promise.all([
ctx.db
.select()
.from(recordings)
.where(where)
.orderBy(desc(recordings.date_created))
.limit(limit)
.offset(offset),
ctx.db.select({ total: count() }).from(recordings).where(where),
]);
return { items: rows, total: totalRows[0]?.total ?? 0 };
},
}),
);
builder.mutationField("adminDeleteRecording", (t) =>
t.field({
type: "Boolean",
args: {
id: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
await ctx.db.delete(recordings).where(eq(recordings.id, args.id));
return true;
},
}),
);

View File

@@ -2,7 +2,7 @@ import { GraphQLError } from "graphql";
import { builder } from "../builder";
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
import { users, user_photos, files } from "../../db/schema/index";
import { eq, ilike, or, count, and } from "drizzle-orm";
import { eq, ilike, or, count, and, asc, type SQL } from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
builder.queryField("me", (t) =>
@@ -45,6 +45,7 @@ builder.mutationField("updateProfile", (t) =>
artistName: t.arg.string(),
description: t.arg.string(),
tags: t.arg.stringList(),
avatar: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
@@ -58,10 +59,11 @@ builder.mutationField("updateProfile", (t) =>
if (args.description !== undefined && args.description !== null)
updates.description = args.description;
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
if (args.avatar !== undefined) updates.avatar = args.avatar;
await ctx.db
.update(users)
.set(updates as any)
.set(updates as Partial<typeof users.$inferInsert>)
.where(eq(users.id, ctx.currentUser.id));
const updated = await ctx.db
@@ -91,27 +93,27 @@ builder.queryField("adminListUsers", (t) =>
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
let query = ctx.db.select().from(users);
let countQuery = ctx.db.select({ total: count() }).from(users);
const conditions: any[] = [];
const conditions: SQL<unknown>[] = [];
if (args.role) {
conditions.push(eq(users.role, args.role as any));
conditions.push(eq(users.role, args.role as "model" | "viewer" | "admin"));
}
if (args.search) {
const pattern = `%${args.search}%`;
conditions.push(or(ilike(users.email, pattern), ilike(users.artist_name, pattern)));
}
if (conditions.length > 0) {
const where = conditions.length === 1 ? conditions[0] : and(...conditions);
query = (query as any).where(where);
countQuery = (countQuery as any).where(where);
conditions.push(
or(ilike(users.email, pattern), ilike(users.artist_name, pattern)) as SQL<unknown>,
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [items, totalRows] = await Promise.all([
(query as any).limit(limit).offset(offset),
countQuery,
ctx.db
.select()
.from(users)
.where(where)
.orderBy(asc(users.artist_name))
.limit(limit)
.offset(offset),
ctx.db.select({ total: count() }).from(users).where(where),
]);
return { items, total: totalRows[0]?.total ?? 0 };
@@ -132,12 +134,14 @@ builder.mutationField("adminUpdateUser", (t) =>
artistName: t.arg.string(),
avatarId: t.arg.string(),
bannerId: t.arg.string(),
photoId: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const updates: Record<string, unknown> = { date_updated: new Date() };
if (args.role !== undefined && args.role !== null) updates.role = args.role as any;
if (args.role !== undefined && args.role !== null)
updates.role = args.role as "model" | "viewer" | "admin";
if (args.isAdmin !== undefined && args.isAdmin !== null) updates.is_admin = args.isAdmin;
if (args.firstName !== undefined && args.firstName !== null)
updates.first_name = args.firstName;
@@ -146,10 +150,11 @@ builder.mutationField("adminUpdateUser", (t) =>
updates.artist_name = args.artistName;
if (args.avatarId !== undefined && args.avatarId !== null) updates.avatar = args.avatarId;
if (args.bannerId !== undefined && args.bannerId !== null) updates.banner = args.bannerId;
if (args.photoId !== undefined && args.photoId !== null) updates.photo = args.photoId;
const updated = await ctx.db
.update(users)
.set(updates as any)
.set(updates as Partial<typeof users.$inferInsert>)
.where(eq(users.id, args.userId))
.returning();
@@ -192,8 +197,8 @@ builder.queryField("adminGetUser", (t) =>
.orderBy(user_photos.sort);
const seen = new Set<string>();
const photos = photoRows
.filter((p: any) => p.id && !seen.has(p.id) && seen.add(p.id))
.map((p: any) => ({ id: p.id, filename: p.filename }));
.filter((p) => p.id !== null && !seen.has(p.id!) && seen.add(p.id!))
.map((p) => ({ id: p.id!, filename: p.filename! }));
return { ...user[0], photos };
},
}),

View File

@@ -28,10 +28,12 @@ import {
lt,
gte,
arrayContains,
type SQL,
} from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
import type { DB } from "../../db/connection";
async function enrichVideo(db: any, video: any) {
async function enrichVideo(db: DB, video: typeof videos.$inferSelect) {
// Fetch models
const modelRows = await db
.select({
@@ -39,6 +41,7 @@ async function enrichVideo(db: any, video: any) {
artist_name: users.artist_name,
slug: users.slug,
avatar: users.avatar,
description: users.description,
})
.from(video_models)
.leftJoin(users, eq(video_models.user_id, users.id))
@@ -61,9 +64,19 @@ async function enrichVideo(db: any, video: any) {
.from(video_plays)
.where(eq(video_plays.video_id, video.id));
const models = modelRows
.filter((m) => m.id !== null)
.map((m) => ({
id: m.id!,
artist_name: m.artist_name,
slug: m.slug,
avatar: m.avatar,
description: m.description,
}));
return {
...video,
models: modelRows,
models,
movie_file: movieFile,
likes_count: likesCount[0]?.count || 0,
plays_count: playsCount[0]?.count || 0,
@@ -87,7 +100,7 @@ builder.queryField("videos", (t) =>
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
const conditions: any[] = [lte(videos.upload_date, new Date())];
const conditions: SQL<unknown>[] = [lte(videos.upload_date, new Date())];
if (!ctx.currentUser) conditions.push(eq(videos.premium, false));
if (args.featured !== null && args.featured !== undefined) {
conditions.push(eq(videos.featured, args.featured));
@@ -107,7 +120,7 @@ builder.queryField("videos", (t) =>
conditions.push(
inArray(
videos.id,
videoIds.map((v: any) => v.video_id),
videoIds.map((v) => v.video_id),
),
);
}
@@ -148,8 +161,8 @@ builder.queryField("videos", (t) =>
.leftJoin(files, eq(videos.movie, files.id))
.where(fullWhere),
]);
const videoList = rows.map((r: any) => r.v || r);
const items = await Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v)));
const videoList = rows.map((r) => r.v);
const items = await Promise.all(videoList.map((v) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
}
@@ -157,7 +170,7 @@ builder.queryField("videos", (t) =>
ctx.db.select().from(videos).where(where).orderBy(order).limit(pageSize).offset(offset),
ctx.db.select({ total: count() }).from(videos).where(where),
]);
const items = await Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
@@ -188,6 +201,22 @@ builder.queryField("video", (t) =>
}),
);
builder.queryField("adminGetVideo", (t) =>
t.field({
type: VideoType,
nullable: true,
args: {
id: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const video = await ctx.db.select().from(videos).where(eq(videos.id, args.id)).limit(1);
if (!video[0]) return null;
return enrichVideo(ctx.db, video[0]);
},
}),
);
builder.queryField("videoLikeStatus", (t) =>
t.field({
type: VideoLikeStatusType,
@@ -405,7 +434,7 @@ builder.queryField("analytics", (t) =>
};
}
const videoIds = modelVideoIds.map((v: any) => v.video_id);
const videoIds = modelVideoIds.map((v) => v.video_id);
const videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds));
const plays = await ctx.db
.select()
@@ -419,14 +448,14 @@ builder.queryField("analytics", (t) =>
const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0);
const totalPlays = videoList.reduce((sum, v) => sum + (v.plays_count || 0), 0);
const playsByDate = plays.reduce((acc: any, play) => {
const playsByDate = plays.reduce((acc: Record<string, number>, play) => {
const date = new Date(play.date_created).toISOString().split("T")[0];
if (!acc[date]) acc[date] = 0;
acc[date]++;
return acc;
}, {});
const likesByDate = likes.reduce((acc: any, like) => {
const likesByDate = likes.reduce((acc: Record<string, number>, like) => {
const date = new Date(like.date_created).toISOString().split("T")[0];
if (!acc[date]) acc[date] = 0;
acc[date]++;
@@ -483,7 +512,7 @@ builder.queryField("adminListVideos", (t) =>
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
const conditions: any[] = [];
const conditions: SQL<unknown>[] = [];
if (args.search) conditions.push(ilike(videos.title, `%${args.search}%`));
if (args.premium !== null && args.premium !== undefined)
conditions.push(eq(videos.premium, args.premium));
@@ -501,7 +530,7 @@ builder.queryField("adminListVideos", (t) =>
.offset(offset),
ctx.db.select({ total: count() }).from(videos).where(where),
]);
const items = await Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
@@ -574,7 +603,7 @@ builder.mutationField("updateVideo", (t) =>
const updated = await ctx.db
.update(videos)
.set(updates as any)
.set(updates as Partial<typeof videos.$inferInsert>)
.where(eq(videos.id, args.id))
.returning();
if (!updated[0]) return null;

View File

@@ -55,6 +55,7 @@ export const UserType = builder.objectRef<User>("User").implement({
is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }),
}),
@@ -75,6 +76,7 @@ export const CurrentUserType = builder.objectRef<User>("CurrentUser").implement(
is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }),
}),
@@ -86,6 +88,7 @@ export const VideoModelType = builder.objectRef<VideoModel>("VideoModel").implem
artist_name: t.exposeString("artist_name", { nullable: true }),
slug: t.exposeString("slug", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
}),
});
@@ -132,6 +135,7 @@ export const ModelType = builder.objectRef<Model>("Model").implement({
description: t.exposeString("description", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
date_created: t.expose("date_created", { type: "DateTime" }),
photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }),
@@ -329,6 +333,74 @@ export const AchievementType = builder.objectRef<Achievement>("Achievement").imp
}),
});
// --- Queue / Job types (admin only, not in shared types package) ---
type JobCounts = {
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
paused: number;
};
type JobData = {
id: string;
name: string;
queue: string;
status: string;
data: unknown;
result: unknown;
failedReason: string | null;
attemptsMade: number;
createdAt: Date;
processedAt: Date | null;
finishedAt: Date | null;
progress: number | null;
};
type QueueInfoData = {
name: string;
counts: JobCounts;
isPaused: boolean;
};
export const JobCountsType = builder.objectRef<JobCounts>("JobCounts").implement({
fields: (t) => ({
waiting: t.exposeInt("waiting"),
active: t.exposeInt("active"),
completed: t.exposeInt("completed"),
failed: t.exposeInt("failed"),
delayed: t.exposeInt("delayed"),
paused: t.exposeInt("paused"),
}),
});
export const JobType = builder.objectRef<JobData>("Job").implement({
fields: (t) => ({
id: t.exposeString("id"),
name: t.exposeString("name"),
queue: t.exposeString("queue"),
status: t.exposeString("status"),
data: t.expose("data", { type: "JSON" }),
result: t.expose("result", { type: "JSON", nullable: true }),
failedReason: t.exposeString("failedReason", { nullable: true }),
attemptsMade: t.exposeInt("attemptsMade"),
createdAt: t.expose("createdAt", { type: "DateTime" }),
processedAt: t.expose("processedAt", { type: "DateTime", nullable: true }),
finishedAt: t.expose("finishedAt", { type: "DateTime", nullable: true }),
progress: t.exposeFloat("progress", { nullable: true }),
}),
});
export const QueueInfoType = builder.objectRef<QueueInfoData>("QueueInfo").implement({
fields: (t) => ({
name: t.exposeString("name"),
counts: t.expose("counts", { type: JobCountsType }),
isPaused: t.exposeBoolean("isPaused"),
}),
});
export const VideoListType = builder
.objectRef<{ items: Video[]; total: number }>("VideoList")
.implement({
@@ -374,6 +446,24 @@ export const AdminArticleListType = builder
}),
});
export const AdminCommentListType = builder
.objectRef<{ items: Comment[]; total: number }>("AdminCommentList")
.implement({
fields: (t) => ({
items: t.expose("items", { type: [CommentType] }),
total: t.exposeInt("total"),
}),
});
export const AdminRecordingListType = builder
.objectRef<{ items: Recording[]; total: number }>("AdminRecordingList")
.implement({
fields: (t) => ({
items: t.expose("items", { type: [RecordingType] }),
total: t.exposeInt("total"),
}),
});
export const AdminUserListType = builder
.objectRef<{ items: User[]; total: number }>("AdminUserList")
.implement({
@@ -397,6 +487,7 @@ export const AdminUserDetailType = builder.objectRef<AdminUserDetail>("AdminUser
is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }),
photos: t.expose("photos", { type: [ModelPhotoType] }),

View File

@@ -7,19 +7,34 @@ import { createYoga } from "graphql-yoga";
import { eq } from "drizzle-orm";
import { files } from "./db/schema/index";
import path from "path";
import { existsSync } from "fs";
import { existsSync, mkdirSync } from "fs";
import { writeFile, rm } from "fs/promises";
import sharp from "sharp";
import { schema } from "./graphql/index";
import { buildContext } from "./graphql/context";
import { db } from "./db/connection";
import { redis } from "./lib/auth";
import { logger } from "./lib/logger";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { startMailWorker } from "./queues/workers/mail";
import { startGamificationWorker } from "./queues/workers/gamification";
const PORT = parseInt(process.env.PORT || "4000");
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:3000";
async function main() {
// Run pending DB migrations before starting the server
const migrationsFolder = path.join(__dirname, "migrations");
logger.info(`Running migrations from ${migrationsFolder}`);
await migrate(db, { migrationsFolder });
logger.info("Migrations complete");
// Start background workers
startMailWorker();
startGamificationWorker();
logger.info("Queue workers started");
const fastify = Fastify({ loggerInstance: logger });
await fastify.register(fastifyCookie, {
@@ -120,6 +135,54 @@ async function main() {
return reply.sendFile(path.join(id, filename));
});
// Upload a file: POST /upload (multipart, requires session)
fastify.post("/upload", async (request, reply) => {
const token = request.cookies["session_token"];
if (!token) return reply.status(401).send({ error: "Unauthorized" });
const sessionData = await redis.get(`session:${token}`);
if (!sessionData) return reply.status(401).send({ error: "Unauthorized" });
const { id: userId } = JSON.parse(sessionData);
const data = await request.file();
if (!data) return reply.status(400).send({ error: "No file provided" });
const id = crypto.randomUUID();
const filename = data.filename;
const mime_type = data.mimetype;
const dir = path.join(UPLOAD_DIR, id);
mkdirSync(dir, { recursive: true });
const buffer = await data.toBuffer();
await writeFile(path.join(dir, filename), buffer);
const [file] = await db
.insert(files)
.values({ id, filename, mime_type, filesize: buffer.byteLength, uploaded_by: userId })
.returning();
return reply.status(201).send(file);
});
// Delete a file: DELETE /assets/:id (requires session)
fastify.delete("/assets/:id", async (request, reply) => {
const token = request.cookies["session_token"];
if (!token) return reply.status(401).send({ error: "Unauthorized" });
const sessionData = await redis.get(`session:${token}`);
if (!sessionData) return reply.status(401).send({ error: "Unauthorized" });
const { id } = request.params as { id: string };
const result = await db.select().from(files).where(eq(files.id, id)).limit(1);
if (!result[0]) return reply.status(404).send({ error: "File not found" });
await db.delete(files).where(eq(files.id, id));
const dir = path.join(UPLOAD_DIR, id);
await rm(dir, { recursive: true, force: true });
return reply.status(200).send({ ok: true });
});
fastify.get("/health", async (_request, reply) => {
return reply.send({ status: "ok", timestamp: new Date().toISOString() });
});

View File

@@ -21,6 +21,8 @@ export async function setSession(token: string, user: SessionUser): Promise<void
export async function getSession(token: string): Promise<SessionUser | null> {
const data = await redis.get(`session:${token}`);
if (!data) return null;
// Slide the expiration window on every access
await redis.expire(`session:${token}`, 86400);
return JSON.parse(data) as SessionUser;
}

View File

@@ -1,4 +1,5 @@
import nodemailer from "nodemailer";
import { mailQueue } from "../queues/index.js";
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || "localhost",
@@ -32,3 +33,13 @@ export async function sendPasswordReset(email: string, token: string): Promise<v
html: `<p>Click <a href="${BASE_URL}/password/reset?token=${token}">here</a> to reset your password.</p>`,
});
}
const jobOpts = { attempts: 3, backoff: { type: "exponential" as const, delay: 5000 } };
export async function enqueueVerification(email: string, token: string): Promise<void> {
await mailQueue.add("sendVerification", { email, token }, jobOpts);
}
export async function enqueuePasswordReset(email: string, token: string): Promise<void> {
await mailQueue.add("sendPasswordReset", { email, token }, jobOpts);
}

View File

@@ -1,4 +1,4 @@
import { eq, sql, and, gt, isNotNull, count, sum } from "drizzle-orm";
import { eq, sql, and, gt, isNull, isNotNull, count, sum } from "drizzle-orm";
import type { DB } from "../db/connection";
import {
user_points,
@@ -28,26 +28,62 @@ export async function awardPoints(
recordingId?: string,
): Promise<void> {
const points = POINT_VALUES[action];
await db.insert(user_points).values({
user_id: userId,
action,
points,
recording_id: recordingId || null,
date_created: new Date(),
});
await db
.insert(user_points)
.values({
user_id: userId,
action,
points,
recording_id: recordingId || null,
date_created: new Date(),
})
.onConflictDoNothing();
await updateUserStats(db, userId);
}
export async function revokePoints(
db: DB,
userId: string,
action: keyof typeof POINT_VALUES,
recordingId?: string,
): Promise<void> {
const recordingCondition = recordingId
? eq(user_points.recording_id, recordingId)
: isNull(user_points.recording_id);
// When no recordingId (e.g. COMMENT_CREATE), delete only one row so each
// revoke undoes exactly one prior award.
if (!recordingId) {
const row = await db
.select({ id: user_points.id })
.from(user_points)
.where(
and(eq(user_points.user_id, userId), eq(user_points.action, action), recordingCondition),
)
.limit(1);
if (row[0]) {
await db.delete(user_points).where(eq(user_points.id, row[0].id));
}
} else {
await db
.delete(user_points)
.where(
and(eq(user_points.user_id, userId), eq(user_points.action, action), recordingCondition),
);
}
await updateUserStats(db, userId);
}
export async function calculateWeightedScore(db: DB, userId: string): Promise<number> {
const now = new Date();
const result = await db.execute(sql`
SELECT SUM(
points * EXP(-${DECAY_LAMBDA} * EXTRACT(EPOCH FROM (${now}::timestamptz - date_created)) / 86400)
points * EXP(${sql.raw(String(-DECAY_LAMBDA))} * EXTRACT(EPOCH FROM (NOW() - date_created)) / 86400)
) as weighted_score
FROM user_points
WHERE user_id = ${userId}
`);
return parseFloat((result.rows[0] as any)?.weighted_score || "0");
return parseFloat((result.rows[0] as { weighted_score?: string })?.weighted_score || "0");
}
export async function updateUserStats(db: DB, userId: string): Promise<void> {
@@ -84,7 +120,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
sql`, `,
)})
`);
playbacksCount = parseInt((playbacksResult.rows[0] as any)?.count || "0");
playbacksCount = parseInt((playbacksResult.rows[0] as { count?: string })?.count || "0");
} else {
const playbacksResult = await db
.select({ count: count() })
@@ -96,7 +132,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
const commentsResult = await db
.select({ count: count() })
.from(comments)
.where(and(eq(comments.user_id, userId), eq(comments.collection, "recordings")));
.where(and(eq(comments.user_id, userId), eq(comments.collection, "videos")));
const commentsCount = commentsResult[0]?.count || 0;
const achievementsResult = await db
@@ -175,7 +211,9 @@ export async function checkAchievements(db: DB, userId: string, category?: strin
.update(user_achievements)
.set({
progress,
date_unlocked: isUnlocked ? existing[0].date_unlocked || new Date() : null,
date_unlocked: isUnlocked
? (existing[0].date_unlocked ?? new Date())
: existing[0].date_unlocked,
})
.where(
and(
@@ -242,7 +280,7 @@ async function getAchievementProgress(
WHERE rp.user_id = ${userId}
AND r.user_id != ${userId}
`);
return parseInt((result.rows[0] as any)?.count || "0");
return parseInt((result.rows[0] as { count?: string })?.count || "0");
}
if (["completionist_10", "completionist_100"].includes(code)) {
@@ -257,7 +295,7 @@ async function getAchievementProgress(
const result = await db
.select({ count: count() })
.from(comments)
.where(and(eq(comments.user_id, userId), eq(comments.collection, "recordings")));
.where(and(eq(comments.user_id, userId), eq(comments.collection, "videos")));
return result[0]?.count || 0;
}
@@ -293,7 +331,7 @@ async function getAchievementProgress(
WHERE rp.user_id = ${userId} AND r.user_id != ${userId}
`);
const rc = recordingsResult[0]?.count || 0;
const pc = parseInt((playsResult.rows[0] as any)?.count || "0");
const pc = parseInt((playsResult.rows[0] as { count?: string })?.count || "0");
return rc >= 50 && pc >= 100 ? 1 : 0;
}

View File

@@ -0,0 +1,8 @@
-- Update any archived recordings to draft before removing the status
UPDATE "recordings" SET "status" = 'draft' WHERE "status" = 'archived';--> statement-breakpoint
-- Recreate enum without 'archived'
ALTER TYPE "public"."recording_status" RENAME TO "recording_status_old";--> statement-breakpoint
CREATE TYPE "public"."recording_status" AS ENUM('draft', 'published');--> statement-breakpoint
ALTER TABLE "recordings" ALTER COLUMN "status" TYPE "public"."recording_status" USING "status"::text::"public"."recording_status";--> statement-breakpoint
DROP TYPE "public"."recording_status_old";

View File

@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "photo" text REFERENCES "files"("id") ON DELETE set null;

View File

@@ -0,0 +1,6 @@
-- Partial unique index: prevents duplicate RECORDING_CREATE / RECORDING_FEATURED points
-- for the same recording. RECORDING_PLAY / RECORDING_COMPLETE are excluded so a user
-- can earn play points across multiple sessions.
CREATE UNIQUE INDEX "user_points_unique_action_recording"
ON "user_points" ("user_id", "action", "recording_id")
WHERE "action" IN ('RECORDING_CREATE', 'RECORDING_FEATURED') AND "recording_id" IS NOT NULL;

View File

@@ -15,6 +15,20 @@
"when": 1772645674514,
"tag": "0001_is_admin",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1741337600000,
"tag": "0002_remove_archived_recording_status",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1741420000000,
"tag": "0003_model_photo",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,16 @@
function parseRedisUrl(url: string): { host: string; port: number; password?: string } {
const parsed = new URL(url);
return {
host: parsed.hostname,
port: parseInt(parsed.port) || 6379,
password: parsed.password || undefined,
};
}
// BullMQ creates its own IORedis connections from these options.
// maxRetriesPerRequest: null is required for workers.
export const redisConnectionOpts = {
...parseRedisUrl(process.env.REDIS_URL || "redis://localhost:6379"),
maxRetriesPerRequest: null as null,
enableReadyCheck: false,
};

View File

@@ -0,0 +1,25 @@
import { Queue } from "bullmq";
import { redisConnectionOpts } from "./connection.js";
import { logger } from "../lib/logger.js";
const log = logger.child({ component: "queues" });
export const mailQueue = new Queue("mail", { connection: redisConnectionOpts });
mailQueue.on("error", (err) => {
log.error({ queue: "mail", err: err.message }, "Queue error");
});
export const gamificationQueue = new Queue("gamification", {
connection: redisConnectionOpts,
defaultJobOptions: { attempts: 3, backoff: { type: "exponential", delay: 2000 } },
});
gamificationQueue.on("error", (err) => {
log.error({ queue: "gamification", err: err.message }, "Queue error");
});
log.info("Queues initialized");
export const queues: Record<string, Queue> = {
mail: mailQueue,
gamification: gamificationQueue,
};

View File

@@ -0,0 +1,52 @@
import { Worker } from "bullmq";
import { redisConnectionOpts } from "../connection.js";
import { awardPoints, revokePoints, checkAchievements } from "../../lib/gamification.js";
import { db } from "../../db/connection.js";
import { logger } from "../../lib/logger.js";
import type { POINT_VALUES } from "../../lib/gamification.js";
const log = logger.child({ component: "gamification-worker" });
export type GamificationJobData =
| { job: "awardPoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId?: string }
| { job: "revokePoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId?: string }
| { job: "checkAchievements"; userId: string; category?: string };
export function startGamificationWorker(): Worker {
const worker = new Worker(
"gamification",
async (bullJob) => {
const data = bullJob.data as GamificationJobData;
log.info(
{ jobId: bullJob.id, job: data.job, userId: data.userId },
"Processing gamification job",
);
switch (data.job) {
case "awardPoints":
await awardPoints(db, data.userId, data.action, data.recordingId);
break;
case "revokePoints":
await revokePoints(db, data.userId, data.action, data.recordingId);
break;
case "checkAchievements":
await checkAchievements(db, data.userId, data.category);
break;
default:
throw new Error(`Unknown gamification job: ${(data as GamificationJobData).job}`);
}
log.info({ jobId: bullJob.id, job: data.job }, "Gamification job completed");
},
{ connection: redisConnectionOpts },
);
worker.on("failed", (bullJob, err) => {
log.error(
{ jobId: bullJob?.id, job: (bullJob?.data as GamificationJobData)?.job, err: err.message },
"Gamification job failed",
);
});
return worker;
}

View File

@@ -0,0 +1,33 @@
import { Worker } from "bullmq";
import { redisConnectionOpts } from "../connection.js";
import { sendVerification, sendPasswordReset } from "../../lib/email.js";
import { logger } from "../../lib/logger.js";
const log = logger.child({ component: "mail-worker" });
export function startMailWorker(): Worker {
const worker = new Worker(
"mail",
async (job) => {
log.info({ jobId: job.id, jobName: job.name }, `Processing mail job`);
switch (job.name) {
case "sendVerification":
await sendVerification(job.data.email as string, job.data.token as string);
break;
case "sendPasswordReset":
await sendPasswordReset(job.data.email as string, job.data.token as string);
break;
default:
throw new Error(`Unknown mail job: ${job.name}`);
}
log.info({ jobId: job.id, jobName: job.name }, `Mail job completed`);
},
{ connection: redisConnectionOpts },
);
worker.on("failed", (job, err) => {
log.error({ jobId: job?.id, jobName: job?.name, err: err.message }, `Mail job failed`);
});
return worker;
}

5
packages/buttplug/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
wasm/
target/
pkg/

View File

@@ -2,6 +2,7 @@
"name": "@sexy.pivoine.art/buttplug",
"version": "1.0.0",
"type": "module",
"private": true,
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -10,7 +11,8 @@
],
"scripts": {
"build": "vite build",
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target bundler --release"
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target web --release",
"serve": "node serve.mjs"
},
"dependencies": {
"eventemitter3": "^5.0.4",

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env node
// Simple static server for local development — serves dist/ and wasm/ on port 8080
import http from "http";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT ?? 8080;
const MIME = {
".js": "application/javascript",
".wasm": "application/wasm",
".ts": "text/plain",
".d.ts": "text/plain",
};
http
.createServer((req, res) => {
const filePath = path.join(__dirname, decodeURIComponent(req.url.split("?")[0]));
const ext = path.extname(filePath);
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end("Not found");
return;
}
res.writeHead(200, {
"Content-Type": MIME[ext] ?? "application/octet-stream",
"Cache-Control": "no-cache",
"Cross-Origin-Resource-Policy": "cross-origin",
});
res.end(data);
});
})
.listen(PORT, () => {
console.log(`[buttplug] serving on http://localhost:${PORT}`);
});

View File

@@ -8,8 +8,8 @@
"use strict";
import { IButtplugClientConnector } from "./IButtplugClientConnector";
import { ButtplugMessage } from "../core/Messages";
import { type IButtplugClientConnector } from "./IButtplugClientConnector";
import { type ButtplugMessage } from "../core/Messages";
import { ButtplugBrowserWebsocketConnector } from "../utils/ButtplugBrowserWebsocketConnector";
export class ButtplugBrowserWebsocketClientConnector

View File

@@ -11,7 +11,7 @@
import { ButtplugLogger } from "../core/Logging";
import { EventEmitter } from "eventemitter3";
import { ButtplugClientDevice } from "./ButtplugClientDevice";
import { IButtplugClientConnector } from "./IButtplugClientConnector";
import { type IButtplugClientConnector } from "./IButtplugClientConnector";
import { ButtplugMessageSorter } from "../utils/ButtplugMessageSorter";
import * as Messages from "../core/Messages";
import { ButtplugError, ButtplugInitError, ButtplugMessageError } from "../core/Exceptions";
@@ -158,7 +158,7 @@ export class ButtplugClient extends EventEmitter {
};
private parseDeviceList = (list: Messages.DeviceList) => {
for (let [_, d] of Object.entries(list.Devices)) {
for (const [_, d] of Object.entries(list.Devices)) {
if (!this._devices.has(d.DeviceIndex)) {
const device = ButtplugClientDevice.fromMsg(d, this.sendMessageClosure);
this._logger.Debug(`ButtplugClient: Adding Device: ${device}`);
@@ -168,8 +168,8 @@ export class ButtplugClient extends EventEmitter {
this._logger.Debug(`ButtplugClient: Device already added: ${d}`);
}
}
for (let [index, device] of this._devices.entries()) {
if (!list.Devices.hasOwnProperty(index.toString())) {
for (const [index, device] of this._devices.entries()) {
if (!Object.prototype.hasOwnProperty.call(list.Devices, index.toString())) {
this._devices.delete(index);
this.emit("deviceremoved", device);
}

View File

@@ -11,7 +11,7 @@ import * as Messages from "../core/Messages";
import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } from "../core/Exceptions";
import { EventEmitter } from "eventemitter3";
import { ButtplugClientDeviceFeature } from "./ButtplugClientDeviceFeature";
import { DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
import { type DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
/**
* Represents an abstract device, capable of taking certain kinds of messages.
@@ -105,14 +105,22 @@ export class ButtplugClientDevice extends EventEmitter {
};
protected isOutputValid(featureIndex: number, type: Messages.OutputType) {
if (!this._deviceInfo.DeviceFeatures.hasOwnProperty(featureIndex.toString())) {
if (
!Object.prototype.hasOwnProperty.call(
this._deviceInfo.DeviceFeatures,
featureIndex.toString(),
)
) {
throw new ButtplugDeviceError(
`Feature index ${featureIndex} does not exist for device ${this.name}`,
);
}
if (
this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs !== undefined &&
!this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs.hasOwnProperty(type)
!Object.prototype.hasOwnProperty.call(
this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs,
type,
)
) {
throw new ButtplugDeviceError(
`Feature index ${featureIndex} does not support type ${type} for device ${this.name}`,
@@ -139,8 +147,8 @@ export class ButtplugClientDevice extends EventEmitter {
}
public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
let p: Promise<void>[] = [];
for (let f of this._features.values()) {
const p: Promise<void>[] = [];
for (const f of this._features.values()) {
if (f.hasOutput(cmd.outputType)) {
p.push(f.runOutput(cmd));
}
@@ -164,11 +172,14 @@ export class ButtplugClientDevice extends EventEmitter {
}
public async battery(): Promise<number> {
let p: Promise<void>[] = [];
for (let f of this._features.values()) {
const _p: Promise<void>[] = [];
for (const f of this._features.values()) {
if (f.hasInput(Messages.InputType.Battery)) {
// Right now, we only have one battery per device, so assume the first one we find is it.
let response = await f.runInput(Messages.InputType.Battery, Messages.InputCommandType.Read);
const response = await f.runInput(
Messages.InputType.Battery,
Messages.InputCommandType.Read,
);
if (response === undefined) {
throw new ButtplugMessageError("Got incorrect message back.");
}

View File

@@ -14,7 +14,7 @@ class PercentOrSteps {
}
public static createSteps(s: number): PercentOrSteps {
let v = new PercentOrSteps();
const v = new PercentOrSteps();
v._steps = s;
return v;
}
@@ -24,7 +24,7 @@ class PercentOrSteps {
throw new ButtplugDeviceError(`Percent value ${p} is not in the range 0.0 <= x <= 1.0`);
}
let v = new PercentOrSteps();
const v = new PercentOrSteps();
v._percent = p;
return v;
}

View File

@@ -1,6 +1,6 @@
import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } from "../core/Exceptions";
import * as Messages from "../core/Messages";
import { DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
import { type DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
export class ButtplugClientDeviceFeature {
constructor(
@@ -26,7 +26,10 @@ export class ButtplugClientDeviceFeature {
};
protected isOutputValid(type: Messages.OutputType) {
if (this._feature.Output !== undefined && !this._feature.Output.hasOwnProperty(type)) {
if (
this._feature.Output !== undefined &&
!Object.prototype.hasOwnProperty.call(this._feature.Output, type)
) {
throw new ButtplugDeviceError(
`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`,
);
@@ -34,7 +37,10 @@ export class ButtplugClientDeviceFeature {
}
protected isInputValid(type: Messages.InputType) {
if (this._feature.Input !== undefined && !this._feature.Input.hasOwnProperty(type)) {
if (
this._feature.Input !== undefined &&
!Object.prototype.hasOwnProperty.call(this._feature.Input, type)
) {
throw new ButtplugDeviceError(
`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`,
);
@@ -48,7 +54,7 @@ export class ButtplugClientDeviceFeature {
throw new ButtplugDeviceError(`${command.outputType} requires value defined`);
}
let type = command.outputType;
const type = command.outputType;
let duration: undefined | number = undefined;
if (type == Messages.OutputType.HwPositionWithDuration) {
if (command.duration === undefined) {
@@ -57,18 +63,18 @@ export class ButtplugClientDeviceFeature {
duration = command.duration;
}
let value: number;
let p = command.value;
const p = command.value;
if (p.percent === undefined) {
// TODO Check step limits here
value = command.value.steps!;
} else {
value = Math.ceil(this._feature.Output[type]!.Value![1] * p.percent);
}
let newCommand: Messages.DeviceFeatureOutput = { Value: value, Duration: duration };
let outCommand = {};
const newCommand: Messages.DeviceFeatureOutput = { Value: value, Duration: duration };
const outCommand = {};
outCommand[type.toString()] = newCommand;
let cmd: Messages.ButtplugMessage = {
const cmd: Messages.ButtplugMessage = {
OutputCmd: {
Id: 1,
DeviceIndex: this._deviceIndex,
@@ -111,14 +117,14 @@ export class ButtplugClientDeviceFeature {
public hasOutput(type: Messages.OutputType): boolean {
if (this._feature.Output !== undefined) {
return this._feature.Output.hasOwnProperty(type.toString());
return Object.prototype.hasOwnProperty.call(this._feature.Output, type.toString());
}
return false;
}
public hasInput(type: Messages.InputType): boolean {
if (this._feature.Input !== undefined) {
return this._feature.Input.hasOwnProperty(type.toString());
return Object.prototype.hasOwnProperty.call(this._feature.Input, type.toString());
}
return false;
}
@@ -126,7 +132,7 @@ export class ButtplugClientDeviceFeature {
public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
if (
this._feature.Output !== undefined &&
this._feature.Output.hasOwnProperty(cmd.outputType.toString())
Object.prototype.hasOwnProperty.call(this._feature.Output, cmd.outputType.toString())
) {
return this.sendOutputCmd(cmd);
}
@@ -139,7 +145,7 @@ export class ButtplugClientDeviceFeature {
): Promise<Messages.InputReading | undefined> {
// Make sure the requested feature is valid
this.isInputValid(inputType);
let inputAttributes = this._feature.Input[inputType];
const inputAttributes = this._feature.Input[inputType];
console.log(this._feature.Input);
if (
inputCommand === Messages.InputCommandType.Unsubscribe &&
@@ -149,7 +155,7 @@ export class ButtplugClientDeviceFeature {
throw new ButtplugDeviceError(`${inputType} does not support command ${inputCommand}`);
}
let cmd: Messages.ButtplugMessage = {
const cmd: Messages.ButtplugMessage = {
InputCmd: {
Id: 1,
DeviceIndex: this._deviceIndex,

View File

@@ -6,8 +6,8 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
import { ButtplugMessage } from "../core/Messages";
import { EventEmitter } from "eventemitter3";
import { type ButtplugMessage } from "../core/Messages";
import { type EventEmitter } from "eventemitter3";
export interface IButtplugClientConnector extends EventEmitter {
connect: () => Promise<void>;

View File

@@ -7,7 +7,7 @@
*/
import * as Messages from "./Messages";
import { ButtplugLogger } from "./Logging";
import { type ButtplugLogger } from "./Logging";
export class ButtplugError extends Error {
public get ErrorClass(): Messages.ErrorClass {

View File

@@ -36,7 +36,7 @@ export interface ButtplugMessage {
}
export function msgId(msg: ButtplugMessage): number {
for (let [_, entry] of Object.entries(msg)) {
for (const [_, entry] of Object.entries(msg)) {
if (entry != undefined) {
return entry.Id;
}
@@ -45,7 +45,7 @@ export function msgId(msg: ButtplugMessage): number {
}
export function setMsgId(msg: ButtplugMessage, id: number) {
for (let [_, entry] of Object.entries(msg)) {
for (const [_, entry] of Object.entries(msg)) {
if (entry != undefined) {
entry.Id = id;
return;

View File

@@ -6,8 +6,8 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
import { ButtplugMessage } from "./core/Messages";
import { IButtplugClientConnector } from "./client/IButtplugClientConnector";
import { type ButtplugMessage } from "./core/Messages";
import { type IButtplugClientConnector } from "./client/IButtplugClientConnector";
import { EventEmitter } from "eventemitter3";
export * from "./client/ButtplugClient";
@@ -40,7 +40,9 @@ export class ButtplugWasmClientConnector extends EventEmitter implements IButtpl
private static maybeLoadWasm = async () => {
if (ButtplugWasmClientConnector.wasmInstance == undefined) {
ButtplugWasmClientConnector.wasmInstance = await import("../wasm/index.js");
const wasmModule = await import("../wasm/index.js");
await wasmModule.default(); // --target web requires calling init() before using exports
ButtplugWasmClientConnector.wasmInstance = wasmModule;
}
};

View File

@@ -23,6 +23,7 @@ type FFICallback = js_sys::Function;
type FFICallbackContext = u32;
#[derive(Clone, Copy)]
#[allow(dead_code)]
pub struct FFICallbackContextWrapper(FFICallbackContext);
unsafe impl Send for FFICallbackContextWrapper {
@@ -50,7 +51,7 @@ pub fn send_server_message(
let buf = json.as_bytes();
let this = JsValue::null();
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
callback.call1(&this, &JsValue::from(uint8buf));
let _ = callback.call1(&this, &JsValue::from(uint8buf));
}
}
@@ -119,7 +120,7 @@ pub fn buttplug_client_send_json_message(
let buf = json.as_bytes();
let this = JsValue::null();
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
callback.call1(&this, &JsValue::from(uint8buf));
let _ = callback.call1(&this, &JsValue::from(uint8buf));
}
});
}

View File

@@ -9,7 +9,7 @@
"use strict";
import { EventEmitter } from "eventemitter3";
import { ButtplugMessage } from "../core/Messages";
import { type ButtplugMessage } from "../core/Messages";
export class ButtplugBrowserWebsocketConnector extends EventEmitter {
protected _ws: WebSocket | undefined;

View File

@@ -40,7 +40,7 @@ export class ButtplugMessageSorter {
public ParseIncomingMessages(msgs: Messages.ButtplugMessage[]): Messages.ButtplugMessage[] {
const noMatch: Messages.ButtplugMessage[] = [];
for (const x of msgs) {
let id = Messages.msgId(x);
const id = Messages.msgId(x);
if (id !== Messages.SYSTEM_MESSAGE_ID && this._waitingMsgs.has(id)) {
const [res, rej] = this._waitingMsgs.get(id)!;
this._waitingMsgs.delete(id);

View File

@@ -184,6 +184,7 @@ impl HardwareSpecializer for WebBluetoothHardwareSpecializer {
pub enum WebBluetoothEvent {
// This is the only way we have to get our endpoints back to device creation
// right now. My god this is a mess.
#[allow(dead_code)]
Connected(Vec<Endpoint>),
Disconnected,
}
@@ -201,6 +202,7 @@ pub enum WebBluetoothDeviceCommand {
HardwareSubscribeCmd,
oneshot::Sender<Result<(), ButtplugDeviceError>>,
),
#[allow(dead_code)]
Unsubscribe(
HardwareUnsubscribeCmd,
oneshot::Sender<Result<(), ButtplugDeviceError>>,
@@ -271,7 +273,7 @@ async fn run_webbluetooth_loop(
//let web_btle_device = WebBluetoothDeviceImpl::new(device, char_map);
info!("device created!");
let endpoints = char_map.keys().into_iter().cloned().collect();
device_local_event_sender
let _ = device_local_event_sender
.send(WebBluetoothEvent::Connected(endpoints))
.await;
while let Some(msg) = device_command_receiver.recv().await {
@@ -337,6 +339,7 @@ async fn run_webbluetooth_loop(
#[derive(Debug)]
pub struct WebBluetoothHardware {
device_command_sender: mpsc::Sender<WebBluetoothDeviceCommand>,
#[allow(dead_code)]
device_event_receiver: mpsc::Receiver<WebBluetoothEvent>,
event_sender: broadcast::Sender<HardwareEvent>,
}

View File

@@ -1,7 +1,6 @@
{
"name": "@sexy.pivoine.art/frontend",
"version": "1.0.0",
"author": "valknarogg",
"type": "module",
"private": true,
"scripts": {
@@ -12,9 +11,10 @@
"check": "svelte-check --tsconfig ./tsconfig.json --threshold warning"
},
"devDependencies": {
"@sexy.pivoine.art/buttplug": "workspace:*",
"@iconify-json/ri": "^1.2.10",
"@iconify/tailwind4": "^1.2.1",
"@internationalized/date": "^3.11.0",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/adapter-static": "^3.0.10",
@@ -29,7 +29,6 @@
"glob": "^13.0.6",
"mode-watcher": "^1.1.0",
"prettier-plugin-svelte": "^3.5.1",
"super-sitemap": "^1.0.7",
"svelte": "^5.53.7",
"svelte-check": "^4.4.4",
"svelte-sonner": "^1.0.8",
@@ -38,11 +37,9 @@
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-wasm": "3.5.0"
"vite": "^7.3.1"
},
"dependencies": {
"@sexy.pivoine.art/buttplug": "workspace:*",
"@sexy.pivoine.art/types": "workspace:*",
"graphql": "^16.11.0",
"graphql-request": "^7.1.2",

View File

@@ -3,6 +3,13 @@
@plugin "@iconify/tailwind4";
@utility scrollbar-none {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant hover (&:hover);
@@ -194,7 +201,7 @@
--card-foreground: oklch(0.95 0.01 280);
--border: oklch(0.2 0.05 280);
--input: oklch(1 0 0 / 0.15);
--primary: oklch(0.65 0.25 320);
--primary: oklch(65.054% 0.25033 319.934);
--primary-foreground: oklch(0.98 0.01 320);
--secondary: oklch(0.15 0.05 260);
--secondary-foreground: oklch(0.9 0.02 260);

View File

@@ -9,7 +9,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400..700&family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap"
href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet"
/>

View File

@@ -10,23 +10,23 @@
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
>
<div
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? "translate-x-10" : ""}`}
class={`bg-foreground h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? "translate-x-10" : ""}`}
></div>
<div
class={`bg-white h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
class={`bg-foreground h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
></div>
<div
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
class={`bg-foreground h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
></div>
<div
class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? "translate-x-0 w-12" : ""}`}
>
<div
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? "rotate-45" : ""}`}
class={`absolute bg-foreground h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? "rotate-45" : ""}`}
></div>
<div
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? "-rotate-45" : ""}`}
class={`absolute bg-foreground h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? "-rotate-45" : ""}`}
></div>
</div>
</div>

View File

@@ -6,7 +6,8 @@
import { logout } from "$lib/services";
import { goto } from "$app/navigation";
import { getAssetUrl } from "$lib/api";
import LogoutButton from "../logout-button/logout-button.svelte";
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import Separator from "../ui/separator/separator.svelte";
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
import Logo from "../logo/logo.svelte";
@@ -37,7 +38,7 @@
isMobileMenuOpen = false;
}
function isActiveLink(link: any) {
function isActiveLink(link: { name?: string; href: string }) {
return (
(page.url.pathname === "/" && link === navLinks[0]) ||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
@@ -46,7 +47,7 @@
</script>
<header
class="sticky top-0 z-50 w-full bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
class="sticky top-0 z-50 w-full backdrop-blur-xl shadow-[0_4px_24px_-8px_color-mix(in_oklab,var(--color-primary)_12%,transparent)] bg-card/50"
>
<div class="container mx-auto px-4">
<div class="flex items-center justify-evenly h-16">
@@ -55,7 +56,7 @@
href="/"
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
>
<Logo hideName={true} />
<Logo />
</a>
<!-- Desktop Navigation -->
@@ -75,28 +76,14 @@
{/each}
</nav>
<!-- Desktop Auth Actions -->
<!-- Auth Actions -->
{#if authStatus.authenticated}
<div class="w-full hidden lg:flex items-center justify-end">
<div class="w-full flex items-center justify-end">
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
<Button
variant="link"
size="icon"
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "text-foreground" : "hover:text-foreground"}`}
href="/me"
title={$_("header.dashboard")}
>
<span class="icon-[ri--dashboard-2-line] h-4 w-4"></span>
<span
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/me" }) ? "w-full" : "group-hover:w-full"}`}
></span>
<span class="sr-only">{$_("header.dashboard")}</span>
</Button>
<Button
variant="link"
size="icon"
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
class={`flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
href="/play"
title={$_("header.play")}
>
@@ -111,7 +98,7 @@
<Button
variant="link"
size="icon"
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/admin" }) ? "text-foreground" : "hover:text-foreground"}`}
class={`flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/admin" }) ? "text-foreground" : "hover:text-foreground"}`}
href="/admin/users"
title="Admin"
>
@@ -123,44 +110,70 @@
</Button>
{/if}
<Separator orientation="vertical" class="mx-1 h-6 bg-border/50" />
<Separator orientation="vertical" class="hidden lg:block mx-1 h-6 bg-border/50" />
<LogoutButton
user={{
name:
authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
email: authStatus.user!.email,
}}
onLogout={handleLogout}
<a href="/me" class="flex items-center gap-2 px-1 hover:opacity-80 transition-opacity">
<Avatar class="h-7 w-7 ring-2 ring-primary/20">
<AvatarImage
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
alt={authStatus.user!.artist_name || authStatus.user!.email}
/>
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold"
>
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
</AvatarFallback>
</Avatar>
<span
class="hidden lg:inline text-sm font-medium text-foreground/90 max-w-24 truncate"
>
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
</span>
</a>
<Button
variant="link"
size="icon"
class="hidden lg:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group hover:text-destructive"
onclick={handleLogout}
title={$_("header.logout")}
>
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
</Button>
</div>
<div class="lg:hidden ml-2">
<BurgerMenuButton
label={$_("header.navigation")}
bind:isMobileMenuOpen
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
/>
</div>
</div>
{:else}
<div class="hidden lg:flex w-full items-center justify-end gap-4">
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button>
<Button
href="/signup"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
>{$_("header.signup")}</Button
>
<div class="w-full flex items-center justify-end gap-2">
<div class="flex gap-4">
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button
>
<Button
href="/signup"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
>{$_("header.signup")}</Button
>
</div>
<div class="lg:hidden ml-2">
<BurgerMenuButton
label={$_("header.navigation")}
bind:isMobileMenuOpen
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
/>
</div>
</div>
{/if}
<!-- Burger button — mobile/tablet only -->
<div class="lg:hidden ml-auto">
<BurgerMenuButton
label={$_("header.navigation")}
bind:isMobileMenuOpen
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
/>
</div>
</div>
</div>
</header>
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
role="presentation"
class={`fixed inset-0 z-40 bg-black/60 backdrop-blur-sm transition-opacity duration-300 lg:hidden ${isMobileMenuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
@@ -174,21 +187,40 @@
>
<!-- Panel header -->
<div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
<Logo hideName={true} />
<Logo />
</div>
<div class="flex-1 py-6 px-5 space-y-6">
<!-- User logout slider -->
<!-- User card -->
{#if authStatus.authenticated}
<LogoutButton
user={{
name: authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
email: authStatus.user!.email,
}}
onLogout={handleLogout}
class="w-full"
/>
<div class="flex items-center gap-3 rounded-xl border border-border/40 bg-card/50 px-4 py-3">
<Avatar class="h-10 w-10 ring-2 ring-primary/20 shrink-0">
<AvatarImage
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
alt={authStatus.user!.artist_name || authStatus.user!.email}
/>
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-sm font-semibold"
>
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
</AvatarFallback>
</Avatar>
<div class="flex flex-col min-w-0 flex-1">
<span class="text-sm font-semibold text-foreground truncate">
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
</span>
<span class="text-xs text-muted-foreground truncate">{authStatus.user!.email}</span>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 shrink-0"
onclick={handleLogout}
title={$_("header.logout")}
>
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
</Button>
</div>
{/if}
<!-- Navigation -->

File diff suppressed because one or more lines are too long

View File

@@ -145,7 +145,12 @@
{#if isViewerOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/95 backdrop-blur-xl" onclick={closeViewer}></div>
<button
type="button"
class="absolute inset-0 bg-black/95 backdrop-blur-xl cursor-default"
onclick={closeViewer}
aria-label="Close viewer"
></button>
<!-- Viewer Content -->
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">

View File

@@ -1,21 +1,5 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import SexyIcon from "../icon/icon.svelte";
const { hideName = false } = $props();
</script>
<div class="relative">
<SexyIcon class="w-8 h-8 text-primary" />
</div>
<span
class={`logo text-3xl text-foreground opacity-90 tracking-wide font-extrabold drop-shadow-x ${hideName ? "hidden sm:inline-block" : ""}`}
>
{$_("brand.name")}
</span>
<style>
.logo {
font-family: "Dancing Script", cursive;
}
</style>
<SexyIcon class="w-12 h-12" />

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
title: string;
description?: string;
children?: Snippet;
}
let { title, description, children }: Props = $props();
</script>
<section class="relative py-12 md:py-20 overflow-hidden">
<div class="relative container mx-auto px-4 text-center">
<div class="max-w-5xl mx-auto">
<h1
class="text-5xl md:text-7xl font-bold mb-6 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
>
{title}
</h1>
{#if description}
<p
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
>
{description}
</p>
{/if}
{#if children}
{@render children()}
{/if}
</div>
</div>
</section>

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { _ } from "svelte-i18n";
interface Props {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
let { currentPage, totalPages, onPageChange }: Props = $props();
const pageNumbers = $derived(() => {
const pages: (number | -1)[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (currentPage > 3) pages.push(-1);
for (
let i = Math.max(2, currentPage - 1);
i <= Math.min(totalPages - 1, currentPage + 1);
i++
)
pages.push(i);
if (currentPage < totalPages - 2) pages.push(-1);
pages.push(totalPages);
}
return pages;
});
</script>
{#if totalPages > 1}
<div class="flex items-center gap-1">
<Button
variant="outline"
disabled={currentPage <= 1}
onclick={() => onPageChange(currentPage - 1)}
>
{$_("common.previous")}
</Button>
{#each pageNumbers() as p, i (i)}
{#if p === -1}
<span class="px-2 text-muted-foreground select-none"></span>
{:else}
<Button
variant={p === currentPage ? "default" : "outline"}
class="min-w-9"
onclick={() => onPageChange(p)}
>
{p}
</Button>
{/if}
{/each}
<Button
variant="outline"
disabled={currentPage >= totalPages}
onclick={() => onPageChange(currentPage + 1)}
>
{$_("common.next")}
</Button>
</div>
{/if}

View File

@@ -2,16 +2,18 @@
import { _ } from "svelte-i18n";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import type { Recording, DeviceInfo } from "$lib/types";
import { cn } from "$lib/utils";
interface Props {
recording: Recording;
onPlay?: (id: string) => void;
onPublish?: (id: string) => void;
onUnpublish?: (id: string) => void;
onDelete?: (id: string) => void;
}
let { recording, onPlay, onDelete }: Props = $props();
let { recording, onPlay, onPublish, onUnpublish, onDelete }: Props = $props();
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
@@ -19,19 +21,6 @@
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
function getStatusColor(status: string): string {
switch (status) {
case "published":
return "text-green-400 bg-green-400/20";
case "draft":
return "text-yellow-400 bg-yellow-400/20";
case "archived":
return "text-red-400 bg-red-400/20";
default:
return "text-gray-400 bg-gray-400/20";
}
}
</script>
<Card
@@ -44,9 +33,14 @@
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
{recording.title}
</h3>
<span class={cn("text-xs px-2 py-0.5 rounded-full", getStatusColor(recording.status))}>
<Badge
variant="outline"
class={recording.status === "published"
? "text-green-600 border-green-500/40 bg-green-500/10"
: "text-yellow-600 border-yellow-500/40 bg-yellow-500/10"}
>
{$_(`recording_card.status_${recording.status}`)}
</span>
</Badge>
</div>
{#if recording.description}
<p class="text-sm text-muted-foreground line-clamp-2">
@@ -151,12 +145,35 @@
{$_("recording_card.play")}
</Button>
{/if}
{#if onPublish && recording.status === "draft"}
<Button
size="sm"
variant="outline"
onclick={() => onPublish?.(recording.id)}
class="cursor-pointer border-primary/20 hover:bg-primary/10 hover:text-primary"
title={$_("recording_card.publish")}
>
<span class="icon-[ri--send-plane-line] w-4 h-4"></span>
</Button>
{/if}
{#if onUnpublish && recording.status === "published"}
<Button
size="sm"
variant="outline"
onclick={() => onUnpublish?.(recording.id)}
class="cursor-pointer border-muted-foreground/20 hover:bg-muted/50 hover:text-muted-foreground"
title={$_("recording_card.unpublish")}
>
<span class="icon-[ri--arrow-go-back-line] w-4 h-4"></span>
</Button>
{/if}
{#if onDelete}
<Button
size="sm"
variant="outline"
onclick={() => onDelete?.(recording.id)}
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
title={$_("common.delete")}
>
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
</Button>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-content"
class={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className,
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-description"
class={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-header"
class={cn("flex max-w-sm flex-col items-center gap-2 text-center", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,41 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const emptyMediaVariants = tv({
base: "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
});
export type EmptyMediaVariant = VariantProps<typeof emptyMediaVariants>["variant"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
variant = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: EmptyMediaVariant } = $props();
</script>
<div
bind:this={ref}
data-slot="empty-icon"
data-variant={variant}
class={cn(emptyMediaVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-title"
class={cn("text-lg font-medium tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty"
class={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className,
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,22 @@
import Root from "./empty.svelte";
import Header from "./empty-header.svelte";
import Media from "./empty-media.svelte";
import Title from "./empty-title.svelte";
import Description from "./empty-description.svelte";
import Content from "./empty-content.svelte";
export {
Root,
Header,
Media,
Title,
Description,
Content,
//
Root as Empty,
Header as EmptyHeader,
Media as EmptyMedia,
Title as EmptyTitle,
Description as EmptyDescription,
Content as EmptyContent,
};

View File

@@ -23,11 +23,13 @@
...rest
}: FileDropZoneProps = $props();
if (maxFiles !== undefined && fileCount === undefined) {
console.warn(
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
);
}
$effect(() => {
if (maxFiles !== undefined && fileCount === undefined) {
console.warn(
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
);
}
});
let uploading = $state(false);

View File

@@ -50,7 +50,7 @@ export default {
account: "Account",
},
brand: {
name: "SexyArt",
name: "Sexy",
tagline: "Where Love Meets Artistry",
description:
"The premier destination for artistic adult content, intimate storytelling, and creative expression through video and magazine content.",
@@ -91,6 +91,23 @@ export default {
me: {
title: "Dashboard",
welcome: "Welcome back, {name}",
nav: {
profile: "Profile",
security: "Security",
recordings: "Recordings",
analytics: "Analytics",
back_to_site: "Back to site",
back_mobile: "Back",
},
analytics: {
title: "Analytics",
description: "Track your content performance and audience engagement",
total_videos: "Total Videos",
total_likes: "Total Likes",
total_plays: "Total Plays",
video_performance: "Video Performance",
video_performance_description: "Detailed metrics for each video",
},
view_profile: "View Public Profile",
settings: {
title: "Settings",
@@ -134,6 +151,10 @@ export default {
delete_confirm: "Are you sure you want to delete this recording?",
delete_success: "Recording deleted successfully",
delete_error: "Failed to delete recording",
publish_success: "Recording published successfully",
publish_error: "Failed to publish recording",
unpublish_success: "Recording unpublished",
unpublish_error: "Failed to unpublish recording",
},
},
recording_card: {
@@ -143,8 +164,9 @@ export default {
created: "Created",
status_draft: "Draft",
status_published: "Published",
status_archived: "Archived",
play: "Play",
publish: "Publish",
unpublish: "Unpublish",
edit: "Edit",
delete: "Delete",
public: "Public",
@@ -303,13 +325,15 @@ export default {
show: "Show",
add_comment_placeholder: "Add a comment...",
toast_comment: "Your comment has been sent",
comment_deleted: "Comment deleted",
comment_delete_error: "Failed to delete comment",
comment: "Comment",
commenting: "Commenting...",
error: "Heads Up!",
back: "Back to Videos",
},
magazine: {
title: "SexyArt Magazine",
title: "Sexy Magazine",
description:
"Insights, stories, and inspiration from the world of love, art, and intimate expression",
search_placeholder: "Search articles...",
@@ -386,7 +410,7 @@ export default {
},
},
about: {
title: "About SexyArt",
title: "About Sexy",
subtitle:
"Where passion meets artistry, and intimate storytelling becomes a celebration of human connection.",
join_community: "Join Our Community",
@@ -402,11 +426,11 @@ export default {
subtitle:
"Born from a vision to transform how intimate content is created, shared, and appreciated",
description_part1:
"SexyArt was founded in 2019 with a simple yet powerful mission: to create a platform where intimate content could be appreciated as an art form, where creators could express their authentic selves, and where viewers could connect with content that celebrates love, passion, and human connection.",
"Sexy was founded in 2019 with a simple yet powerful mission: to create a platform where intimate content could be appreciated as an art form, where creators could express their authentic selves, and where viewers could connect with content that celebrates love, passion, and human connection.",
description_part2:
"We recognized that the adult content industry needed a platform that prioritized artistic expression, creator empowerment, and community building. Our founders, coming from backgrounds in photography, digital media, and community management, set out to build something different.",
description_part3:
"Today, SexyArt is home to hundreds of talented creators and thousands of passionate community members who share our vision of elevating intimate content to new artistic heights.",
"Today, Sexy is home to hundreds of talented creators and thousands of passionate community members who share our vision of elevating intimate content to new artistic heights.",
},
values: {
title: "Our Values",
@@ -446,7 +470,7 @@ export default {
image: "/img/valknar.gif",
bio: "DJ and visual storyteller specializing in diffusion AI art.",
},
subtitle: "The passionate individuals behind SexyArt's success",
subtitle: "The passionate individuals behind Sexy's success",
},
mission: {
title: "Our Mission",
@@ -473,7 +497,7 @@ export default {
},
faq: {
title: "Frequently Asked Questions",
description: "Find answers to common questions about SexyArt, our platform, and services",
description: "Find answers to common questions about Sexy, our platform, and services",
search_placeholder: "Search frequently asked questions...",
search_results: "Search Results ({count})",
no_results: "No questions found matching your search.",
@@ -482,24 +506,24 @@ export default {
title: "Getting Started",
questions: [
{
question: "How do I create an account on SexyArt?",
question: "How do I create an account on Sexy?",
answer:
"Creating an account is simple! Click the 'Join Now' button in the top navigation, fill out the registration form with your email and basic information, verify you're 18+, and agree to our terms. You'll receive a confirmation email to activate your account.",
},
{
question: "What types of content can I find on SexyArt?",
question: "What types of content can I find on Sexy?",
answer:
"SexyArt features high-quality artistic adult content including intimate photography, romantic videos, artistic nude content, and creative adult entertainment. All content is created by verified models and creators who focus on artistic expression and storytelling.",
"Sexy features high-quality artistic adult content including intimate photography, romantic videos, artistic nude content, and creative adult entertainment. All content is created by verified models and creators who focus on artistic expression and storytelling.",
},
{
question: "Is SexyArt safe and secure?",
question: "Is Sexy safe and secure?",
answer:
"Yes! We use industry-standard encryption, secure payment processing, and strict privacy measures. All creators are verified, and we have comprehensive content moderation. Your personal information and viewing habits are kept completely private.",
},
{
question: "Can I access SexyArt on mobile devices?",
question: "Can I access Sexy on mobile devices?",
answer:
"Absolutely! SexyArt is fully responsive and works perfectly on smartphones, tablets, and desktop computers. You can enjoy the same high-quality experience across all your devices.",
"Absolutely! Sexy is fully responsive and works perfectly on smartphones, tablets, and desktop computers. You can enjoy the same high-quality experience across all your devices.",
},
],
},
@@ -507,7 +531,7 @@ export default {
title: "For Creators & Models",
questions: [
{
question: "How do I become a creator on SexyArt?",
question: "How do I become a creator on Sexy?",
answer:
"To become a creator, sign up for a Creator account during registration or upgrade your existing account. You'll need to verify your identity, provide tax information, and agree to our creator terms. Once approved, you can start uploading content and building your audience.",
},
@@ -596,7 +620,7 @@ export default {
company_information: "Company Information",
company_name: {
title: "Company Name",
value: "SexyArt",
value: "Sexy",
},
legal_form: {
title: "Legal Form",
@@ -613,7 +637,7 @@ export default {
contact_information: "Contact Information",
registered_address: "Registered Address",
address: {
company: "SexyArt",
company: "Sexy",
name: "Sebastian Krüger",
street: "Berlingerstraße 48",
city: "78333 Stockach",
@@ -687,7 +711,7 @@ export default {
acceptance: {
title: "1. Acceptance of Terms",
text: [
"By accessing and using SexyArt, you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to abide by the above, please do not use this service.",
"By accessing and using Sexy, you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to abide by the above, please do not use this service.",
],
},
age: {
@@ -731,7 +755,7 @@ export default {
values: {
title: "Our Community Values",
text: [
"SexyArt is built on respect, consent, and artistic expression. We believe in creating a space where creators and viewers can connect through shared appreciation for intimate art and storytelling.",
"Sexy is built on respect, consent, and artistic expression. We believe in creating a space where creators and viewers can connect through shared appreciation for intimate art and storytelling.",
],
},
respect: {
@@ -798,11 +822,19 @@ export default {
questions_email: "support@pivoine.art",
},
play: {
title: "SexyPlay",
description: "Bring your toys.",
title: "Play",
description: "Connect and control your Bluetooth toys.",
scan: "Start Scan",
scanning: "Scanning...",
no_results: "No Devices founds",
no_results: "No devices found",
no_results_description: "Start a scan to discover nearby Bluetooth devices",
nav: {
play: "Play",
recordings: "Recordings",
leaderboard: "Leaderboard",
back_to_site: "Back to site",
back_mobile: "Site",
},
},
error: {
not_found: "Oops! Page Not Found",
@@ -900,16 +932,19 @@ export default {
},
},
head: {
title: "SexyArt | {title}",
title: "Sexy | {title}",
},
admin: {
nav: {
back_to_site: "Back to site",
back_mobile: "Back",
back_to_site: "Back to site",
back_mobile: "Back",
title: "Admin",
users: "Users",
videos: "Videos",
articles: "Articles",
comments: "Comments",
recordings: "Recordings",
queues: "Queues",
},
common: {
save_changes: "Save changes",
@@ -924,8 +959,8 @@ export default {
cover_image: "Cover image",
tags: "Tags",
publish_date: "Publish date",
title_field: "Title *",
slug_field: "Slug *",
title_field: "Title",
slug_field: "Slug",
title_slug_required: "Title and slug are required",
image_uploaded: "Image uploaded",
image_upload_failed: "Image upload failed",
@@ -959,6 +994,11 @@ export default {
artist_name: "Artist name",
avatar: "Avatar",
banner: "Banner",
model_photo: "Model photo",
model_photo_hint:
"Used in model cards and on the model profile page. Avatar is used for comments and article authors.",
model_photo_uploaded: "Model photo uploaded",
model_photo_failed: "Model photo upload failed",
is_admin: "Administrator",
is_admin_hint: "Grants full admin access to the dashboard",
photos: "Photo gallery",
@@ -1022,6 +1062,64 @@ export default {
delete_success: "Article deleted",
delete_error: "Failed to delete article",
},
comments: {
title: "Comments",
search_placeholder: "Search comments…",
col_user: "User",
col_comment: "Comment",
col_on: "On",
col_date: "Date",
no_results: "No comments found",
delete_title: "Delete comment",
delete_success: "Comment deleted",
delete_error: "Failed to delete comment",
},
recordings: {
title: "Recordings",
search_placeholder: "Search recordings…",
col_title: "Title",
col_status: "Status",
col_duration: "Duration",
col_date: "Date",
no_results: "No recordings found",
published: "Published",
draft: "Draft",
public: "Public",
delete_title: "Delete recording",
delete_description: 'Permanently delete "{title}"? This cannot be undone.',
delete_success: "Recording deleted",
delete_error: "Failed to delete recording",
},
queues: {
title: "Job Queues",
pause: "Pause",
resume: "Resume",
paused_badge: "Paused",
retry: "Retry",
remove: "Remove",
retry_success: "Job retried",
retry_error: "Failed to retry job",
remove_success: "Job removed",
remove_error: "Failed to remove job",
pause_success: "Queue paused",
pause_error: "Failed to pause queue",
resume_success: "Queue resumed",
resume_error: "Failed to resume queue",
col_id: "ID",
col_name: "Name",
col_status: "Status",
col_attempts: "Attempts",
col_created: "Created",
col_actions: "Actions",
no_jobs: "No jobs found",
status_all: "All",
status_waiting: "Waiting",
status_active: "Active",
status_completed: "Completed",
status_failed: "Failed",
status_delayed: "Delayed",
failed_reason: "Reason: {reason}",
},
article_form: {
new_title: "New article",
edit_title: "Edit article",

View File

@@ -3,6 +3,7 @@ import { apiUrl, getGraphQLClient } from "$lib/api";
import type {
Analytics,
Article,
Comment,
CurrentUser,
Model,
Recording,
@@ -295,6 +296,7 @@ const ARTICLE_BY_SLUG_QUERY = gql`
artist_name
slug
avatar
description
}
}
}
@@ -489,6 +491,7 @@ const MODELS_QUERY = gql`
description
avatar
banner
photo
tags
date_created
photos {
@@ -538,6 +541,7 @@ const MODEL_BY_SLUG_QUERY = gql`
description
avatar
banner
photo
tags
date_created
photos {
@@ -572,6 +576,7 @@ const UPDATE_PROFILE_MUTATION = gql`
$artistName: String
$description: String
$tags: [String!]
$avatar: String
) {
updateProfile(
firstName: $firstName
@@ -579,6 +584,7 @@ const UPDATE_PROFILE_MUTATION = gql`
artistName: $artistName
description: $description
tags: $tags
avatar: $avatar
) {
id
email
@@ -608,6 +614,7 @@ export async function updateProfile(user: Partial<User> & { password?: string })
artistName: user.artist_name,
description: user.description,
tags: user.tags,
avatar: user.avatar,
},
);
return data.updateProfile;
@@ -651,7 +658,8 @@ export async function removeFile(id: string) {
method: "DELETE",
credentials: "include",
});
if (!response.ok) throw new Error(`Failed to delete file: ${response.statusText}`);
if (!response.ok && response.status !== 404)
throw new Error(`Failed to delete file: ${response.statusText}`);
},
{ fileId: id },
);
@@ -894,6 +902,26 @@ export async function createRecording(
);
}
const UPDATE_RECORDING_MUTATION = gql`
mutation UpdateRecording($id: String!, $status: String, $public: Boolean) {
updateRecording(id: $id, status: $status, public: $public) {
id
status
public
}
}
`;
export async function updateRecording(id: string, fields: { status?: string; public?: boolean }) {
return loggedApiCall("updateRecording", async () => {
const data = await getGraphQLClient().request<{ updateRecording: Recording }>(
UPDATE_RECORDING_MUTATION,
{ id, ...fields },
);
return data.updateRecording;
});
}
const DELETE_RECORDING_MUTATION = gql`
mutation DeleteRecording($id: String!) {
deleteRecording(id: $id)
@@ -1145,6 +1173,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
$artistName: String
$avatarId: String
$bannerId: String
$photoId: String
) {
adminUpdateUser(
userId: $userId
@@ -1155,6 +1184,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
artistName: $artistName
avatarId: $avatarId
bannerId: $bannerId
photoId: $photoId
) {
id
email
@@ -1165,6 +1195,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
is_admin
avatar
banner
photo
date_created
}
}
@@ -1179,6 +1210,7 @@ export async function adminUpdateUser(input: {
artistName?: string;
avatarId?: string;
bannerId?: string;
photoId?: string;
}) {
return loggedApiCall(
"adminUpdateUser",
@@ -1222,6 +1254,7 @@ const ADMIN_GET_USER_QUERY = gql`
is_admin
avatar
banner
photo
description
tags
email_verified
@@ -1239,7 +1272,9 @@ export async function adminGetUser(userId: string, token?: string) {
"adminGetUser",
async () => {
const client = token ? getAuthClient(token) : getGraphQLClient();
const data = await client.request<{ adminGetUser: any }>(ADMIN_GET_USER_QUERY, { userId });
const data = await client.request<{
adminGetUser: User & { photos: Array<{ id: string; filename: string }> };
}>(ADMIN_GET_USER_QUERY, { userId });
return data.adminGetUser;
},
{ userId },
@@ -1339,6 +1374,51 @@ export async function adminListVideos(
});
}
const ADMIN_GET_VIDEO_QUERY = gql`
query AdminGetVideo($id: String!) {
adminGetVideo(id: $id) {
id
slug
title
description
image
movie
tags
upload_date
premium
featured
likes_count
plays_count
models {
id
artist_name
slug
avatar
}
movie_file {
id
filename
mime_type
duration
}
}
}
`;
export async function adminGetVideo(
id: string,
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<Video | null> {
return loggedApiCall("adminGetVideo", async () => {
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminGetVideo: Video | null }>(ADMIN_GET_VIDEO_QUERY, {
id,
});
return data.adminGetVideo;
});
}
const CREATE_VIDEO_MUTATION = gql`
mutation CreateVideo(
$title: String!
@@ -1543,6 +1623,44 @@ export async function adminListArticles(
});
}
const ADMIN_GET_ARTICLE_QUERY = gql`
query AdminGetArticle($id: String!) {
adminGetArticle(id: $id) {
id
slug
title
excerpt
content
image
tags
publish_date
category
featured
author {
id
artist_name
slug
avatar
}
}
}
`;
export async function adminGetArticle(
id: string,
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<Article | null> {
return loggedApiCall("adminGetArticle", async () => {
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminGetArticle: Article | null }>(
ADMIN_GET_ARTICLE_QUERY,
{ id },
);
return data.adminGetArticle;
});
}
const CREATE_ARTICLE_MUTATION = gql`
mutation CreateArticle(
$title: String!
@@ -1693,3 +1811,235 @@ export async function getAnalytics(fetchFn?: typeof globalThis.fetch) {
{},
);
}
// ─── Admin: Comments ──────────────────────────────────────────────────────────
const ADMIN_LIST_COMMENTS_QUERY = gql`
query AdminListComments($search: String, $limit: Int, $offset: Int) {
adminListComments(search: $search, limit: $limit, offset: $offset) {
items {
id
collection
item_id
comment
user_id
date_created
user {
id
artist_name
avatar
}
}
total
}
}
`;
export async function adminListComments(
opts: { search?: string; limit?: number; offset?: number } = {},
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<{ items: Comment[]; total: number }> {
return loggedApiCall("adminListComments", async () => {
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminListComments: { items: Comment[]; total: number } }>(
ADMIN_LIST_COMMENTS_QUERY,
opts,
);
return data.adminListComments;
});
}
// ─── Admin: Recordings ────────────────────────────────────────────────────────
const ADMIN_LIST_RECORDINGS_QUERY = gql`
query AdminListRecordings($search: String, $status: String, $limit: Int, $offset: Int) {
adminListRecordings(search: $search, status: $status, limit: $limit, offset: $offset) {
items {
id
title
slug
status
duration
public
featured
user_id
date_created
}
total
}
}
`;
export async function adminListRecordings(
opts: { search?: string; status?: string; limit?: number; offset?: number } = {},
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<{ items: Recording[]; total: number }> {
return loggedApiCall("adminListRecordings", async () => {
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
const data = await client.request<{
adminListRecordings: { items: Recording[]; total: number };
}>(ADMIN_LIST_RECORDINGS_QUERY, opts);
return data.adminListRecordings;
});
}
const ADMIN_DELETE_RECORDING_MUTATION = gql`
mutation AdminDeleteRecording($id: String!) {
adminDeleteRecording(id: $id)
}
`;
export async function adminDeleteRecording(id: string): Promise<void> {
return loggedApiCall("adminDeleteRecording", async () => {
await getGraphQLClient().request(ADMIN_DELETE_RECORDING_MUTATION, { id });
});
}
// --- Queues ---
export type JobCounts = {
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
paused: number;
};
export type QueueInfo = {
name: string;
counts: JobCounts;
isPaused: boolean;
};
export type Job = {
id: string;
name: string;
queue: string;
status: string;
data: Record<string, unknown>;
result: unknown;
failedReason: string | null;
attemptsMade: number;
createdAt: string;
processedAt: string | null;
finishedAt: string | null;
progress: number | null;
};
const ADMIN_QUEUES_QUERY = gql`
query AdminQueues {
adminQueues {
name
isPaused
counts {
waiting
active
completed
failed
delayed
paused
}
}
}
`;
export async function getAdminQueues(
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<QueueInfo[]> {
return loggedApiCall("getAdminQueues", async () => {
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminQueues: QueueInfo[] }>(ADMIN_QUEUES_QUERY);
return data.adminQueues;
});
}
const ADMIN_QUEUE_JOBS_QUERY = gql`
query AdminQueueJobs($queue: String!, $status: String, $limit: Int, $offset: Int) {
adminQueueJobs(queue: $queue, status: $status, limit: $limit, offset: $offset) {
id
name
queue
status
data
result
failedReason
attemptsMade
createdAt
processedAt
finishedAt
progress
}
}
`;
export async function getAdminQueueJobs(
queue: string,
status?: string,
limit?: number,
offset?: number,
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<Job[]> {
return loggedApiCall("getAdminQueueJobs", async () => {
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminQueueJobs: Job[] }>(ADMIN_QUEUE_JOBS_QUERY, {
queue,
status,
limit,
offset,
});
return data.adminQueueJobs;
});
}
const ADMIN_RETRY_JOB_MUTATION = gql`
mutation AdminRetryJob($queue: String!, $jobId: String!) {
adminRetryJob(queue: $queue, jobId: $jobId)
}
`;
export async function adminRetryJob(queue: string, jobId: string): Promise<void> {
return loggedApiCall("adminRetryJob", async () => {
await getGraphQLClient().request(ADMIN_RETRY_JOB_MUTATION, { queue, jobId });
});
}
const ADMIN_REMOVE_JOB_MUTATION = gql`
mutation AdminRemoveJob($queue: String!, $jobId: String!) {
adminRemoveJob(queue: $queue, jobId: $jobId)
}
`;
export async function adminRemoveJob(queue: string, jobId: string): Promise<void> {
return loggedApiCall("adminRemoveJob", async () => {
await getGraphQLClient().request(ADMIN_REMOVE_JOB_MUTATION, { queue, jobId });
});
}
const ADMIN_PAUSE_QUEUE_MUTATION = gql`
mutation AdminPauseQueue($queue: String!) {
adminPauseQueue(queue: $queue)
}
`;
const ADMIN_RESUME_QUEUE_MUTATION = gql`
mutation AdminResumeQueue($queue: String!) {
adminResumeQueue(queue: $queue)
}
`;
export async function adminPauseQueue(queue: string): Promise<void> {
return loggedApiCall("adminPauseQueue", async () => {
await getGraphQLClient().request(ADMIN_PAUSE_QUEUE_MUTATION, { queue });
});
}
export async function adminResumeQueue(queue: string): Promise<void> {
return loggedApiCall("adminResumeQueue", async () => {
await getGraphQLClient().request(ADMIN_RESUME_QUEUE_MUTATION, { queue });
});
}

View File

@@ -28,7 +28,9 @@
<div class="bg-background text-foreground min-h-screen">
<!-- Advanced Global Plasma Background -->
<div class="fixed inset-0 pointer-events-none overflow-hidden">
<div
class="fixed inset-0 pointer-events-none overflow-hidden bg-gradient-to-b from-primary/12 via-accent/6 to-transparent"
>
<!-- Large primary blobs -->
<div
class="absolute -top-40 -left-40 w-80 h-80 bg-gradient-to-r from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-ultra-slow"

View File

@@ -5,6 +5,7 @@
import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte";
import { formatVideoDuration } from "$lib/utils.js";
import SexyBackground from "$lib/components/background/background.svelte";
const { data } = $props();
</script>
@@ -13,10 +14,9 @@
<!-- Hero Section -->
<section class="relative min-h-screen flex items-center justify-center overflow-hidden">
<!-- Background Gradient -->
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 via-accent/10 to-background"></div>
<SexyBackground />
<!-- Content -->
<div class="relative z-10 container mx-auto px-4 text-center">
<div class="max-w-5xl mx-auto space-y-12">
<h1
@@ -47,14 +47,6 @@
</div>
</div>
</div>
<!-- Floating Elements -->
<div
class="absolute top-20 left-10 w-20 h-20 bg-primary/20 rounded-full blur-xl animate-pulse"
></div>
<div
class="absolute bottom-20 right-10 w-32 h-32 bg-accent/20 rounded-full blur-xl animate-pulse delay-1000"
></div>
</section>
<!-- Featured Models -->
@@ -71,40 +63,24 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-3xl mx-auto">
{#each data.models as model (model.slug)}
<Card
class="p-0 group hover:shadow-2xl hover:shadow-primary/20 transition-all duration-300 hover:-translate-y-2 bg-gradient-to-br from-card to-card/50 border-primary/20"
>
<CardContent class="p-6 text-center">
<div class="relative mb-4">
<img
src={getAssetUrl(model.avatar, "mini")}
alt={model.artist_name}
class="w-24 h-24 rounded-full mx-auto object-cover ring-4 ring-primary/20 group-hover:ring-primary/40 transition-all"
/>
<!-- <div
class="absolute -bottom-2 -right-2 bg-primary text-primary-foreground rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold"
>
<HeartIcon class="w-4 h-4 fill-current" />
</div> -->
</div>
<h3 class="font-semibold text-lg mb-2">{model.artist_name}</h3>
<!-- <div
class="flex items-center justify-center gap-4 text-sm text-muted-foreground"
>
<div class="flex items-center gap-1">
<StarIcon class="w-4 h-4 text-yellow-500 fill-current" />
{model.rating}
<a href="/models/{model.slug}" class="block group">
<Card
class="p-0 h-full hover:shadow-2xl hover:shadow-primary/20 transition-all duration-300 hover:-translate-y-2 bg-gradient-to-br from-card to-card/50 border-primary/20"
>
<CardContent class="p-6 text-center">
<div class="relative mb-4">
<img
src={getAssetUrl(model.avatar, "thumbnail")}
alt={model.artist_name}
class="w-24 h-24 rounded-full mx-auto object-cover ring-4 ring-primary/20 group-hover:ring-primary/40 transition-all bg-muted"
/>
</div>
<div>{model.videos} {$_("home.featured_models.videos")}</div>
</div> -->
<Button
variant="ghost"
size="sm"
class="mt-4 w-full group-hover:bg-primary/10"
href="/models/{model.slug}">{$_("home.featured_models.view_profile")}</Button
>
</CardContent>
</Card>
<h3 class="font-semibold text-lg group-hover:text-primary transition-colors">
{model.artist_name}
</h3>
</CardContent>
</Card>
</a>
{/each}
</div>
</div>
@@ -122,50 +98,44 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-6xl mx-auto">
{#each data.videos as video (video.slug)}
<Card
class="p-0 group hover:shadow-2xl hover:shadow-accent/20 transition-all duration-300 hover:-translate-y-2 bg-gradient-to-br from-card to-card/50 border-accent/20 overflow-hidden"
>
<div class="relative">
<img
src={getAssetUrl(video.image, "preview")}
alt={video.title}
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent group-hover:scale-105 transition-transform duration-300"
></div>
<div class="absolute bottom-2 left-2 text-white text-sm font-medium">
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
</div>
<!-- <div
class="absolute top-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded-full"
>
{video.views}
{$_("home.trending.views")}
</div> -->
<div
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<a
class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center"
href="/videos/{video.slug}"
aria-label={video.title}
<a href="/videos/{video.slug}" class="block group">
<Card
class="p-0 h-full hover:shadow-2xl hover:shadow-accent/20 transition-all duration-300 hover:-translate-y-2 bg-gradient-to-br from-card to-card/50 border-accent/20 overflow-hidden"
>
<div class="relative">
<img
src={getAssetUrl(video.image, "preview")}
alt={video.title}
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent group-hover:scale-105 transition-transform duration-300"
></div>
<div class="absolute bottom-2 left-2 text-white text-sm font-medium">
{#if video.movie_file?.duration}{formatVideoDuration(
video.movie_file.duration,
)}{/if}
</div>
<div
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-hidden="true"
>
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
</a>
<div class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center">
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
</div>
</div>
</div>
</div>
<CardContent class="px-4 pb-4 pt-0">
<h3 class="font-semibold mb-2 group-hover:text-primary transition-colors">
{video.title}
</h3>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span class="icon-[ri--fire-line] w-4 h-4"></span>
{$_("home.trending.trending")}
</div>
</CardContent>
</Card>
<CardContent class="px-4 pb-4 pt-0">
<h3 class="font-semibold mb-2 group-hover:text-primary transition-colors">
{video.title}
</h3>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span class="icon-[ri--fire-line] w-4 h-4"></span>
{$_("home.trending.trending")}
</div>
</CardContent>
</Card>
</a>
{/each}
</div>
</div>

View File

@@ -7,7 +7,7 @@
const { data } = $props();
const stats = [
const stats = $derived([
{
icon: "icon-[ri--user-heart-line]",
value: data.stats.viewers_count,
@@ -28,7 +28,7 @@
value: $_("about.stats.yearsFormatted", { values: { years: 5 } }),
label: $_("about.stats.experience"),
},
];
]);
const team = [
{

View File

@@ -1,13 +1,33 @@
<script lang="ts">
import { page } from "$app/state";
import { _ } from "svelte-i18n";
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import { getAssetUrl } from "$lib/api";
const { children } = $props();
const { children, data } = $props();
const user = $derived(data.authStatus.user!);
const avatarUrl = $derived(
user.avatar ? (getAssetUrl(user.avatar, "thumbnail") ?? undefined) : undefined,
);
const displayName = $derived(user.artist_name ?? user.email);
const navLinks = $derived([
{ name: $_("admin.nav.users"), href: "/admin/users", icon: "icon-[ri--team-line]" },
{ name: $_("admin.nav.videos"), href: "/admin/videos", icon: "icon-[ri--film-line]" },
{ name: $_("admin.nav.articles"), href: "/admin/articles", icon: "icon-[ri--article-line]" },
{ name: $_("admin.nav.comments"), href: "/admin/comments", icon: "icon-[ri--message-line]" },
{
name: $_("admin.nav.recordings"),
href: "/admin/recordings",
icon: "icon-[ri--record-circle-line]",
},
{
name: $_("admin.nav.queues"),
href: "/admin/queues",
icon: "icon-[ri--stack-line]",
},
]);
function isActive(href: string) {
@@ -15,29 +35,32 @@
}
</script>
<div class="min-h-screen bg-background">
<div class="min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
<div class="container mx-auto px-4">
<!-- Mobile top nav -->
<div class="lg:hidden flex items-center gap-2 py-3 border-b border-border/40">
<a
href="/"
class="text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0 mr-2"
>
{$_("admin.nav.back_mobile")}
</a>
{#each navLinks as link (link.href)}
<div class="lg:hidden border-b border-border/40">
<div class="flex items-center gap-1 overflow-x-auto py-2 scrollbar-none">
<a
href={link.href}
class={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
isActive(link.href)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
href="/"
class="shrink-0 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
>
<span class={`${link.icon} h-4 w-4`}></span>
{link.name}
<span class="icon-[ri--arrow-left-line] h-4 w-4"></span>
<span class="hidden sm:inline">{$_("admin.nav.back_mobile")}</span>
</a>
{/each}
{#each navLinks as link (link.href)}
<a
href={link.href}
class={`shrink-0 flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
isActive(link.href)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span class={`${link.icon} h-4 w-4 shrink-0`}></span>
<span class="hidden sm:inline">{link.name}</span>
</a>
{/each}
</div>
</div>
<!-- Desktop layout -->
@@ -45,10 +68,33 @@
<!-- Sidebar (desktop only) -->
<aside class="hidden lg:flex w-56 shrink-0 flex-col border-r border-border/40">
<div class="px-4 py-5 border-b border-border/40">
<a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors">
<a
href="/"
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span class="icon-[ri--arrow-left-line] h-3.5 w-3.5"></span>
{$_("admin.nav.back_to_site")}
</a>
<h1 class="mt-2 text-base font-bold text-foreground">{$_("admin.nav.title")}</h1>
<div class="mt-3 flex items-center gap-3">
<div class="relative shrink-0">
<Avatar class="h-9 w-9">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback class="text-xs">
{getUserInitials(displayName)}
</AvatarFallback>
</Avatar>
<span
class="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary ring-2 ring-background"
>
<span class="icon-[ri--shield-keyhole-fill] h-2.5 w-2.5 text-primary-foreground"
></span>
</span>
</div>
<div class="min-w-0">
<p class="text-sm font-semibold text-foreground truncate">{displayName}</p>
<p class="text-xs text-primary font-medium">{$_("admin.nav.title")}</p>
</div>
</div>
</div>
<nav class="flex-1 p-3 space-y-1">

View File

@@ -12,6 +12,8 @@
import * as Dialog from "$lib/components/ui/dialog";
import type { Article } from "$lib/types";
import TimeAgo from "javascript-time-ago";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props();
@@ -20,7 +22,7 @@
let deleteTarget: Article | null = $state(null);
let deleteOpen = $state(false);
let deleting = $state(false);
let searchValue = $state(data.search ?? "");
let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) {
@@ -64,8 +66,10 @@
}
</script>
<div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<Meta title={$_("admin.articles.title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.articles.title")}</h1>
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground"
@@ -81,7 +85,7 @@
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
<div class="flex flex-wrap items-center gap-3 mb-4">
<Input
placeholder={$_("admin.articles.search_placeholder")}
class="max-w-xs"
@@ -110,7 +114,6 @@
</SelectContent>
</Select>
<Button
size="sm"
variant={data.featured === true ? "default" : "outline"}
onclick={() => setFilter("featured", data.featured === true ? null : "true")}
>
@@ -202,7 +205,7 @@
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
@@ -212,32 +215,15 @@
},
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}
>
{$_("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}
>
{$_("common.next")}
</Button>
</div>
<Pagination
currentPage={Math.floor(data.offset / data.limit) + 1}
totalPages={Math.ceil(data.total / data.limit)}
onPageChange={(p) => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String((p - 1) * data.limit));
goto(`?${params.toString()}`);
}}
/>
</div>
{/if}
</div>

View File

@@ -1,16 +1,17 @@
import { adminListArticles, adminListUsers } from "$lib/services";
import { adminGetArticle, adminListUsers } from "$lib/services";
import { error } from "@sveltejs/kit";
export async function load({ params, fetch, cookies }) {
const token = cookies.get("session_token") || "";
const [articles, modelsResult] = await Promise.all([
adminListArticles(fetch, token).catch(() => []),
const [article, modelsResult] = await Promise.all([
adminGetArticle(params.id, fetch, token).catch(() => null),
adminListUsers({ role: "model", limit: 200 }, fetch, token).catch(() => ({
items: [],
total: 0,
})),
]);
const article = articles.find((a) => a.id === params.id);
if (!article) throw error(404, "Article not found");
return { article, authors: modelsResult.items };
}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from "svelte";
import { goto } from "$app/navigation";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
@@ -10,24 +11,44 @@
import { Textarea } from "$lib/components/ui/textarea";
import { TagsInput } from "$lib/components/ui/tags-input";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { Card, CardContent } from "$lib/components/ui/card";
import { getAssetUrl } from "$lib/api";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import { DatePicker } from "$lib/components/ui/date-picker";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
let title = $state(data.article.title);
let slug = $state(data.article.slug);
let excerpt = $state(data.article.excerpt ?? "");
let content = $state(data.article.content ?? "");
let category = $state(data.article.category ?? "");
let tags = $state<string[]>(data.article.tags ?? []);
let featured = $state(data.article.featured ?? false);
let title = $state(untrack(() => data.article.title));
let slug = $state(untrack(() => data.article.slug));
let excerpt = $state(untrack(() => data.article.excerpt ?? ""));
let content = $state(untrack(() => data.article.content ?? ""));
let category = $state(untrack(() => data.article.category ?? ""));
let tags = $state<string[]>(untrack(() => data.article.tags ?? []));
let featured = $state(untrack(() => data.article.featured ?? false));
let publishDate = $state(
data.article.publish_date ? new Date(data.article.publish_date).toISOString().slice(0, 16) : "",
untrack(() =>
data.article.publish_date
? new Date(data.article.publish_date).toISOString().slice(0, 16)
: "",
),
);
let imageId = $state<string | null>(data.article.image ?? null);
let authorId = $state(data.article.author?.id ?? "");
let imageId = $state<string | null>(untrack(() => data.article.image ?? null));
let authorId = $state(untrack(() => data.article.author?.id ?? ""));
$effect(() => {
title = data.article.title;
slug = data.article.slug;
excerpt = data.article.excerpt ?? "";
content = data.article.content ?? "";
category = data.article.category ?? "";
tags = data.article.tags ?? [];
featured = data.article.featured ?? false;
publishDate = data.article.publish_date
? new Date(data.article.publish_date).toISOString().slice(0, 16)
: "";
imageId = data.article.image ?? null;
authorId = data.article.author?.id ?? "";
});
let selectedAuthor = $derived(data.authors.find((a) => a.id === authorId) ?? null);
let saving = $state(false);
let editorTab = $state<"write" | "preview">("write");
@@ -66,153 +87,178 @@
});
toast.success($_("admin.article_form.update_success"));
goto("/admin/articles");
} catch (e: any) {
toast.error(e?.message ?? $_("admin.article_form.update_error"));
} catch (e) {
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.article_form.update_error"));
} finally {
saving = false;
}
}
</script>
<div class="p-3 sm:p-6">
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/articles" size="sm">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
</Button>
<h1 class="text-2xl font-bold">{$_("admin.article_form.edit_title")}</h1>
<Meta title={$_("admin.article_form.edit_title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{data.article.title}</h1>
<p class="text-xs text-muted-foreground mt-0.5">
{data.article.slug}{data.article.category ? " · " + data.article.category : ""}{data.article
.author
? " · " + data.article.author.artist_name
: ""}
</p>
</div>
<div class="space-y-5 max-w-4xl">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input id="title" bind:value={title} />
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input id="slug" bind:value={slug} />
</div>
</div>
<div class="space-y-1.5">
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
<Textarea id="excerpt" bind:value={excerpt} rows={2} />
</div>
<!-- Markdown editor with live preview -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label>{$_("admin.article_form.content")}</Label>
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
<button
type="button"
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</button
>
<button
type="button"
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</button
>
<Card class="bg-card/50 border-primary/20 max-w-4xl">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input
id="slug"
bind:value={slug}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
<div class="space-y-1.5">
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
<Textarea
bind:value={content}
class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
id="excerpt"
bind:value={excerpt}
rows={2}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
<div
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
>
{#if preview}
{@html preview}
{:else}
<p class="text-muted-foreground italic text-sm">
{$_("admin.article_form.preview_placeholder")}
</p>
{/if}
</div>
<!-- Markdown editor with live preview -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label>{$_("admin.article_form.content")}</Label>
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
<Button
variant="ghost"
size="sm"
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</Button
>
<Button
variant="ghost"
size="sm"
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</Button
>
</div>
</div>
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
<Textarea
bind:value={content}
class={`h-full min-h-96 font-mono text-sm resize-none bg-background/50 border-primary/20 focus:border-primary ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
/>
<div
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
>
{#if preview}
{@html preview}
{:else}
<p class="text-muted-foreground italic text-sm">
{$_("admin.article_form.preview_placeholder")}
</p>
{/if}
</div>
</div>
</div>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.cover_image")}</Label>
{#if imageId}
<img
src={getAssetUrl(imageId, "thumbnail")}
alt=""
class="h-24 rounded object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.cover_image")}</Label>
{#if imageId}
<img
src={getAssetUrl(imageId, "thumbnail")}
alt=""
class="h-24 rounded object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
</div>
<!-- Author -->
<div class="space-y-1.5">
<Label>{$_("admin.article_form.author")}</Label>
<Select type="single" bind:value={authorId}>
<SelectTrigger class="w-full">
{#if selectedAuthor}
{#if selectedAuthor.avatar}
<img
src={getAssetUrl(selectedAuthor.avatar, "mini")}
alt=""
class="h-5 w-5 rounded-full object-cover shrink-0"
/>
{/if}
{selectedAuthor.artist_name}
{:else}
<span class="text-muted-foreground">{$_("admin.article_form.no_author")}</span>
{/if}
</SelectTrigger>
<SelectContent>
<SelectItem value="">{$_("admin.article_form.no_author")}</SelectItem>
{#each data.authors as author (author.id)}
<SelectItem value={author.id}>
{#if author.avatar}
<div class="space-y-1.5">
<Label>{$_("admin.article_form.author")}</Label>
<Select type="single" bind:value={authorId}>
<SelectTrigger class="w-full bg-background/50 border-primary/20">
{#if selectedAuthor}
{#if selectedAuthor.avatar}
<img
src={getAssetUrl(author.avatar, "mini")}
src={getAssetUrl(selectedAuthor.avatar, "mini")}
alt=""
class="h-5 w-5 rounded-full object-cover shrink-0"
/>
{/if}
{author.artist_name}
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="category">{$_("admin.article_form.category")}</Label>
<Input id="category" bind:value={category} />
{selectedAuthor.artist_name}
{:else}
<span class="text-muted-foreground">{$_("admin.article_form.no_author")}</span>
{/if}
</SelectTrigger>
<SelectContent>
<SelectItem value="">{$_("admin.article_form.no_author")}</SelectItem>
{#each data.authors as author (author.id)}
<SelectItem value={author.id}>
{#if author.avatar}
<img
src={getAssetUrl(author.avatar, "mini")}
alt=""
class="h-5 w-5 rounded-full object-cover shrink-0"
/>
{/if}
{author.artist_name}
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="category">{$_("admin.article_form.category")}</Label>
<Input
id="category"
bind:value={category}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
</div>
</div>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput
bind:value={tags}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
<div class="flex gap-3 pt-2">
<Button
onclick={handleSubmit}
disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button>
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -11,6 +11,8 @@
import { TagsInput } from "$lib/components/ui/tags-input";
import { DatePicker } from "$lib/components/ui/date-picker";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { Card, CardContent } from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
let title = $state("");
let slug = $state("");
@@ -67,135 +69,142 @@
});
toast.success($_("admin.article_form.create_success"));
goto("/admin/articles");
} catch (e: any) {
toast.error(e?.message ?? $_("admin.article_form.create_error"));
} catch (e) {
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.article_form.create_error"));
} finally {
saving = false;
}
}
</script>
<div class="p-3 sm:p-6">
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/articles" size="sm">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
</Button>
<Meta title={$_("admin.article_form.new_title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("admin.article_form.new_title")}</h1>
</div>
<div class="space-y-5 max-w-4xl">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
oninput={() => {
if (!slug) slug = generateSlug(title);
}}
placeholder={$_("admin.article_form.title_placeholder")}
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input
id="slug"
bind:value={slug}
placeholder={$_("admin.article_form.slug_placeholder")}
/>
</div>
</div>
<div class="space-y-1.5">
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
<Textarea
id="excerpt"
bind:value={excerpt}
placeholder={$_("admin.article_form.excerpt_placeholder")}
rows={2}
/>
</div>
<!-- Markdown editor with live preview -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label>{$_("admin.article_form.content")}</Label>
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
<button
type="button"
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</button
>
<button
type="button"
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</button
>
<Card class="bg-card/50 border-primary/20 max-w-4xl">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
oninput={() => {
if (!slug) slug = generateSlug(title);
}}
placeholder={$_("admin.article_form.title_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input
id="slug"
bind:value={slug}
placeholder={$_("admin.article_form.slug_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
<!-- Mobile: single pane toggled; Desktop: side by side -->
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
<div class="space-y-1.5">
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
<Textarea
bind:value={content}
placeholder={$_("admin.article_form.content_placeholder")}
class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
id="excerpt"
bind:value={excerpt}
placeholder={$_("admin.article_form.excerpt_placeholder")}
rows={2}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
<div
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
>
{#if preview}
{@html preview}
{:else}
<p class="text-muted-foreground italic text-sm">
{$_("admin.article_form.preview_placeholder")}
</p>
{/if}
</div>
<!-- Markdown editor with live preview -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label>{$_("admin.article_form.content")}</Label>
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
<Button
variant="ghost"
size="sm"
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</Button
>
<Button
variant="ghost"
size="sm"
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</Button
>
</div>
</div>
<!-- Mobile: single pane toggled; Desktop: side by side -->
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
<Textarea
bind:value={content}
placeholder={$_("admin.article_form.content_placeholder")}
class={`h-full min-h-96 font-mono text-sm resize-none bg-background/50 border-primary/20 focus:border-primary ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
/>
<div
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
>
{#if preview}
{@html preview}
{:else}
<p class="text-muted-foreground italic text-sm">
{$_("admin.article_form.preview_placeholder")}
</p>
{/if}
</div>
</div>
</div>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.cover_image")}</Label>
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
{#if imageId}<p class="text-xs text-green-600 mt-1">
{$_("admin.common.image_uploaded")}
</p>{/if}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="category">{$_("admin.article_form.category")}</Label>
<Input
id="category"
bind:value={category}
placeholder={$_("admin.article_form.category_placeholder")}
<Label>{$_("admin.common.cover_image")}</Label>
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
{#if imageId}
<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")}</p>
{/if}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="category">{$_("admin.article_form.category")}</Label>
<Input
id="category"
bind:value={category}
placeholder={$_("admin.article_form.category_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
</div>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput
bind:value={tags}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
</div>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} />
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
<div class="flex gap-3 pt-2">
<Button
onclick={handleSubmit}
disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{saving ? $_("admin.common.creating") : $_("admin.article_form.create")}
</Button>
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,15 @@
import { adminListComments } from "$lib/services";
export async function load({ fetch, url, cookies }) {
const token = cookies.get("session_token") || "";
const search = url.searchParams.get("search") || undefined;
const offset = parseInt(url.searchParams.get("offset") || "0", 10);
const limit = 50;
const result = await adminListComments({ search, limit, offset }, fetch, token).catch(() => ({
items: [],
total: 0,
}));
return { ...result, search, offset, limit };
}

View File

@@ -0,0 +1,195 @@
<script lang="ts">
import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { deleteComment } from "$lib/services";
import { getAssetUrl } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog";
import TimeAgo from "javascript-time-ago";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props();
const timeAgo = new TimeAgo("en");
let deleteTarget: { id: number; comment: string } | null = $state(null);
let deleteOpen = $state(false);
let deleting = $state(false);
let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value) params.set("search", value);
else params.delete("search");
params.delete("offset");
goto(`?${params.toString()}`, { keepFocus: true });
}, 300);
}
function confirmDelete(id: number, comment: string) {
deleteTarget = { id, comment };
deleteOpen = true;
}
async function handleDelete() {
if (!deleteTarget) return;
deleting = true;
try {
await deleteComment(deleteTarget.id);
toast.success($_("admin.comments.delete_success"));
deleteOpen = false;
deleteTarget = null;
await invalidateAll();
} catch {
toast.error($_("admin.comments.delete_error"));
} finally {
deleting = false;
}
}
</script>
<Meta title={$_("admin.comments.title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.comments.title")}</h1>
<span class="text-sm text-muted-foreground"
>{$_("admin.users.total", { values: { total: data.total } })}</span
>
</div>
<div class="flex flex-wrap gap-3 mb-4">
<Input
placeholder={$_("admin.comments.search_placeholder")}
class="max-w-xs"
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
/>
</div>
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/30">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
>{$_("admin.comments.col_user")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
>{$_("admin.comments.col_comment")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell"
>{$_("admin.comments.col_on")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell"
>{$_("admin.comments.col_date")}</th
>
<th class="px-4 py-3 text-right font-medium text-muted-foreground"
>{$_("admin.users.col_actions")}</th
>
</tr>
</thead>
<tbody class="divide-y divide-border/30">
{#each data.items as comment (comment.id)}
<tr class="hover:bg-muted/10 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
{#if comment.user?.avatar}
<img
src={getAssetUrl(comment.user.avatar, "mini")}
alt=""
class="h-7 w-7 rounded-full object-cover"
/>
{:else}
<div
class="h-7 w-7 rounded-full bg-muted/50 flex items-center justify-center text-muted-foreground"
>
<span class="icon-[ri--user-line] h-4 w-4"></span>
</div>
{/if}
<span class="font-medium text-sm">{comment.user?.artist_name ?? "—"}</span>
</div>
</td>
<td class="px-4 py-3 max-w-sm">
<p class="truncate text-sm">{comment.comment}</p>
</td>
<td class="px-4 py-3 text-muted-foreground hidden sm:table-cell capitalize text-sm">
{comment.collection} /
<span class="font-mono text-xs">{comment.item_id.slice(0, 8)}</span>
</td>
<td class="px-4 py-3 text-muted-foreground hidden sm:table-cell text-sm">
{timeAgo.format(new Date(comment.date_created))}
</td>
<td class="px-4 py-3 text-right">
<Button
size="sm"
variant="ghost"
class="text-destructive hover:text-destructive hover:bg-destructive/10"
onclick={() => confirmDelete(comment.id, comment.comment)}
>
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
</Button>
</td>
</tr>
{/each}
{#if data.items.length === 0}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">
{$_("admin.comments.no_results")}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
start: data.offset + 1,
end: Math.min(data.offset + data.limit, data.total),
total: data.total,
},
})}
</span>
<Pagination
currentPage={Math.floor(data.offset / data.limit) + 1}
totalPages={Math.ceil(data.total / data.limit)}
onPageChange={(p) => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String((p - 1) * data.limit));
goto(`?${params.toString()}`);
}}
/>
</div>
{/if}
</div>
<Dialog.Root bind:open={deleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$_("admin.comments.delete_title")}</Dialog.Title>
<Dialog.Description>
"{deleteTarget?.comment.slice(0, 80)}{(deleteTarget?.comment.length ?? 0) > 80 ? "…" : ""}"
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (deleteOpen = false)}>{$_("common.cancel")}</Button>
<Button variant="destructive" disabled={deleting} onclick={handleDelete}>
{deleting ? $_("admin.common.deleting") : $_("common.delete")}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,35 @@
import { getAdminQueues, getAdminQueueJobs } from "$lib/services";
const LIMIT = 25;
export async function load({ fetch, cookies, url }) {
const token = cookies.get("session_token") || "";
const queues = await getAdminQueues(fetch, token).catch(() => []);
const queueParam = url.searchParams.get("queue") ?? queues[0]?.name ?? null;
const status = url.searchParams.get("status") ?? null;
const offset = parseInt(url.searchParams.get("offset") ?? "0") || 0;
let jobs: Awaited<ReturnType<typeof getAdminQueueJobs>> = [];
let total = 0;
if (queueParam) {
jobs = await getAdminQueueJobs(
queueParam,
status ?? undefined,
LIMIT,
offset,
fetch,
token,
).catch(() => []);
const queueInfo = queues.find((q) => q.name === queueParam);
if (queueInfo) {
const { waiting, active, completed, failed, delayed } = queueInfo.counts;
const counts: Record<string, number> = { waiting, active, completed, failed, delayed };
total = status ? (counts[status] ?? 0) : Object.values(counts).reduce((a, b) => a + b, 0);
}
}
return { queues, queue: queueParam, status, jobs, total, offset, limit: LIMIT };
}

View File

@@ -0,0 +1,293 @@
<script lang="ts">
import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { adminRetryJob, adminRemoveJob, adminPauseQueue, adminResumeQueue } from "$lib/services";
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import type { Job } from "$lib/services";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props();
let togglingQueue = $state<string | null>(null);
const STATUS_FILTERS = [
{ value: null, label: $_("admin.queues.status_all") },
{ value: "waiting", label: $_("admin.queues.status_waiting") },
{ value: "active", label: $_("admin.queues.status_active") },
{ value: "completed", label: $_("admin.queues.status_completed") },
{ value: "failed", label: $_("admin.queues.status_failed") },
{ value: "delayed", label: $_("admin.queues.status_delayed") },
];
function navigate(overrides: Record<string, string | null>) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
for (const [k, v] of Object.entries(overrides)) {
if (v === null) params.delete(k);
else params.set(k, v);
}
goto(`?${params.toString()}`);
}
function selectQueue(name: string) {
navigate({ queue: name, status: null, offset: null });
}
function selectStatus(status: string | null) {
navigate({ status, offset: null });
}
async function retryJob(job: Job) {
try {
await adminRetryJob(job.queue, job.id);
toast.success($_("admin.queues.retry_success"));
await invalidateAll();
} catch {
toast.error($_("admin.queues.retry_error"));
}
}
async function removeJob(job: Job) {
try {
await adminRemoveJob(job.queue, job.id);
toast.success($_("admin.queues.remove_success"));
await invalidateAll();
} catch {
toast.error($_("admin.queues.remove_error"));
}
}
async function toggleQueue(queueName: string, isPaused: boolean) {
togglingQueue = queueName;
try {
if (isPaused) {
await adminResumeQueue(queueName);
toast.success($_("admin.queues.resume_success"));
} else {
await adminPauseQueue(queueName);
toast.success($_("admin.queues.pause_success"));
}
await invalidateAll();
} catch {
toast.error(isPaused ? $_("admin.queues.resume_error") : $_("admin.queues.pause_error"));
} finally {
togglingQueue = null;
}
}
function statusColor(status: string): string {
switch (status) {
case "active":
return "text-blue-500 border-blue-500/30 bg-blue-500/10";
case "completed":
return "text-green-500 border-green-500/30 bg-green-500/10";
case "failed":
return "text-destructive border-destructive/30 bg-destructive/10";
case "delayed":
return "text-yellow-500 border-yellow-500/30 bg-yellow-500/10";
default:
return "text-muted-foreground border-border/40 bg-muted/20";
}
}
function formatDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleString();
}
</script>
<Meta title={$_("admin.queues.title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.queues.title")}</h1>
{#if data.queue && data.total > 0}
<span class="text-sm text-muted-foreground">
{$_("admin.users.total", { values: { total: data.total } })}
</span>
{/if}
</div>
<!-- Queue cards -->
<div class="flex flex-wrap gap-3 mb-6">
{#each data.queues as queue (queue.name)}
{@const isSelected = data.queue === queue.name}
<div
role="button"
tabindex="0"
class={`flex-1 min-w-48 rounded-lg border p-4 text-left transition-colors cursor-pointer ${
isSelected
? "border-primary/50 bg-primary/5"
: "border-border/40 bg-card hover:border-border/70"
}`}
onclick={() => selectQueue(queue.name)}
onkeydown={(e) => e.key === "Enter" && selectQueue(queue.name)}
aria-pressed={isSelected}
>
<div class="flex items-center justify-between mb-3">
<span class="font-semibold capitalize">{queue.name}</span>
<div class="flex items-center gap-1.5">
{#if queue.isPaused}
<Badge variant="outline" class="text-yellow-600 border-yellow-500/40 bg-yellow-500/10"
>{$_("admin.queues.paused_badge")}</Badge
>
{/if}
<Button
size="sm"
variant="ghost"
class="h-6 px-2 text-xs"
disabled={togglingQueue === queue.name}
onclick={(e) => {
e.stopPropagation();
toggleQueue(queue.name, queue.isPaused);
}}
>
{queue.isPaused ? $_("admin.queues.resume") : $_("admin.queues.pause")}
</Button>
</div>
</div>
<div class="flex flex-wrap gap-2 text-xs">
{#if queue.counts.waiting > 0}
<span class="text-muted-foreground">{queue.counts.waiting} waiting</span>
{/if}
{#if queue.counts.active > 0}
<span class="text-blue-500">{queue.counts.active} active</span>
{/if}
{#if queue.counts.completed > 0}
<span class="text-green-500">{queue.counts.completed} completed</span>
{/if}
{#if queue.counts.failed > 0}
<span class="text-destructive font-medium">{queue.counts.failed} failed</span>
{/if}
{#if queue.counts.delayed > 0}
<span class="text-yellow-500">{queue.counts.delayed} delayed</span>
{/if}
{#if Object.values(queue.counts).every((v) => v === 0)}
<span class="text-muted-foreground">empty</span>
{/if}
</div>
</div>
{/each}
</div>
{#if data.queue}
<!-- Status filter tabs -->
<div class="flex gap-1 mb-4 flex-wrap">
{#each STATUS_FILTERS as f (f.value ?? "all")}
<Button
variant={data.status === f.value ? "default" : "outline"}
onclick={() => selectStatus(f.value)}
>
{f.label}
</Button>
{/each}
</div>
<!-- Jobs table -->
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/30">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
>{$_("admin.queues.col_id")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
>{$_("admin.queues.col_name")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
>{$_("admin.queues.col_status")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">
{$_("admin.queues.col_attempts")}
</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden lg:table-cell">
{$_("admin.queues.col_created")}
</th>
<th class="px-4 py-3 text-right font-medium text-muted-foreground"
>{$_("admin.queues.col_actions")}</th
>
</tr>
</thead>
<tbody class="divide-y divide-border/30">
{#each data.jobs as job (job.id)}
<tr class="hover:bg-muted/10 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id}</td>
<td class="px-4 py-3">
<div>
<p class="font-medium">{job.name}</p>
{#if job.failedReason}
<p class="text-xs text-destructive mt-0.5 max-w-xs truncate">
{$_("admin.queues.failed_reason", { values: { reason: job.failedReason } })}
</p>
{/if}
</div>
</td>
<td class="px-4 py-3">
<Badge variant="outline" class={statusColor(job.status)}>{job.status}</Badge>
</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
>{job.attemptsMade}</td
>
<td class="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs"
>{formatDate(job.createdAt)}</td
>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
{#if job.status === "failed"}
<Button
size="sm"
variant="ghost"
aria-label={$_("admin.queues.retry")}
onclick={() => retryJob(job)}
>
<span class="icon-[ri--restart-line] h-4 w-4"></span>
</Button>
{/if}
<Button
size="sm"
variant="ghost"
aria-label={$_("admin.queues.remove")}
class="text-destructive hover:text-destructive hover:bg-destructive/10"
onclick={() => removeJob(job)}
>
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
</Button>
</div>
</td>
</tr>
{/each}
{#if data.jobs.length === 0}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
>{$_("admin.queues.no_jobs")}</td
>
</tr>
{/if}
</tbody>
</table>
</div>
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
start: data.offset + 1,
end: Math.min(data.offset + data.limit, data.total),
total: data.total,
},
})}
</span>
<Pagination
currentPage={Math.floor(data.offset / data.limit) + 1}
totalPages={Math.ceil(data.total / data.limit)}
onPageChange={(p) => navigate({ offset: String((p - 1) * data.limit) })}
/>
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,15 @@
import { adminListRecordings } from "$lib/services";
export async function load({ fetch, url, cookies }) {
const token = cookies.get("session_token") || "";
const search = url.searchParams.get("search") || undefined;
const status = url.searchParams.get("status") || undefined;
const offset = parseInt(url.searchParams.get("offset") || "0", 10);
const limit = 50;
const result = await adminListRecordings({ search, status, limit, offset }, fetch, token).catch(
() => ({ items: [], total: 0 }),
);
return { ...result, search, status, offset, limit };
}

View File

@@ -0,0 +1,221 @@
<script lang="ts">
import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { adminDeleteRecording } from "$lib/services";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Badge } from "$lib/components/ui/badge";
import * as Dialog from "$lib/components/ui/dialog";
import type { Recording } from "$lib/types";
import TimeAgo from "javascript-time-ago";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props();
const timeAgo = new TimeAgo("en");
let deleteTarget: Recording | null = $state(null);
let deleteOpen = $state(false);
let deleting = $state(false);
let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value) params.set("search", value);
else params.delete("search");
params.delete("offset");
goto(`?${params.toString()}`, { keepFocus: true });
}, 300);
}
function setFilter(key: string, value: string | null) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value !== null) params.set(key, value);
else params.delete(key);
params.delete("offset");
goto(`?${params.toString()}`);
}
function formatDuration(seconds: number) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
async function handleDelete() {
if (!deleteTarget) return;
deleting = true;
try {
await adminDeleteRecording(deleteTarget.id);
toast.success($_("admin.recordings.delete_success"));
deleteOpen = false;
deleteTarget = null;
await invalidateAll();
} catch {
toast.error($_("admin.recordings.delete_error"));
} finally {
deleting = false;
}
}
</script>
<Meta title={$_("admin.recordings.title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.recordings.title")}</h1>
<span class="text-sm text-muted-foreground"
>{$_("admin.users.total", { values: { total: data.total } })}</span
>
</div>
<div class="flex flex-wrap items-center gap-3 mb-4">
<Input
placeholder={$_("admin.recordings.search_placeholder")}
class="max-w-xs"
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
/>
<div class="flex gap-1">
<Button
variant={data.status === undefined ? "default" : "outline"}
onclick={() => setFilter("status", null)}>{$_("admin.common.all")}</Button
>
<Button
variant={data.status === "published" ? "default" : "outline"}
onclick={() => setFilter("status", "published")}>{$_("admin.recordings.published")}</Button
>
<Button
variant={data.status === "draft" ? "default" : "outline"}
onclick={() => setFilter("status", "draft")}>{$_("admin.recordings.draft")}</Button
>
</div>
</div>
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/30">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
>{$_("admin.recordings.col_title")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell"
>{$_("admin.recordings.col_status")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell"
>{$_("admin.recordings.col_duration")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell"
>{$_("admin.recordings.col_date")}</th
>
<th class="px-4 py-3 text-right font-medium text-muted-foreground"
>{$_("admin.users.col_actions")}</th
>
</tr>
</thead>
<tbody class="divide-y divide-border/30">
{#each data.items as recording (recording.id)}
<tr class="hover:bg-muted/10 transition-colors">
<td class="px-4 py-3">
<p class="font-medium">{recording.title}</p>
<p class="text-xs text-muted-foreground font-mono">{recording.slug}</p>
</td>
<td class="px-4 py-3 hidden sm:table-cell">
<div class="flex gap-1">
<Badge
variant="outline"
class={recording.status === "published"
? "text-green-600 border-green-500/40 bg-green-500/10"
: "text-yellow-600 border-yellow-500/40 bg-yellow-500/10"}
>
{$_(`recording_card.status_${recording.status}`)}
</Badge>
{#if recording.public}
<Badge variant="outline" class="text-blue-600 border-blue-500/40 bg-blue-500/10"
>{$_("admin.recordings.public")}</Badge
>
{/if}
</div>
</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell">
{formatDuration(recording.duration ?? 0)}
</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell">
{timeAgo.format(new Date(recording.date_created))}
</td>
<td class="px-4 py-3 text-right">
<Button
size="sm"
variant="ghost"
class="text-destructive hover:text-destructive hover:bg-destructive/10"
onclick={() => {
deleteTarget = recording;
deleteOpen = true;
}}
>
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
</Button>
</td>
</tr>
{/each}
{#if data.items.length === 0}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">
{$_("admin.recordings.no_results")}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
start: data.offset + 1,
end: Math.min(data.offset + data.limit, data.total),
total: data.total,
},
})}
</span>
<Pagination
currentPage={Math.floor(data.offset / data.limit) + 1}
totalPages={Math.ceil(data.total / data.limit)}
onPageChange={(p) => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String((p - 1) * data.limit));
goto(`?${params.toString()}`);
}}
/>
</div>
{/if}
</div>
<Dialog.Root bind:open={deleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$_("admin.recordings.delete_title")}</Dialog.Title>
<Dialog.Description>
{$_("admin.recordings.delete_description", { values: { title: deleteTarget?.title } })}
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (deleteOpen = false)}>{$_("common.cancel")}</Button>
<Button variant="destructive" disabled={deleting} onclick={handleDelete}>
{deleting ? $_("admin.common.deleting") : $_("common.delete")}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -12,10 +12,12 @@
import { Badge } from "$lib/components/ui/badge";
import * as Dialog from "$lib/components/ui/dialog";
import type { User } from "$lib/types";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props();
let searchValue = $state(data.search ?? "");
let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
let deleteTarget: User | null = $state(null);
let deleteOpen = $state(false);
@@ -84,8 +86,10 @@
}
</script>
<div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<Meta title={$_("admin.users.title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.users.title")}</h1>
<span class="text-sm text-muted-foreground"
>{$_("admin.users.total", { values: { total: data.total } })}</span
@@ -93,7 +97,7 @@
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
<div class="flex flex-wrap items-center gap-3 mb-4">
<Input
placeholder={$_("admin.users.search_placeholder")}
class="max-w-xs"
@@ -107,7 +111,6 @@
<div class="flex gap-1">
{#each roles as role (role)}
<Button
size="sm"
variant={data.role === role || (!data.role && role === "") ? "default" : "outline"}
onclick={() => setRole(role)}
>
@@ -226,7 +229,7 @@
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
@@ -236,32 +239,15 @@
},
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}
>
{$_("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}
>
{$_("common.next")}
</Button>
</div>
<Pagination
currentPage={Math.floor(data.offset / data.limit) + 1}
totalPages={Math.ceil(data.total / data.limit)}
onPageChange={(p) => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String((p - 1) * data.limit));
goto(`?${params.toString()}`);
}}
/>
</div>
{/if}
</div>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from "svelte";
import { toast } from "svelte-sonner";
import { invalidateAll } from "$app/navigation";
import {
@@ -12,17 +13,29 @@
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Card, CardContent } from "$lib/components/ui/card";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
let firstName = $state(data.user.first_name ?? "");
let lastName = $state(data.user.last_name ?? "");
let artistName = $state(data.user.artist_name ?? "");
let avatarId = $state<string | null>(data.user.avatar ?? null);
let bannerId = $state<string | null>(data.user.banner ?? null);
let isAdmin = $state(data.user.is_admin ?? false);
let firstName = $state(untrack(() => data.user.first_name ?? ""));
let lastName = $state(untrack(() => data.user.last_name ?? ""));
let artistName = $state(untrack(() => data.user.artist_name ?? ""));
let avatarId = $state<string | null>(untrack(() => data.user.avatar ?? null));
let bannerId = $state<string | null>(untrack(() => data.user.banner ?? null));
let photoId = $state<string | null>(untrack(() => data.user.photo ?? null));
let isAdmin = $state(untrack(() => data.user.is_admin ?? false));
let saving = $state(false);
$effect(() => {
firstName = data.user.first_name ?? "";
lastName = data.user.last_name ?? "";
artistName = data.user.artist_name ?? "";
avatarId = data.user.avatar ?? null;
bannerId = data.user.banner ?? null;
photoId = data.user.photo ?? null;
isAdmin = data.user.is_admin ?? false;
});
async function handleAvatarUpload(files: File[]) {
const file = files[0];
@@ -52,6 +65,20 @@
}
}
async function handlePhotoUpload2(files: File[]) {
const file = files[0];
if (!file) return;
const fd = new FormData();
fd.append("file", file);
try {
const res = await uploadFile(fd);
photoId = res.id;
toast.success($_("admin.user_edit.model_photo_uploaded"));
} catch {
toast.error($_("admin.user_edit.model_photo_failed"));
}
}
async function handlePhotoUpload(files: File[]) {
for (const file of files) {
const fd = new FormData();
@@ -88,22 +115,22 @@
artistName: artistName || undefined,
avatarId: avatarId || undefined,
bannerId: bannerId || undefined,
photoId: photoId || undefined,
isAdmin,
});
toast.success($_("admin.user_edit.save_success"));
} catch (e: any) {
toast.error(e?.message ?? $_("admin.user_edit.save_error"));
} catch (e) {
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.user_edit.save_error"));
} finally {
saving = false;
}
}
</script>
<div class="p-3 sm:p-6 max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/users" size="sm">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
</Button>
<Meta title={data.user.artist_name || data.user.email} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<div>
<h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1>
<p class="text-xs text-muted-foreground">
@@ -114,103 +141,142 @@
</div>
</div>
<div class="space-y-6">
<!-- Basic info -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="firstName">{$_("admin.user_edit.first_name")}</Label>
<Input id="firstName" bind:value={firstName} />
</div>
<div class="space-y-1.5">
<Label for="lastName">{$_("admin.user_edit.last_name")}</Label>
<Input id="lastName" bind:value={lastName} />
</div>
</div>
<div class="space-y-1.5">
<Label for="artistName">{$_("admin.user_edit.artist_name")}</Label>
<Input id="artistName" bind:value={artistName} />
</div>
<!-- Avatar -->
<div class="space-y-1.5">
<Label>{$_("admin.user_edit.avatar")}</Label>
{#if avatarId}
<img
src={getAssetUrl(avatarId, "thumbnail")}
alt=""
class="h-20 w-20 rounded-full object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleAvatarUpload} />
</div>
<!-- Banner -->
<div class="space-y-1.5">
<Label>{$_("admin.user_edit.banner")}</Label>
{#if bannerId}
<img
src={getAssetUrl(bannerId, "preview")}
alt=""
class="w-full h-24 rounded object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} />
</div>
<!-- Admin flag -->
<label
class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"
>
<input
type="checkbox"
bind:checked={isAdmin}
class="h-4 w-4 rounded accent-primary shrink-0"
/>
<div>
<span class="text-sm font-medium">{$_("admin.user_edit.is_admin")}</span>
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.is_admin_hint")}</p>
</div>
</label>
<div class="flex gap-3">
<Button
onclick={handleSave}
disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button>
</div>
<!-- Photo gallery -->
<div class="space-y-3 pt-4 border-t border-border/40">
<Label>{$_("admin.user_edit.photos")}</Label>
{#if data.user.photos && data.user.photos.length > 0}
<div class="grid grid-cols-3 gap-2">
{#each data.user.photos as photo (photo.id)}
<div class="relative group">
<img
src={getAssetUrl(photo.id, "thumbnail")}
alt=""
class="w-full aspect-square object-cover rounded"
/>
<button
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded"
onclick={() => removePhoto(photo.id)}
type="button"
>
<span class="icon-[ri--delete-bin-line] h-5 w-5 text-white"></span>
</button>
</div>
{/each}
<div class="space-y-6 max-w-2xl">
<!-- Profile & files card -->
<Card class="bg-card/50 border-primary/20">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="firstName">{$_("admin.user_edit.first_name")}</Label>
<Input
id="firstName"
bind:value={firstName}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label for="lastName">{$_("admin.user_edit.last_name")}</Label>
<Input
id="lastName"
bind:value={lastName}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
{:else}
<p class="text-sm text-muted-foreground">{$_("admin.user_edit.no_photos")}</p>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
</div>
<div class="space-y-1.5">
<Label for="artistName">{$_("admin.user_edit.artist_name")}</Label>
<Input
id="artistName"
bind:value={artistName}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.user_edit.avatar")}</Label>
{#if avatarId}
<img
src={getAssetUrl(avatarId, "thumbnail")}
alt=""
class="h-20 w-20 rounded-full object-cover mb-2"
/>
{/if}
<FileDropZone
accept="image/*"
maxFileSize={10 * MEGABYTE}
onUpload={handleAvatarUpload}
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.user_edit.banner")}</Label>
{#if bannerId}
<img
src={getAssetUrl(bannerId, "preview")}
alt=""
class="w-full h-24 rounded object-cover mb-2"
/>
{/if}
<FileDropZone
accept="image/*"
maxFileSize={10 * MEGABYTE}
onUpload={handleBannerUpload}
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.user_edit.model_photo")}</Label>
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.model_photo_hint")}</p>
{#if photoId}
<img
src={getAssetUrl(photoId, "preview")}
alt=""
class="w-full h-48 rounded object-cover mb-2"
/>
{/if}
<FileDropZone
accept="image/*"
maxFileSize={10 * MEGABYTE}
onUpload={handlePhotoUpload2}
/>
</div>
<label
class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"
>
<input
type="checkbox"
bind:checked={isAdmin}
class="h-4 w-4 rounded accent-primary shrink-0"
/>
<div>
<span class="text-sm font-medium">{$_("admin.user_edit.is_admin")}</span>
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.is_admin_hint")}</p>
</div>
</label>
<Button
onclick={handleSave}
disabled={saving}
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button>
</CardContent>
</Card>
<!-- Photo gallery card -->
<Card class="bg-card/50 border-primary/20">
<CardContent class="space-y-4 pt-6">
<Label>{$_("admin.user_edit.photos")}</Label>
{#if data.user.photos && data.user.photos.length > 0}
<div class="grid grid-cols-3 gap-2">
{#each data.user.photos as photo (photo.id)}
<div class="relative group">
<img
src={getAssetUrl(photo.id, "thumbnail")}
alt=""
class="w-full aspect-square object-cover rounded"
/>
<Button
variant="ghost"
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded h-auto p-0"
onclick={() => removePhoto(photo.id)}
aria-label="Remove photo"
>
<span class="icon-[ri--delete-bin-line] h-5 w-5 text-white"></span>
</Button>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">{$_("admin.user_edit.no_photos")}</p>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
</CardContent>
</Card>
</div>
</div>

View File

@@ -11,13 +11,15 @@
import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog";
import type { Video } from "$lib/types";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props();
let deleteTarget: Video | null = $state(null);
let deleteOpen = $state(false);
let deleting = $state(false);
let searchValue = $state(data.search ?? "");
let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) {
@@ -61,8 +63,10 @@
}
</script>
<div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<Meta title={$_("admin.videos.title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.videos.title")}</h1>
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground"
@@ -78,7 +82,7 @@
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
<div class="flex flex-wrap items-center gap-3 mb-4">
<Input
placeholder={$_("admin.videos.search_placeholder")}
class="max-w-xs"
@@ -90,21 +94,18 @@
/>
<div class="flex gap-1">
<Button
size="sm"
variant={data.featured === undefined ? "default" : "outline"}
onclick={() => setFilter("featured", null)}
>
{$_("admin.common.all")}
</Button>
<Button
size="sm"
variant={data.featured === true ? "default" : "outline"}
onclick={() => setFilter("featured", "true")}
>
{$_("admin.common.featured")}
</Button>
<Button
size="sm"
variant={data.premium === true ? "default" : "outline"}
onclick={() => setFilter("premium", data.premium === true ? null : "true")}
>
@@ -209,7 +210,7 @@
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
@@ -219,32 +220,15 @@
},
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}
>
{$_("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}
>
{$_("common.next")}
</Button>
</div>
<Pagination
currentPage={Math.floor(data.offset / data.limit) + 1}
totalPages={Math.ceil(data.total / data.limit)}
onPageChange={(p) => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String((p - 1) * data.limit));
goto(`?${params.toString()}`);
}}
/>
</div>
{/if}
</div>

View File

@@ -1,15 +1,14 @@
import { adminListVideos, getModels } from "$lib/services";
import { adminGetVideo, getModels } from "$lib/services";
import { error } from "@sveltejs/kit";
export async function load({ params, fetch, cookies }) {
const token = cookies.get("session_token") || "";
const [allVideos, models] = await Promise.all([
adminListVideos(fetch, token).catch(() => []),
getModels(fetch).catch(() => []),
const [video, modelsResult] = await Promise.all([
adminGetVideo(params.id, fetch, token).catch(() => null),
getModels({}, fetch).catch(() => ({ items: [], total: 0 })),
]);
const video = allVideos.find((v) => v.id === params.id);
if (!video) throw error(404, "Video not found");
return { video, models };
return { video, models: modelsResult.items };
}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from "svelte";
import { goto } from "$app/navigation";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
@@ -9,26 +10,44 @@
import { Textarea } from "$lib/components/ui/textarea";
import { TagsInput } from "$lib/components/ui/tags-input";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { Card, CardContent } from "$lib/components/ui/card";
import { getAssetUrl } from "$lib/api";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import { DatePicker } from "$lib/components/ui/date-picker";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
let title = $state(data.video.title);
let slug = $state(data.video.slug);
let description = $state(data.video.description ?? "");
let tags = $state<string[]>(data.video.tags ?? []);
let premium = $state(data.video.premium ?? false);
let featured = $state(data.video.featured ?? false);
let title = $state(untrack(() => data.video.title));
let slug = $state(untrack(() => data.video.slug));
let description = $state(untrack(() => data.video.description ?? ""));
let tags = $state<string[]>(untrack(() => data.video.tags ?? []));
let premium = $state(untrack(() => data.video.premium ?? false));
let featured = $state(untrack(() => data.video.featured ?? false));
let uploadDate = $state(
data.video.upload_date ? new Date(data.video.upload_date).toISOString().slice(0, 16) : "",
untrack(() =>
data.video.upload_date ? new Date(data.video.upload_date).toISOString().slice(0, 16) : "",
),
);
let imageId = $state<string | null>(data.video.image ?? null);
let movieId = $state<string | null>(data.video.movie ?? null);
let imageId = $state<string | null>(untrack(() => data.video.image ?? null));
let movieId = $state<string | null>(untrack(() => data.video.movie ?? null));
let selectedModelIds = $state<string[]>(
data.video.models?.map((m: { id: string }) => m.id) ?? [],
untrack(() => data.video.models?.map((m: { id: string }) => m.id) ?? []),
);
$effect(() => {
title = data.video.title;
slug = data.video.slug;
description = data.video.description ?? "";
tags = data.video.tags ?? [];
premium = data.video.premium ?? false;
featured = data.video.featured ?? false;
uploadDate = data.video.upload_date
? new Date(data.video.upload_date).toISOString().slice(0, 16)
: "";
imageId = data.video.image ?? null;
movieId = data.video.movie ?? null;
selectedModelIds = data.video.models?.map((m: { id: string }) => m.id) ?? [];
});
let saving = $state(false);
async function handleImageUpload(files: File[]) {
@@ -77,138 +96,153 @@
await setVideoModels(data.video.id, selectedModelIds);
toast.success($_("admin.video_form.update_success"));
goto("/admin/videos");
} catch (e: any) {
toast.error(e?.message ?? $_("admin.video_form.update_error"));
} catch (e) {
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.video_form.update_error"));
} finally {
saving = false;
}
}
</script>
<div class="p-3 sm:p-6 max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/videos" size="sm">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
</Button>
<h1 class="text-2xl font-bold">{$_("admin.video_form.edit_title")}</h1>
<Meta title={$_("admin.video_form.edit_title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{data.video.title}</h1>
<p class="text-xs text-muted-foreground mt-0.5">
{data.video.slug}{data.video.premium ? " · premium" : ""}{data.video.featured
? " · featured"
: ""}
</p>
</div>
<div class="space-y-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card class="bg-card/50 border-primary/20 max-w-2xl">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
placeholder={$_("admin.video_form.title_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input
id="slug"
bind:value={slug}
placeholder={$_("admin.video_form.slug_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
placeholder={$_("admin.video_form.title_placeholder")}
<Label for="description">{$_("admin.video_form.description")}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={$_("admin.video_form.description_placeholder")}
rows={3}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
<Label>{$_("admin.common.cover_image")}</Label>
{#if imageId}
<img
src={getAssetUrl(imageId, "thumbnail")}
alt=""
class="h-24 rounded object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
</div>
</div>
<div class="space-y-1.5">
<Label for="description">{$_("admin.video_form.description")}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={$_("admin.video_form.description_placeholder")}
rows={3}
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.video_form.video_file")}</Label>
{#if movieId}
<video
src={getAssetUrl(movieId)}
poster={imageId ? (getAssetUrl(imageId, "preview") ?? undefined) : undefined}
controls
class="w-full rounded-lg bg-black max-h-72 mb-2"
>
<track kind="captions" />
</video>
{/if}
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.cover_image")}</Label>
{#if imageId}
<img
src={getAssetUrl(imageId, "thumbnail")}
alt=""
class="h-24 rounded object-cover mb-2"
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput
bind:value={tags}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.video_form.video_file")}</Label>
{#if movieId}
<video
src={getAssetUrl(movieId)}
poster={imageId ? (getAssetUrl(imageId, "preview") ?? undefined) : undefined}
controls
class="w-full rounded-lg bg-black max-h-72 mb-2"
></video>
{/if}
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker
bind:value={uploadDate}
placeholder={$_("admin.common.publish_date")}
showTime={false}
/>
</div>
<div class="flex gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={premium} class="rounded" />
<span class="text-sm">{$_("admin.common.premium")}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
</div>
{#if data.models.length > 0}
<div class="space-y-1.5">
<Label>{$_("admin.video_form.models")}</Label>
<Select type="multiple" bind:value={selectedModelIds}>
<SelectTrigger class="w-full">
{#if selectedModelIds.length}
{$_("admin.video_form.models_selected", {
values: { count: selectedModelIds.length },
})}
{:else}
<span class="text-muted-foreground">{$_("admin.video_form.no_models")}</span>
{/if}
</SelectTrigger>
<SelectContent>
{#each data.models as model (model.id)}
<SelectItem value={model.id}>
{#if model.avatar}
<img
src={getAssetUrl(model.avatar, "mini")}
alt=""
class="h-5 w-5 rounded-full object-cover shrink-0"
/>
{/if}
{model.artist_name}
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
{/if}
<div class="flex gap-3 pt-2">
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker
bind:value={uploadDate}
placeholder={$_("admin.common.publish_date")}
showTime={false}
/>
</div>
<div class="flex gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={premium} class="rounded" />
<span class="text-sm">{$_("admin.common.premium")}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
</div>
{#if data.models.length > 0}
<div class="space-y-1.5">
<Label>{$_("admin.video_form.models")}</Label>
<Select type="multiple" bind:value={selectedModelIds}>
<SelectTrigger class="w-full bg-background/50 border-primary/20">
{#if selectedModelIds.length}
{$_("admin.video_form.models_selected", {
values: { count: selectedModelIds.length },
})}
{:else}
<span class="text-muted-foreground">{$_("admin.video_form.no_models")}</span>
{/if}
</SelectTrigger>
<SelectContent>
{#each data.models as model (model.id)}
<SelectItem value={model.id}>
{#if model.avatar}
<img
src={getAssetUrl(model.avatar, "mini")}
alt=""
class="h-5 w-5 rounded-full object-cover shrink-0"
/>
{/if}
{model.artist_name}
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
{/if}
<Button
onclick={handleSubmit}
disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button>
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -10,6 +10,8 @@
import { TagsInput } from "$lib/components/ui/tags-input";
import { DatePicker } from "$lib/components/ui/date-picker";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { Card, CardContent } from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
@@ -89,122 +91,131 @@
}
toast.success($_("admin.video_form.create_success"));
goto("/admin/videos");
} catch (e: any) {
toast.error(e?.message ?? $_("admin.video_form.create_error"));
} catch (e) {
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.video_form.create_error"));
} finally {
saving = false;
}
}
</script>
<div class="p-3 sm:p-6 max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/videos" size="sm">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
</Button>
<Meta title={$_("admin.video_form.new_title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("admin.video_form.new_title")}</h1>
</div>
<div class="space-y-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
oninput={() => {
if (!slug) slug = generateSlug(title);
}}
placeholder={$_("admin.video_form.title_placeholder")}
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
</div>
</div>
<div class="space-y-1.5">
<Label for="description">{$_("admin.video_form.description")}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={$_("admin.video_form.description_placeholder")}
rows={3}
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.cover_image")}</Label>
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
{#if imageId}<p class="text-xs text-green-600 mt-1">
{$_("admin.common.image_uploaded")}
</p>{/if}
</div>
<div class="space-y-1.5">
<Label>{$_("admin.video_form.video_file")}</Label>
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
{#if movieId}<p class="text-xs text-green-600 mt-1">
{$_("admin.video_form.video_uploaded")}
</p>{/if}
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker
bind:value={uploadDate}
placeholder={$_("admin.common.publish_date")}
showTime={false}
/>
</div>
<div class="flex gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={premium} class="rounded" />
<span class="text-sm">{$_("admin.common.premium")}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
</div>
{#if data.models.length > 0}
<div class="space-y-2">
<Label>Models</Label>
<div class="flex flex-wrap gap-2">
{#each data.models as model (model.id)}
<button
type="button"
class={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
selectedModelIds.includes(model.id)
? "border-primary bg-primary/10 text-primary"
: "border-border/40 text-muted-foreground hover:border-primary/40"
}`}
onclick={() => toggleModel(model.id)}
>
{model.artist_name || model.id}
</button>
{/each}
<Card class="bg-card/50 border-primary/20 max-w-2xl">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
oninput={() => {
if (!slug) slug = generateSlug(title);
}}
placeholder={$_("admin.video_form.title_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input
id="slug"
bind:value={slug}
placeholder={$_("admin.video_form.slug_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
{/if}
<div class="flex gap-3 pt-2">
<div class="space-y-1.5">
<Label for="description">{$_("admin.video_form.description")}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={$_("admin.video_form.description_placeholder")}
rows={3}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.cover_image")}</Label>
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
{#if imageId}
<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")}</p>
{/if}
</div>
<div class="space-y-1.5">
<Label>{$_("admin.video_form.video_file")}</Label>
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
{#if movieId}
<p class="text-xs text-green-600 mt-1">{$_("admin.video_form.video_uploaded")}</p>
{/if}
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput
bind:value={tags}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker
bind:value={uploadDate}
placeholder={$_("admin.common.publish_date")}
showTime={false}
/>
</div>
<div class="flex gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={premium} class="rounded" />
<span class="text-sm">{$_("admin.common.premium")}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
</div>
{#if data.models.length > 0}
<div class="space-y-2">
<Label>{$_("admin.video_form.models")}</Label>
<div class="flex flex-wrap gap-2">
{#each data.models as model (model.id)}
<Button
variant="ghost"
size="sm"
class={`px-3 py-1.5 h-auto rounded-full text-sm border transition-colors ${
selectedModelIds.includes(model.id)
? "border-primary bg-primary/10 text-primary"
: "border-border/40 text-muted-foreground hover:border-primary/40"
}`}
onclick={() => toggleModel(model.id)}
>
{model.artist_name || model.id}
</Button>
{/each}
</div>
</div>
{/if}
<Button
onclick={handleSubmit}
disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{saving ? $_("admin.common.creating") : $_("admin.video_form.create")}
</Button>
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -8,7 +8,8 @@
import Meta from "$lib/components/meta/meta.svelte";
let searchQuery = $state("");
let expandedItems = new SvelteSet<number>();
// eslint-disable-next-line svelte/no-unnecessary-state-wrap -- variable is reassigned, $state is required
let expandedItems = $state(new SvelteSet<number>());
const faqCategories = [
{

View File

@@ -1,66 +1,5 @@
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import { gql } from "graphql-request";
import { getGraphQLClient } from "$lib/api";
const LEADERBOARD_QUERY = gql`
query Leaderboard($limit: Int, $offset: Int) {
leaderboard(limit: $limit, offset: $offset) {
user_id
display_name
avatar
total_weighted_points
total_raw_points
recordings_count
playbacks_count
achievements_count
rank
}
}
`;
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
// Guard: Redirect to login if not authenticated
if (!locals.authStatus.authenticated) {
throw redirect(302, "/login");
}
try {
const limit = parseInt(url.searchParams.get("limit") || "100");
const offset = parseInt(url.searchParams.get("offset") || "0");
const client = getGraphQLClient(fetch);
const data = await client.request<{
leaderboard: {
user_id: string;
display_name: string | null;
avatar: string | null;
total_weighted_points: number | null;
total_raw_points: number | null;
recordings_count: number | null;
playbacks_count: number | null;
achievements_count: number | null;
rank: number;
}[];
}>(LEADERBOARD_QUERY, { limit, offset });
return {
leaderboard: data.leaderboard || [],
pagination: {
limit,
offset,
hasMore: data.leaderboard?.length === limit,
},
};
} catch (error) {
console.error("Leaderboard load error:", error);
return {
leaderboard: [],
pagination: {
limit: 100,
offset: 0,
hasMore: false,
},
};
}
};
export function load() {
throw redirect(301, "/play/leaderboard");
}

View File

@@ -1,4 +1,9 @@
import { redirect } from "@sveltejs/kit";
export async function load({ locals }) {
if (locals.authStatus?.authenticated) {
redirect(302, "/me");
}
return {
authStatus: locals.authStatus,
};

View File

@@ -29,10 +29,12 @@
async function handleSubmit(e: Event) {
e.preventDefault();
try {
isLoading = true;
await login(email, password);
goto("/videos", { invalidateAll: true });
} catch (err: any) {
const raw = err.response?.errors?.[0]?.message ?? err.message;
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
const raw = e.response?.errors?.[0]?.message ?? e.message;
error = raw === "Invalid credentials" ? $_("auth.login.error_invalid_credentials") : raw;
isError = true;
} finally {

View File

@@ -11,15 +11,19 @@
import { getAssetUrl } from "$lib/api";
import { calcReadingTime } from "$lib/utils.js";
import Meta from "$lib/components/meta/meta.svelte";
import SexyBackground from "$lib/components/background/background.svelte";
import PageHero from "$lib/components/page-hero/page-hero.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const timeAgo = new TimeAgo("en");
const { data } = $props();
let searchValue = $state(data.search ?? "");
let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
const featuredArticle =
data.page === 1 && !data.search && !data.category ? data.items.find((a) => a.featured) : null;
const featuredArticle = $derived(
data.page === 1 && !data.search && !data.category ? data.items.find((a) => a.featured) : null,
);
function debounceSearch(value: string) {
clearTimeout(searchTimeout);
@@ -47,7 +51,6 @@
goto(`?${params.toString()}`);
}
const totalPages = $derived(Math.ceil(data.total / data.limit));
</script>
<Meta title={$_("magazine.title")} description={$_("magazine.description")} />
@@ -55,109 +58,80 @@
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<!-- Global Plasma Background -->
<div class="absolute inset-0 pointer-events-none">
<div
class="absolute top-40 left-1/4 w-80 h-80 bg-gradient-to-r from-primary/16 via-accent/20 to-primary/12 rounded-full blur-3xl animate-blob-slow"
></div>
<div
class="absolute bottom-40 right-1/4 w-96 h-96 bg-gradient-to-r from-accent/16 via-primary/20 to-accent/12 rounded-full blur-3xl animate-blob-slow animation-delay-5000"
></div>
<div
class="absolute top-1/3 right-1/3 w-64 h-64 bg-gradient-to-r from-primary/14 via-accent/18 to-primary/10 rounded-full blur-2xl animate-blob-reverse animation-delay-2500"
></div>
</div>
<SexyBackground />
<section class="relative py-20 overflow-hidden">
<div
class="absolute inset-0 bg-gradient-to-br from-primary/10 via-accent/5 to-background"
></div>
<div class="relative container mx-auto px-4 text-center">
<div class="max-w-5xl mx-auto">
<h1
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
>
{$_("magazine.title")}
</h1>
<p
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
>
{$_("magazine.description")}
</p>
<!-- Filters -->
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
<!-- Search -->
<div class="relative flex-1">
<span
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
></span>
<Input
placeholder={$_("magazine.search_placeholder")}
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<!-- Category Filter -->
<Select
type="single"
value={data.category ?? "all"}
onValueChange={(v) => v && setParam("category", v)}
>
<SelectTrigger
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
{!data.category
? $_("magazine.categories.all")
: data.category === "photography"
? $_("magazine.categories.photography")
: data.category === "production"
? $_("magazine.categories.production")
: data.category === "interview"
? $_("magazine.categories.interview")
: data.category === "psychology"
? $_("magazine.categories.psychology")
: data.category === "trends"
? $_("magazine.categories.trends")
: $_("magazine.categories.spotlight")}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{$_("magazine.categories.all")}</SelectItem>
<SelectItem value="photography">{$_("magazine.categories.photography")}</SelectItem>
<SelectItem value="production">{$_("magazine.categories.production")}</SelectItem>
<SelectItem value="interview">{$_("magazine.categories.interview")}</SelectItem>
<SelectItem value="psychology">{$_("magazine.categories.psychology")}</SelectItem>
<SelectItem value="trends">{$_("magazine.categories.trends")}</SelectItem>
<SelectItem value="spotlight">{$_("magazine.categories.spotlight")}</SelectItem>
</SelectContent>
</Select>
<!-- Sort -->
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
<SelectTrigger
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
{data.sort === "featured"
? $_("magazine.sort.featured")
: data.sort === "name"
? $_("magazine.sort.name")
: $_("magazine.sort.recent")}
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">{$_("magazine.sort.recent")}</SelectItem>
<SelectItem value="featured">{$_("magazine.sort.featured")}</SelectItem>
<SelectItem value="name">{$_("magazine.sort.name")}</SelectItem>
</SelectContent>
</Select>
</div>
<PageHero title={$_("magazine.title")} description={$_("magazine.description")}>
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
<!-- Search -->
<div class="relative flex-1">
<span
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
></span>
<Input
placeholder={$_("magazine.search_placeholder")}
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<!-- Category Filter -->
<Select
type="single"
value={data.category ?? "all"}
onValueChange={(v) => v && setParam("category", v)}
>
<SelectTrigger
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
{!data.category
? $_("magazine.categories.all")
: data.category === "photography"
? $_("magazine.categories.photography")
: data.category === "production"
? $_("magazine.categories.production")
: data.category === "interview"
? $_("magazine.categories.interview")
: data.category === "psychology"
? $_("magazine.categories.psychology")
: data.category === "trends"
? $_("magazine.categories.trends")
: $_("magazine.categories.spotlight")}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{$_("magazine.categories.all")}</SelectItem>
<SelectItem value="photography">{$_("magazine.categories.photography")}</SelectItem>
<SelectItem value="production">{$_("magazine.categories.production")}</SelectItem>
<SelectItem value="interview">{$_("magazine.categories.interview")}</SelectItem>
<SelectItem value="psychology">{$_("magazine.categories.psychology")}</SelectItem>
<SelectItem value="trends">{$_("magazine.categories.trends")}</SelectItem>
<SelectItem value="spotlight">{$_("magazine.categories.spotlight")}</SelectItem>
</SelectContent>
</Select>
<!-- Sort -->
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
<SelectTrigger
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
{data.sort === "featured"
? $_("magazine.sort.featured")
: data.sort === "name"
? $_("magazine.sort.name")
: $_("magazine.sort.recent")}
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">{$_("magazine.sort.recent")}</SelectItem>
<SelectItem value="featured">{$_("magazine.sort.featured")}</SelectItem>
<SelectItem value="name">{$_("magazine.sort.name")}</SelectItem>
</SelectContent>
</Select>
</div>
</section>
</PageHero>
<div class="container mx-auto px-4 py-12">
<!-- Featured Article -->
@@ -187,9 +161,7 @@
</span>
</div>
<h2 class="text-2xl md:text-3xl font-bold mb-4 hover:text-primary transition-colors">
<button class="text-left">
<a href="/article/{featuredArticle.slug}">{featuredArticle.title}</a>
</button>
<a href="/magazine/{featuredArticle.slug}">{featuredArticle.title}</a>
</h2>
<p class="text-muted-foreground mb-6 text-lg leading-relaxed">
{featuredArticle.excerpt}
@@ -229,100 +201,83 @@
<!-- Articles Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each data.items as article (article.slug)}
<Card
class="p-0 group hover:shadow-2xl hover:shadow-primary/25 transition-all duration-300 hover:-translate-y-3 bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10 overflow-hidden"
>
<div class="relative">
<img
src={getAssetUrl(article.image, "preview")}
alt={article.title}
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div
class="absolute group-hover:scale-105 transition-transform inset-0 bg-gradient-to-t from-black/40 to-transparent duration-300"
></div>
<!-- Category Badge -->
<div
class="absolute top-3 left-3 bg-primary/90 text-white text-xs px-2 py-1 rounded-full capitalize"
>
{article.category}
</div>
<!-- Featured Badge -->
{#if article.featured}
<a href="/magazine/{article.slug}" class="block group">
<Card
class="p-0 h-full hover:shadow-2xl hover:shadow-primary/25 transition-all duration-300 hover:-translate-y-3 bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10 overflow-hidden"
>
<div class="relative">
<img
src={getAssetUrl(article.image, "preview")}
alt={article.title}
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
/>
<div
class="absolute top-3 right-3 bg-gradient-to-r from-primary to-accent text-white text-xs px-2 py-1 rounded-full"
class="absolute group-hover:scale-105 transition-transform inset-0 bg-gradient-to-t from-black/40 to-transparent duration-300"
></div>
<!-- Category Badge -->
<div
class="absolute top-3 left-3 bg-primary/90 text-white text-xs px-2 py-1 rounded-full capitalize"
>
{$_("magazine.featured")}
{article.category}
</div>
{/if}
<!-- Views -->
<!-- <div
class="absolute bottom-3 right-3 text-white text-sm flex items-center gap-1"
>
<TrendingUpIcon class="w-4 h-4" />
{article.views}
</div> -->
</div>
<CardContent class="p-6">
<div class="mb-4">
<h3
class="font-semibold text-lg mb-2 group-hover:text-primary transition-colors line-clamp-2"
>
<a href="/magazine/{article.slug}">{article.title}</a>
</h3>
<p class="text-muted-foreground text-sm line-clamp-3 leading-relaxed">
{article.excerpt}
</p>
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2 mb-4">
{#each (article.tags ?? []).slice(0, 3) as tag (tag)}
<a
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
href="/tags/{tag}"
<!-- Featured Badge -->
{#if article.featured}
<div
class="absolute top-3 right-3 bg-gradient-to-r from-primary to-accent text-white text-xs px-2 py-1 rounded-full"
>
#{tag}
</a>
{/each}
{$_("magazine.featured")}
</div>
{/if}
</div>
<!-- Author & Meta -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<img
src={getAssetUrl(article.author?.avatar, "mini")}
alt={article.author?.artist_name}
class="w-8 h-8 rounded-full object-cover"
/>
<div>
<p class="text-sm font-medium">{article.author?.artist_name}</p>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
{timeAgo.format(new Date(article.publish_date))}
<CardContent class="p-6">
<div class="mb-4">
<h3
class="font-semibold text-lg mb-2 group-hover:text-primary transition-colors line-clamp-2"
>
{article.title}
</h3>
<p class="text-muted-foreground text-sm line-clamp-3 leading-relaxed">
{article.excerpt}
</p>
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2 mb-4">
{#each (article.tags ?? []).slice(0, 3) as tag (tag)}
<span class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full">
#{tag}
</span>
{/each}
</div>
<!-- Author & Meta -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<img
src={getAssetUrl(article.author?.avatar, "mini")}
alt={article.author?.artist_name}
class="w-8 h-8 rounded-full object-cover bg-muted"
/>
<div>
<p class="text-sm font-medium">{article.author?.artist_name}</p>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
{timeAgo.format(new Date(article.publish_date))}
</div>
</div>
</div>
<div class="text-xs text-muted-foreground">
{$_("magazine.read_time", {
values: { time: calcReadingTime(article.content) },
})}
</div>
</div>
<div class="text-xs text-muted-foreground">
{$_("magazine.read_time", {
values: { time: calcReadingTime(article.content) },
})}
</div>
</div>
<!-- Read More Button -->
<Button
variant="outline"
size="sm"
class="w-full mt-4 border-primary/20 hover:bg-primary/10"
href="/magazine/{article.slug}">{$_("magazine.read_article")}</Button
>
</CardContent>
</Card>
</CardContent>
</Card>
</a>
{/each}
</div>
@@ -338,33 +293,16 @@
{/if}
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-10">
<span class="text-sm text-muted-foreground">
{$_("common.page_of", { values: { page: data.page, total: totalPages } })}
&nbsp;·&nbsp;
{#if Math.ceil(data.total / data.limit) > 1}
<div class="flex flex-col items-center gap-3 mt-10">
<Pagination
currentPage={data.page}
totalPages={Math.ceil(data.total / data.limit)}
onPageChange={goToPage}
/>
<p class="text-sm text-muted-foreground">
{$_("common.total_results", { values: { total: data.total } })}
</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={data.page <= 1}
onclick={() => goToPage(data.page - 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.previous")}
</Button>
<Button
variant="outline"
size="sm"
disabled={data.page >= totalPages}
onclick={() => goToPage(data.page + 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.next")}
</Button>
</div>
</p>
</div>
{/if}
</div>

Some files were not shown because too many files have changed in this diff Show More