Compare commits

...

162 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
f2871b98db style: apply prettier formatting across frontend components and pages
Some checks failed
Build and Push Backend Image / build (push) Successful in 1m4s
Build and Push Frontend Image / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 10:49:35 +01:00
9c5dba5c90 feat: add server-side pagination, search, and filtering to all collection and admin pages
- Public pages (videos, magazine, models): URL-driven search, sort, category/duration
  filters, and Prev/Next pagination (page size 24)
- Admin tables (videos, articles): search input, toggle filters, and pagination (page size 50)
- Tags page: tag filtering now done server-side via DB arrayContains query instead of
  fetching all items and filtering client-side
- Backend resolvers updated for videos, articles, models with paginated { items, total }
  responses and filter/sort/tag args

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 10:43:26 +01:00
c90c09da9a chore: cleanup
All checks were successful
Build and Push Backend Image / build (push) Successful in 49s
Build and Push Frontend Image / build (push) Successful in 4m18s
2026-03-06 19:25:07 +01:00
aed7b4a16f fix: restore admin role in User type, use image logo
- Add "admin" back to User.role union to fix backend TS build
- Replace SVG PeonyIcon with logo.png image in Logo component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 18:04:49 +01:00
454c477c40 style: use rocket icon for all Go to Play buttons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:42:49 +01:00
3cf81bd381 style: apply gradient to primary buttons in admin area
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:39:36 +01:00
ac63e59906 style: remove card wrapper from error page
Some checks failed
Build and Push Backend Image / build (push) Failing after 27s
Build and Push Frontend Image / build (push) Successful in 5m7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:28:11 +01:00
19d29cbfc6 fix: replace flyout profile card with logout slider, i18n auth errors
- Replace static account card in mobile flyout with swipe-to-logout widget
- Remove redundant logout button from flyout bottom
- Make LogoutButton full-width via class prop and dynamic maxSlide
- Extract clean GraphQL error messages instead of raw JSON in all auth forms
- Add i18n keys for known backend errors (invalid credentials, email taken, invalid token)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:26:14 +01:00
0ec27117ae style: streamline /me page header to match admin dashboard style
Replace large gradient title with simple text-2xl font-bold heading,
matching the header pattern used across admin pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:13:19 +01:00
ed9eb6ef22 style: fix admin table padding — edge-to-edge on mobile, no right pad on desktop
Mobile: remove horizontal padding so tables fill full width with top/bottom
borders only. Desktop: keep left padding, table extends to right edge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:08:16 +01:00
609f116b5d feat: replace native date inputs with shadcn date picker
Add calendar + popover components and a custom DateTimePicker wrapper.
Video forms use date-only; article forms include a time picker.
Also add video player preview to the video edit form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:03:35 +01:00
e943876e70 fix: prevent age verification dialog flicker on page load
Initialize isOpen as false and only open in onMount if not yet verified,
instead of opening immediately and closing after localStorage check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 16:52:48 +01:00
7d373b3aa3 i18n: internationalize all admin pages
Add full i18n coverage for the admin section — locale keys, layout nav,
users, videos, and articles pages (list, new, edit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 16:49:30 +01:00
95fd9f48fc refactor: align article author with VideoModel, streamline selects, fix flyout inert
- Remove ArticleAuthor type; article.author now reuses VideoModel (id, artist_name, slug, avatar)
- updateArticle accepts authorId; author selectable in admin article edit page
- Article edit: single Select with bind:value + $derived selectedAuthor display
- Video edit: replace pill toggles with Select type="multiple" bind:value for models
- Video table: replace inline badge spans with Badge component
- Magazine: display artist_name throughout, author bio links to model profile
- Fix flyout aria-hidden warning: replace with inert attribute

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 16:31:41 +01:00
670c18bcb7 feat: refactor role system to is_admin flag, add Badge component, fix native dialogs
- Separate admin identity from role: viewer|model + is_admin boolean flag
- DB migration 0001_is_admin: adds column, migrates former admin role users
- Update ACL helpers, auth session, GraphQL types and all resolvers
- Admin layout guard and header links check is_admin instead of role
- Admin users table: show Admin badge next to name, remove admin from role select
- Admin user edit page: is_admin checkbox toggle
- Install shadcn Badge component; use in admin users table
- Fix duplicate photo keys in adminGetUser resolver
- Replace confirm() in /me recordings with Dialog component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 16:14:00 +01:00
9ef490c1e5 fix: make deleteRecording a hard delete instead of soft archive
Previously deleteRecording set status to "archived", leaving the row
in the DB and visible in queries without a status filter. Now it hard-
deletes the row. Also excludes archived recordings from the default
recordings query so any pre-existing archived rows no longer appear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:45:59 +01:00
434e926f77 style: use primary color for scrollbar thumbs
All checks were successful
Build and Push Backend Image / build (push) Successful in 17s
Build and Push Frontend Image / build (push) Successful in 4m0s
40% opacity at rest, 70% on hover, adapts to light/dark theme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:42:15 +01:00
7a9ce0c3b1 fix: explicitly style html root scrollbar for Firefox
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:39:44 +01:00
ff1e1f6679 feat: style scrollbars globally using theme colors
Thin scrollbars using --border for thumb and transparent track,
with --muted-foreground on hover. Uses both scrollbar-color (Firefox)
and ::-webkit-scrollbar (Chrome/Safari).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:38:02 +01:00
648123fab5 feat: mobile-optimize admin section
- Layout: sidebar hidden on mobile, replaced with horizontal top nav strip
- Tables: overflow-x-auto + hide secondary columns (email/category/dates/
  plays/likes) on small screens; show email inline under name on mobile
- Forms: grid-cols-2 → grid-cols-1 sm:grid-cols-2 on all admin forms
- Markdown editor: Write/Preview tab toggle on mobile, side-by-side on sm+
- Padding: p-3 sm:p-6 on all admin pages for tighter mobile layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:36:52 +01:00
a7fafaf7c5 refactor: replace native select with shadcn Select for user role in admin
All checks were successful
Build and Push Backend Image / build (push) Successful in 1m10s
Build and Push Frontend Image / build (push) Successful in 5m8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:34:39 +01:00
b71d7dc559 refactor: remove duplicate utils/utils.ts, consolidate into utils.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:31:35 +01:00
f764e27d59 fix: shrink flyout account card, remove online indicator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:29:33 +01:00
d7eb2acc6c fix: match mobile flyout header height to main header (h-16)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:27:31 +01:00
fb38d6b9a9 fix: constrain admin layout to container width matching rest of site
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:22:15 +01:00
d021acaf0b feat: add admin user edit page with avatar, banner, and photo gallery
- Backend: adminGetUser query returns user + photos; adminUpdateUser now
  accepts avatarId/bannerId; new adminAddUserPhoto and adminRemoveUserPhoto
  mutations; AdminUserDetailType added to GraphQL schema
- Frontend: /admin/users/[id] page for editing name, avatar, banner, and
  managing the model photo gallery (upload multiple, delete individually)
- Admin users list: edit button per row linking to the detail page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:18:43 +01:00
e06a1915f2 fix: remove backdrop-blur overlay causing blurry text site-wide
The full-screen glassmorphism overlay had backdrop-blur-[0.5px] which
triggered GPU compositing on the entire viewport, degrading subpixel
text rendering inconsistently. Also use globalThis.fetch (not SvelteKit
fetch) when forwarding session token in admin SSR calls to avoid header
stripping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:11:54 +01:00
ebab3405b1 fix: forward session token in admin SSR load functions
Admin list queries (users, videos, articles) were using getGraphQLClient
without auth credentials, causing silent 403s on server-side loads. Now
extract session_token cookie and pass it to getAuthClient so the backend
sees the admin session on SSR requests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:56:47 +01:00
ad7ceee5f8 fix: resolve lint errors from ACL/admin implementation
- Remove unused requireOwnerOrAdmin import from videos.ts
- Remove unused requireAuth import from users.ts
- Remove unused GraphQLError import from articles.ts
- Replace URLSearchParams with SvelteURLSearchParams in admin users page
- Apply prettier formatting to all changed files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:35:11 +01:00
c1770ab9c9 feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query

Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
b200498a10 fix: correct CI badge URLs to use Gitea workflow badge format
All checks were successful
Build and Push Backend Image / build (push) Successful in 16s
Build and Push Frontend Image / build (push) Successful in 15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 12:57:47 +01:00
1369d5c228 fix: copy packages/types into backend Docker build
All checks were successful
Build and Push Backend Image / build (push) Successful in 46s
Build and Push Frontend Image / build (push) Successful in 16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 12:51:52 +01:00
e200514347 refactor: move flyout to left side, restore logo, remove close button
Some checks failed
Build and Push Backend Image / build (push) Failing after 47s
Build and Push Frontend Image / build (push) Successful in 5m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 12:34:57 +01:00
d7057c3681 refactor: improve mobile flyout header
- Replace inline mobile dropdown with sliding flyout panel from right
- Hide burger menu on lg breakpoint, desktop auth buttons use hidden lg:flex
- Add backdrop overlay with opacity transition
- Remove logo from flyout panel header
- Fix backdrop div accessibility with role="presentation"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 11:44:23 +01:00
d820a8f6be chore: relative uploads dir 2026-03-05 11:05:30 +01:00
9bef2469d1 refactor: rename RecordedEvent fields to snake_case
deviceIndex → device_index
deviceName → device_name
actuatorIndex → actuator_index
actuatorType → actuator_type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 11:04:36 +01:00
97269788ee feat: add shared @sexy.pivoine.art/types package and fix type safety across frontend/backend
- Create packages/types with shared TypeScript domain model interfaces (User, Video, Model, Article, Comment, Recording, etc.)
- Wire both frontend and backend packages to use @sexy.pivoine.art/types via workspace:*
- Update backend Pothos objectRef types to use shared interfaces instead of inline types
- Update frontend $lib/types.ts to re-export from shared package
- Fix all type errors introduced by more accurate nullable types (avatar/banner as string|null UUIDs, author nullable, events/device_info as object[])
- Add artist_name to comment user select in backend resolver
- Widen utility function signatures (getAssetUrl, getUserInitials, calcReadingTime) to accept null/undefined

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 11:01:11 +01:00
c6126c13e9 feat: add backend logger matching frontend text format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 10:22:49 +01:00
fd4050a49f refactor: remove directus.ts shim, import directly from api
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 10:19:05 +01:00
efc7624ba3 style: apply prettier formatting to all files
All checks were successful
Build and Push Backend Image / build (push) Successful in 46s
Build and Push Frontend Image / build (push) Successful in 5m12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 22:27:54 +01:00
18116072c9 feat: add formidable ESLint + Prettier linting setup
Some checks failed
Build and Push Backend Image / build (push) Successful in 47s
Build and Push Frontend Image / build (push) Has been cancelled
- Root-level eslint.config.js (flat config): typescript-eslint,
  eslint-plugin-svelte, eslint-config-prettier, @eslint/js
- Root-level prettier.config.js with prettier-plugin-svelte
- svelte-check added to frontend for Svelte/TS type checking
- lint, lint:fix, format, format:check, check scripts in root
  and both packages
- All 60 lint errors fixed across backend and frontend:
  - Consistent type imports
  - Removed unused imports/variables
  - Added keys to all {#each} blocks for Svelte performance
  - Replaced mutable Set/Map with SvelteSet/SvelteMap
  - Fixed useless assignments and empty catch blocks
- 64 remaining warnings are intentional any usages in the
  Pothos/Drizzle GraphQL resolver layer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 22:24:55 +01:00
741e0c3387 docs: update README for custom backend replacing Directus
All checks were successful
Build and Push Backend Image / build (push) Successful in 17s
Build and Push Frontend Image / build (push) Successful in 17s
Replace all Directus references with the new Fastify + GraphQL Yoga
stack, update CI/CD references to dev.pivoine.art Gitea Actions,
add DB schema overview, auth flow, image transform presets table,
and fix all links to use https and correct registry URLs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 22:01:46 +01:00
662e3e8fe2 fix: model join date used join_date but API returns date_created
All checks were successful
Build and Push Backend Image / build (push) Successful in 17s
Build and Push Frontend Image / build (push) Successful in 4m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 21:42:34 +01:00
fa159feffa fix: remove black border below video controls
Some checks failed
Build and Push Backend Image / build (push) Successful in 16s
Build and Push Frontend Image / build (push) Has been cancelled
- video: inline → block w-full (eliminates baseline descender gap)
- media-controller: fill parent container with absolute inset-0 w-full h-full

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 21:41:07 +01:00
124f0bfb22 fix: video src used movie.id but movie is already the UUID string
All checks were successful
Build and Push Backend Image / build (push) Successful in 17s
Build and Push Frontend Image / build (push) Successful in 4m18s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 21:27:06 +01:00
df89cc59f5 fix: use preview transform for home page video teasers
Some checks failed
Build and Push Backend Image / build (push) Has been cancelled
Build and Push Frontend Image / build (push) Has been cancelled
thumbnail (300x300 square) was double-cropping inside the wide h-48
container. preview (800px, aspect-ratio preserved) lets object-cover
do the only crop, matching the videos and model pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 21:24:59 +01:00
845e3df223 fix: image transforms — preserve aspect ratio, increase quality
Some checks failed
Build and Push Backend Image / build (push) Successful in 40s
Build and Push Frontend Image / build (push) Has been cancelled
- preview/medium use fit:inside (no forced crop, preserves aspect ratio)
- Only mini/thumbnail/banner force square/fixed crops
- Increase WebP quality 85 → 92
- Increase preview width 480 → 800, medium 960 → 1400

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 21:22:30 +01:00
05cb6a66e3 fix: image transforms via Sharp, model photos crash, video duration
All checks were successful
Build and Push Backend Image / build (push) Successful in 46s
Build and Push Frontend Image / build (push) Successful in 5m7s
- Backend: add Sharp image transform endpoint (/assets/:id?transform=X)
  with presets: mini(64), thumbnail(200), preview(480), medium(960), banner(1280)
  Transformed images are cached as webp next to originals
- Frontend: fix model photos crash (p.directus_files_id → p)
- Frontend: fix model banner URL (data.model.banner.id → data.model.banner)
- Frontend: fix video duration display (video.movie.duration → video.movie_file?.duration)
  across models/[slug], videos, videos/[slug], and home pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 20:56:33 +01:00
273aa42510 fix: serve assets via DB lookup to resolve file path correctly
All checks were successful
Build and Push Backend Image / build (push) Successful in 38s
Build and Push Frontend Image / build (push) Successful in 4m11s
Files are stored as <UPLOAD_DIR>/<id>/<filename>. The previous static
serving attempted to serve <UPLOAD_DIR>/<id> (a directory) which failed.
Custom /assets/:id route now looks up filename from DB and uses sendFile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 20:40:22 +01:00
1e930baccb fix: resolve GraphQL request hang in Fastify integration
All checks were successful
Build and Push Backend Image / build (push) Successful in 39s
Build and Push Frontend Image / build (push) Successful in 4m7s
- Pass FastifyRequest/FastifyReply directly to yoga.handleNodeRequestAndResponse
  per the official graphql-yoga Fastify integration docs. Yoga v5 uses req.body
  (already parsed by Fastify) when available, avoiding the dead raw stream issue.
- Add proper TypeScript generics for server context including db and redis
- Wrap sendVerification/sendPasswordReset in try/catch so missing SMTP
  does not crash register/requestPasswordReset mutations
- Fix migrate.ts path resolution to work with both tsx (src/) and compiled (dist/)
- Expose postgres:5432 and redis:6379 ports in compose.yml for local dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 20:31:18 +01:00
012bb176d9 fix: convert Web API ReadableStream to Node.js Readable for Fastify
Some checks failed
Build and Push Backend Image / build (push) Failing after 26s
Build and Push Frontend Image / build (push) Successful in 4m17s
graphql-yoga's handleNodeRequestAndResponse returns a Response with a
Web API ReadableStream body. Fastify's reply.send() requires a Node.js
Readable stream, causing all GraphQL requests to hang indefinitely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 20:11:08 +01:00
ed7ac0c573 fix: downgrade nanoid to v3 for CommonJS compatibility
All checks were successful
Build and Push Backend Image / build (push) Successful in 2m28s
Build and Push Frontend Image / build (push) Successful in 5m12s
nanoid v5 is ESM-only and cannot be require()'d in a CommonJS module.
v3 is the last version with native CJS support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 19:43:44 +01:00
4565038be3 fix: cast recording duration float to integer in data migration
Some checks failed
Build and Push Backend Image / build (push) Successful in 39s
Build and Push Frontend Image / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 19:39:52 +01:00
fbafbeca5d fix: pass tags as native arrays not JSON strings in data migration
Some checks failed
Build and Push Backend Image / build (push) Successful in 38s
Build and Push Frontend Image / build (push) Has been cancelled
PostgreSQL text[] columns require native array values, not JSON strings.
Parse string tags from Directus and pass as JS arrays directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 19:36:04 +01:00
480369aa4e fix: correct column names in data migration script to match actual Directus schema
Some checks failed
Build and Push Backend Image / build (push) Successful in 37s
Build and Push Frontend Image / build (push) Has been cancelled
- directus_files: uploaded_on → date_created alias
- directus_users: join_date → date_created, remove email_notifications_key
- junction_directus_users_files: remove non-existent sort column
- sexy_videos: remove non-existent likes_count/plays_count (default 0)
- sexy_recordings: remove non-existent featured column (schema has default false)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 19:33:33 +01:00
ceb57ec1c4 fix: copy root node_modules to runner stage in backend Dockerfile
All checks were successful
Build and Push Backend Image / build (push) Successful in 30s
Build and Push Frontend Image / build (push) Successful in 15s
pnpm hoists workspace dependencies to the root node_modules.
Without copying it, modules like pg are not found at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 19:29:36 +01:00
4f8271217c fix: set CI=true for pnpm install in backend Dockerfile
All checks were successful
Build and Push Backend Image / build (push) Successful in 1m4s
Build and Push Frontend Image / build (push) Successful in 15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 19:26:25 +01:00
046689e363 fix: set CI=true for pnpm install -rP in frontend Dockerfile
Some checks failed
Build and Push Backend Image / build (push) Failing after 28s
Build and Push Frontend Image / build (push) Successful in 4m50s
pnpm requires CI=true to allow non-interactive removal of node_modules
in CI environments without a TTY.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 19:19:45 +01:00
9ba71239b7 fix: use correct 'file' parameter in docker/build-push-action (not 'dockerfile')
Some checks failed
Build and Push Frontend Image / build (push) Failing after 4m12s
Build and Push Backend Image / build (push) Failing after 25s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 19:09:58 +01:00
757bbe9e3b fix: skip buttplug Rust build in backend Dockerfile via --ignore-scripts
Some checks failed
Build and Push Backend Image / build (push) Has been cancelled
Build and Push Frontend Image / build (push) Has been cancelled
Copy all workspace package.json files so pnpm can resolve the lockfile,
but install with --ignore-scripts to prevent buttplug's Rust/WASM build
from running. Only explicitly rebuild argon2 native bindings.
Also restore the missing migrations COPY line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 19:08:48 +01:00
73f7a4f2f0 ci: replace combined workflow with separate frontend and backend workflow files
Some checks failed
Build and Push Frontend Image / build (push) Has been cancelled
Build and Push Backend Image / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 19:05:54 +01:00
3bd8d95576 ci: disable registry cache for backend build to fix poisoned buildcache
All checks were successful
Build and Push Docker Image to Gitea / build-frontend (push) Successful in 15s
Build and Push Docker Image to Gitea / build-backend (push) Successful in 4m44s
The backend buildcache was contaminated with frontend image layers, causing
the backend image to be built with the wrong content. Using no-cache forces
a fresh build until the cache can be reliably separated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 18:57:39 +01:00
14e816241d ci: split frontend and backend into separate jobs to fix image tag mix-up
All checks were successful
Build and Push Docker Image to Gitea / build-frontend (push) Successful in 17s
Build and Push Docker Image to Gitea / build-backend (push) Successful in 16s
Both builds in the same job shared the same docker buildx instance,
causing the backend image to be incorrectly tagged with the frontend image.
Separate jobs get isolated buildx instances and separate build caches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 18:53:16 +01:00
4102f9990c fix: switch backend to CommonJS, generate Drizzle migrations, add migrate script
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 4m23s
- Remove "type": "module" and switch tsconfig to CommonJS/Node resolution
  to fix drizzle-kit ESM/CJS incompatibility
- Strip .js extensions from all backend TypeScript imports
- Fix gamification resolver: combine two .where() calls using and()
- Fix index.ts: wrap top-level awaits in async main(), fix Fastify+yoga
  request handling via handleNodeRequestAndResponse
- Generate initial Drizzle SQL migration (0000_pale_hellion.sql) for all
  15 tables
- Add src/scripts/migrate.ts: programmatic Drizzle migrator for production
- Copy migrations folder into Docker image (Dockerfile.backend)
- Add schema:migrate npm script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 18:42:58 +01:00
2565e6c28b fix: resolve pnpm frozen-lockfile error and argon2 native build
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 5m19s
- Run pnpm install to update lockfile with packages/backend dependencies
- Add argon2 to root onlyBuiltDependencies (pnpm-workspace.yaml + package.json)
- Add explicit `pnpm rebuild argon2` in Dockerfile.backend to ensure native
  bindings compile regardless of pnpm v10 build approval state
- Remove pnpm.onlyBuiltDependencies from packages/backend/package.json
  (ineffective in workspace packages, warned by pnpm)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 18:19:52 +01:00
322 changed files with 24452 additions and 20763 deletions

View File

@@ -0,0 +1,68 @@
name: Build and Push Backend Image
on:
push:
branches:
- main
- develop
tags:
- "v*.*.*"
paths:
- "packages/backend/**"
- "packages/types/**"
- "Dockerfile.backend"
pull_request:
branches:
- main
paths:
- "packages/backend/**"
- "packages/types/**"
- "Dockerfile.backend"
workflow_dispatch:
env:
REGISTRY: dev.pivoine.art
IMAGE_NAME: valknar/sexy-backend
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.backend
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

@@ -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

@@ -0,0 +1,68 @@
name: Build and Push Frontend Image
on:
push:
branches:
- main
- develop
tags:
- "v*.*.*"
paths:
- "packages/frontend/**"
- "packages/types/**"
- "Dockerfile"
pull_request:
branches:
- main
paths:
- "packages/frontend/**"
- "packages/types/**"
- "Dockerfile"
workflow_dispatch:
env:
REGISTRY: dev.pivoine.art
IMAGE_NAME: valknar/sexy
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
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

@@ -1,159 +0,0 @@
name: Build and Push Docker Image to Gitea
on:
push:
branches:
- main
- develop
tags:
- 'v*.*.*'
pull_request:
branches:
- main
workflow_dispatch:
inputs:
tag:
description: 'Custom tag for the image'
required: false
default: 'manual'
env:
REGISTRY: dev.pivoine.art
IMAGE_NAME: valknar/sexy
BACKEND_IMAGE_NAME: valknar/sexy-backend
jobs:
build-and-push:
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 (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Tag as 'latest' for main branch
type=raw,value=latest,enable={{is_default_branch}}
# Tag with branch name
type=ref,event=branch
# Tag with PR number
type=ref,event=pr
# Tag with git tag (semver)
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# Tag with commit SHA
type=sha,prefix={{branch}}-
# Custom tag from workflow_dispatch
type=raw,value=${{ gitea.event.inputs.tag }},enable=${{ gitea.event_name == 'workflow_dispatch' }}
labels: |
org.opencontainers.image.title=sexy.pivoine.art
org.opencontainers.image.description=Adult content platform with SvelteKit, Directus, and hardware integration
org.opencontainers.image.vendor=valknar
org.opencontainers.image.source=https://dev.pivoine.art/${{ gitea.repository }}
- name: Build and push frontend Docker image
uses: docker/build-push-action@v5
with:
context: .
dockerfile: Dockerfile
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
build-args: |
NODE_ENV=production
CI=true
- name: Extract metadata for backend image
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-
type=raw,value=${{ gitea.event.inputs.tag }},enable=${{ gitea.event_name == 'workflow_dispatch' }}
labels: |
org.opencontainers.image.title=sexy.pivoine.art backend
org.opencontainers.image.description=GraphQL backend for sexy.pivoine.art (Fastify + Drizzle + Pothos)
org.opencontainers.image.vendor=valknar
org.opencontainers.image.source=https://dev.pivoine.art/${{ gitea.repository }}
- name: Build and push backend Docker image
uses: docker/build-push-action@v5
with:
context: .
dockerfile: Dockerfile.backend
platforms: linux/amd64
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache,mode=max
build-args: |
NODE_ENV=production
CI=true
- name: Generate image digest
if: gitea.event_name != 'pull_request'
run: |
echo "### Docker Images Published :rocket:" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "**Registry:** \`${{ env.REGISTRY }}\`" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "**Frontend (\`${{ env.IMAGE_NAME }}\`):**" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "**Backend (\`${{ env.BACKEND_IMAGE_NAME }}\`):**" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
echo "${{ steps.meta-backend.outputs.tags }}" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "**Pull commands:**" >> $GITEA_STEP_SUMMARY
echo "\`\`\`bash" >> $GITEA_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITEA_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:latest" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
- name: PR Comment - Images built but not pushed
if: gitea.event_name == 'pull_request'
run: |
echo "### Docker Images Built Successfully :white_check_mark:" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "Images were built successfully but **not pushed** (PR builds are not published)." >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "**Frontend would be tagged as:**" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "**Backend would be tagged as:**" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
echo "${{ steps.meta-backend.outputs.tags }}" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ target/
pkg/
.claude/
.data/

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
build/
.svelte-kit/
dist/
node_modules/
migrations/
pnpm-lock.yaml

241
CLAUDE.md
View File

@@ -2,176 +2,93 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
## Overview
This is a monorepo for an adult content platform built with SvelteKit, Directus CMS, and hardware integration via Buttplug.io. The project uses pnpm workspaces with three main packages.
## Prerequisites
1. Install Node.js 20.19.1
2. Enable corepack: `corepack enable`
3. Install dependencies: `pnpm install`
4. Install Rust toolchain and wasm-bindgen: `cargo install wasm-bindgen-cli`
## Project Structure
### Packages
- **`packages/frontend`**: SvelteKit application (main frontend)
- **`packages/bundle`**: Directus extension bundle (custom endpoints, hooks, themes)
- **`packages/buttplug`**: Hardware control library with TypeScript/WebAssembly bindings
### Frontend (SvelteKit + Tailwind CSS 4)
- **Framework**: SvelteKit 2 with adapter-node
- **Styling**: Tailwind CSS v4 via @tailwindcss/vite
- **UI Components**: bits-ui, custom components in `src/lib/components/ui/`
- **Backend**: Directus headless CMS
- **Routes**: File-based routing in `src/routes/`
- `+page.server.ts`: Server-side data loading
- `+layout.server.ts`: Layout data (authentication, etc.)
- **Authentication**: Session-based via Directus SDK (cookies)
- **API Proxy**: Dev server proxies `/api` to `http://localhost:8055` (Directus)
- **i18n**: svelte-i18n for internationalization
Key files:
- `src/lib/directus.ts`: Directus client configuration
- `src/lib/types.ts`: Shared TypeScript types
- `src/hooks.server.ts`: Server-side auth middleware
- `vite.config.ts`: Dev server on port 3000 with API proxy
### Bundle (Directus Extensions)
Custom Directus extensions providing:
- **Endpoint** (`src/endpoint/index.ts`): `/sexy/stats` endpoint for platform statistics
- **Hook** (`src/hook/index.ts`):
- Auto-generates slugs for users based on artist_name
- Processes uploaded videos with ffmpeg to extract duration
- **Theme** (`src/theme/index.ts`): Custom Directus admin theme
### Buttplug (Hardware Control)
Hybrid TypeScript/Rust package for intimate hardware control:
- **TypeScript**: Client library, connectors (WebSocket, Browser WebSocket)
- **Rust/WASM**: Core buttplug implementation compiled to WebAssembly
- Provides browser-based Bluetooth device control via WebBluetooth API
Key concepts:
- `ButtplugClient`: Main client interface
- `ButtplugClientDevice`: Device abstraction
- `ButtplugWasmClientConnector`: WASM-based connector
- Messages defined in `src/core/Messages.ts`
`sexy.pivoine.art` is a self-hosted adult content platform (18+) built as a pnpm monorepo with three packages: `frontend` (SvelteKit 5), `backend` (Fastify + GraphQL), and `buttplug` (hardware integration via WebBluetooth/WASM).
## Common Commands
### Development
Run from the repo root unless otherwise noted.
Start full development environment (data + Directus + frontend):
```bash
pnpm dev
# Development
pnpm dev:data # Start postgres & redis via Docker
pnpm dev:backend # Start backend on http://localhost:4000
pnpm dev # Start backend + frontend (frontend on :3000)
# Linting & Formatting
pnpm lint # ESLint across all packages
pnpm lint:fix # Auto-fix ESLint issues
pnpm format # Prettier format all files
pnpm format:check # Check formatting without changes
# Build
pnpm build:frontend # SvelteKit production build
pnpm build:backend # Compile backend TypeScript to dist/
# Database migrations (from packages/backend/)
pnpm migrate # Run pending Drizzle migrations
```
Individual services:
## Architecture
### Monorepo Layout
```
packages/
frontend/ # SvelteKit 2 + Svelte 5 + Tailwind CSS 4
backend/ # Fastify v5 + GraphQL Yoga v5 + Drizzle ORM
buttplug/ # TypeScript/Rust hybrid, compiles to WASM
```
### Backend (`packages/backend/src/`)
- **`index.ts`** — Fastify server entry: registers plugins (CORS, multipart, static), mounts GraphQL at `/graphql`, serves transformed assets at `/assets/:id`
- **`graphql/builder.ts`** — Pothos schema builder (code-first GraphQL)
- **`graphql/context.ts`** — Injects `currentUser` from Redis session into every request
- **`lib/auth.ts`** — Session management: `nanoid(32)` token stored in Redis with 24h TTL, set as httpOnly cookie
- **`db/schema/`** — Drizzle ORM table definitions (users, videos, files, comments, gamification, etc.)
- **`migrations/`** — SQL migration files managed by Drizzle Kit
### Frontend (`packages/frontend/src/`)
- **`lib/api.ts`** — GraphQL client (graphql-request)
- **`lib/services.ts`** — All API calls (login, videos, comments, models, etc.)
- **`lib/types.ts`** — Shared TypeScript types
- **`hooks.server.ts`** — Auth guard: reads session cookie, fetches `me` query, redirects if needed
- **`routes/`** — SvelteKit file-based routing: `/`, `/login`, `/signup`, `/me`, `/models`, `/models/[slug]`, `/videos`, `/play/[slug]`, `/magazine`, `/leaderboard`
### Asset Pipeline
Backend serves images with server-side Sharp transforms, cached to disk as WebP. Presets: `mini` (80×80), `thumbnail` (300×300), `preview` (800px wide), `medium` (1400px wide), `banner` (1600×480 cropped).
### Gamification
Points + achievements system tracked in `user_points` and `user_stats` tables. Logic in `packages/backend/src/lib/gamification.ts` and the `gamification` resolver.
## Code Style
- **TypeScript strict mode** in all packages
- **ESLint flat config** (`eslint.config.js` at root) — `any` is allowed but discouraged; enforces consistent type imports
- **Prettier**: 2-space indent, trailing commas, 100-char line width, Svelte plugin
- Migrations folder (`packages/backend/src/migrations/`) is excluded from lint
## Environment Variables (Backend)
| Variable | Purpose |
| --------------------------- | ---------------------------- |
| `DATABASE_URL` | PostgreSQL connection string |
| `REDIS_URL` | Redis connection string |
| `COOKIE_SECRET` | Session cookie signing |
| `CORS_ORIGIN` | Frontend origin URL |
| `UPLOAD_DIR` | File storage path |
| `SMTP_HOST/PORT/EMAIL_FROM` | Email (Nodemailer) |
## Docker
```bash
pnpm dev:data # Start Docker Compose data services
pnpm dev:directus # Start Directus in Docker
pnpm --filter @sexy.pivoine.art/frontend dev # Frontend dev server only
docker compose up -d # Start all services (postgres, redis, backend, frontend)
arty up -d <service> # Preferred way to manage containers in this project
```
### Building
Build all packages:
```bash
pnpm install # Ensure dependencies are installed first
```
Build specific packages:
```bash
pnpm build:frontend # Pulls git, installs, builds frontend
pnpm build:bundle # Pulls git, installs, builds Directus extensions
```
Individual package builds:
```bash
pnpm --filter @sexy.pivoine.art/frontend build
pnpm --filter @sexy.pivoine.art/bundle build
pnpm --filter @sexy.pivoine.art/buttplug build # TypeScript build
pnpm --filter @sexy.pivoine.art/buttplug build:wasm # Rust WASM build
```
### Production
Start production frontend server (local):
```bash
pnpm --filter @sexy.pivoine.art/frontend start
```
Docker Compose deployment (recommended for production):
```bash
# Local development (with Postgres, Redis, Directus)
docker-compose up -d
# Production (with Traefik, external DB, Redis)
docker-compose -f compose.production.yml --env-file .env.production up -d
```
See `COMPOSE.md` for Docker Compose guide and `DOCKER.md` for standalone Docker deployment.
## Architecture Notes
### Data Flow
1. **Frontend**`/api/*` (proxied) → **Directus CMS**
2. Directus uses **bundle extensions** for custom logic (stats, video processing, user management)
3. Frontend uses **Directus SDK** with session authentication
4. Hardware control uses **buttplug package** (TypeScript → WASM → Bluetooth)
### Authentication
- Session tokens stored in `directus_session_token` cookie
- `hooks.server.ts` validates token on every request via `isAuthenticated()`
- User roles: Model, Viewer (checked via role or policy)
- `isModel()` helper in `src/lib/directus.ts` checks user permissions
### Content Types
Core types in `packages/frontend/src/lib/types.ts`:
- **User/CurrentUser**: User profiles with roles and policies
- **Video**: Videos with models, tags, premium flag
- **Model**: Creator profiles with photos and banner
- **Article**: Magazine/blog content
- **BluetoothDevice**: Hardware device state
### Docker Environment
Development uses Docker Compose in `../compose/` directory:
- `../compose/data`: Database/storage services
- `../compose/sexy`: Directus instance (uses `.env.local`)
### Asset URLs
Assets served via Directus with transforms:
```typescript
getAssetUrl(id, "thumbnail" | "preview" | "medium" | "banner")
// Returns: ${directusApiUrl}/assets/${id}?transform=...
```
## Development Workflow
1. Ensure Docker services are running: `pnpm dev:data && pnpm dev:directus`
2. Start frontend dev server: `pnpm --filter @sexy.pivoine.art/frontend dev`
3. Access frontend at `http://localhost:3000`
4. Access Directus admin at `http://localhost:8055`
When modifying:
- **Frontend code**: Hot reload via Vite
- **Bundle extensions**: Rebuild with `pnpm --filter @sexy.pivoine.art/bundle build` and restart Directus
- **Buttplug library**: Rebuild TypeScript (`pnpm build`) and/or WASM (`pnpm build:wasm`)
## Important Notes
- This is a pnpm workspace; always use `pnpm` not `npm` or `yarn`
- Package manager is locked to `pnpm@10.17.0`
- Buttplug package requires Rust toolchain for WASM builds
- Frontend uses SvelteKit's adapter-node for production deployment
- All TypeScript packages use ES modules (`"type": "module"`)
Production images are built and pushed to `dev.pivoine.art` via Gitea Actions on push to `main`.

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,57 +20,31 @@ 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
RUN pnpm install -rP
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,27 +3,38 @@
# ============================================================================
# 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
WORKDIR /app
# Copy all package manifests so pnpm can resolve the workspace lockfile,
# but use --ignore-scripts to skip buttplug's Rust/WASM build entirely.
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY packages/backend/package.json ./packages/backend/package.json
COPY packages/frontend/package.json ./packages/frontend/package.json
COPY packages/buttplug/package.json ./packages/buttplug/package.json
COPY packages/types/package.json ./packages/types/package.json
RUN pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend
RUN pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend --ignore-scripts
# Rebuild native bindings (argon2, sharp)
RUN pnpm rebuild argon2 sharp
COPY packages/types ./packages/types
COPY packages/backend ./packages/backend
RUN pnpm --filter @sexy.pivoine.art/backend build
RUN pnpm install -rP --filter @sexy.pivoine.art/backend
RUN CI=true pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend --prod --ignore-scripts
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 \
@@ -39,9 +50,12 @@ WORKDIR /home/node/app
RUN mkdir -p packages/backend
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
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/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

323
README.md
View File

@@ -4,7 +4,7 @@
![sexy lips tongue mouth american apparel moist lip gloss ](https://i.gifer.com/1pYe.gif)
*"Lust und Liebe gehören zusammen - wer das eine verteufelt, zerstört auch das andere."*
_"Lust und Liebe gehören zusammen - wer das eine verteufelt, zerstört auch das andere."_
**Beate Uhse**, Pionierin der sexuellen Befreiung ✈️
---
@@ -13,10 +13,10 @@
Built with passion, technology, and the fearless spirit of sexual empowerment
[![Build Status](https://img.shields.io/github/actions/workflow/status/valknarxxx/sexy.pivoine.art/docker-build-push.yml?style=for-the-badge&logo=docker&logoColor=white&color=FF69B4&labelColor=8B008B)](https://github.com/valknarxxx/sexy.pivoine.art/actions/workflows/docker-build-push.yml)
[![Security Scan](https://img.shields.io/github/actions/workflow/status/valknarxxx/sexy.pivoine.art/docker-scan.yml?style=for-the-badge&logo=security&logoColor=white&label=Security&color=DA70D6&labelColor=8B008B)](https://github.com/valknarxxx/sexy.pivoine.art/actions/workflows/docker-scan.yml)
[![Build Frontend](https://dev.pivoine.art/valknar/sexy/actions/workflows/docker-build-frontend.yml/badge.svg)](https://dev.pivoine.art/valknar/sexy/actions)
[![Build Backend](https://dev.pivoine.art/valknar/sexy/actions/workflows/docker-build-backend.yml/badge.svg)](https://dev.pivoine.art/valknar/sexy/actions)
[![License](https://img.shields.io/badge/License-For_Pleasure-FF1493?style=for-the-badge&logo=heart&logoColor=white&labelColor=8B008B)](LICENSE)
[![Made with Love](https://img.shields.io/badge/Made_with-💜_Love-FF69B4?style=for-the-badge&labelColor=8B008B)](http://sexy.pivoine.art)
[![Made with Love](https://img.shields.io/badge/Made_with-💜_Love-FF69B4?style=for-the-badge&labelColor=8B008B)](https://sexy.pivoine.art)
</div>
@@ -24,20 +24,23 @@ Built with passion, technology, and the fearless spirit of sexual empowerment
## 👅 What Is This Delicious Creation?
Welcome, dear pleasure-seeker! This is **sexy.pivoine.art** — a modern, sensual platform combining the elegance of **SvelteKit**, the power of **Directus CMS**, and the intimate connection of **Buttplug.io** hardware integration.
Welcome, dear pleasure-seeker! This is **sexy.pivoine.art** — a modern, sensual platform built from the ground up with full control over every intimate detail. A **SvelteKit** frontend caresses a purpose-built **Fastify + GraphQL** backend, while **Buttplug.io** hardware integration brings the experience into the physical world.
Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom to explore, create, and celebrate sexuality without shame. This platform is built for **models**, **creators**, and **connoisseurs** of adult content who deserve technology as sophisticated as their desires.
### ♉ Features That'll Make You Blush ♊
- 💖 **Sensual SvelteKit Frontend** with Tailwind CSS 4 styling
- 🗄️ **Headless CMS** powered by Directus for content liberation
- **Purpose-built GraphQL Backend** — lean, fast, no CMS overhead
- 🔐 **Session-based Auth** with Redis & Argon2 — discretion guaranteed
- 🖼️ **Smart Image Transforms** via Sharp (WebP, multiple presets, cached)
- 🎮 **Hardware Integration** via Buttplug.io (yes, really!)
- 🌐 **Multi-Platform Support** (AMD64 + ARM64) — pleasure everywhere
- 🔒 **Session-Based Authentication** — discretion guaranteed
- 📱 **Responsive Design** that looks sexy on any device
- 🌍 **Internationalization** — pleasure speaks all languages
- 🏆 **Gamification** — achievements, leaderboards, and reward points
- 💬 **Comments & Social** — build your community
- 📊 **Analytics Integration** (Umami) — know your admirers
- 🐳 **Self-hosted CI/CD** via Gitea Actions on `dev.pivoine.art`
<div align="center">
@@ -48,15 +51,21 @@ Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom
```
┌─────────────────────────────────────────────────────────────┐
│ 💋 Frontend Layer │
│ ├─ SvelteKit 2.0 → Smooth as silk │
│ ├─ SvelteKit 2 → Smooth as silk │
│ ├─ Tailwind CSS 4 → Styled to seduce │
│ ├─ bits-ui Components → Building blocks of pleasure │
│ ├─ graphql-request v7 → Whispering to the backend │
│ └─ Vite → Fast and furious │
├─────────────────────────────────────────────────────────────┤
│ 🍷 Backend Layer │
│ ├─ Directus CMS → Content with no limits
│ ├─ Custom Extensions → Bespoke pleasures
─ PostgreSQL → Data deep and secure
│ ├─ Fastify v5 → The fastest penetration
│ ├─ GraphQL Yoga v5 → Flexible positions
─ Pothos (code-first) → Schema with intention
│ ├─ Drizzle ORM → Data with grace │
│ ├─ PostgreSQL 16 → Deep and persistent │
│ ├─ Redis → Sessions that never forget │
│ ├─ Sharp → Images transformed beautifully │
│ └─ Argon2 → Passwords hashed with passion │
├─────────────────────────────────────────────────────────────┤
│ 🎀 Hardware Layer │
│ ├─ Buttplug.io → Real connections │
@@ -65,8 +74,8 @@ Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom
├─────────────────────────────────────────────────────────────┤
│ 🌸 DevOps Layer │
│ ├─ Docker → Containerized ecstasy │
│ ├─ GitHub Actions → Automated seduction
│ └─ GHCR → Images served hot
│ ├─ Gitea Actions Self-hosted seduction │
│ └─ dev.pivoine.art → Our own pleasure palace
└─────────────────────────────────────────────────────────────┘
```
@@ -74,46 +83,49 @@ Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom
## 🔥 Quick Start — Get Intimate Fast
### 💕 Option 1: Using Docker (Recommended)
### 💕 Option 1: Using Docker Compose (Recommended)
```bash
# Pull the pleasure
docker pull ghcr.io/valknarxxx/sexy:latest
# Clone the repository
git clone https://dev.pivoine.art/valknar/sexy.git
cd sexy.pivoine.art
# Run with passion
docker run -d -p 3000:3000 \
-e PUBLIC_API_URL=https://api.your-domain.com \
-e PUBLIC_URL=https://your-domain.com \
ghcr.io/valknarxxx/sexy:latest
# Configure your secrets
cp .env.example .env
# Edit .env with your intimate details
# Awaken all services (postgres, redis, backend, frontend)
docker compose up -d
# Visit your creation at http://localhost:3000 💋
```
See [QUICKSTART.md](QUICKSTART.md) for the full seduction guide.
### 💜 Option 2: Local Development
**Prerequisites:**
1. Node.js 20.19.1 — *the foundation*
2. `corepack enable`*unlock the tools*
3. `pnpm install`*gather your ingredients*
4. Rust + `cargo install wasm-bindgen-cli`*forge the connection*
1. Node.js 20.19.1 — _the foundation_
2. `corepack enable`_unlock the tools_
3. `pnpm install`_gather your ingredients_
4. PostgreSQL 16 + Redis_the data lovers_
**Start your pleasure journey:**
```bash
# Awaken all services
pnpm dev
# Awaken data services
pnpm dev:data
# Or tease them one by one
pnpm dev:data # The foundation
pnpm dev:directus # The content
pnpm --filter @sexy.pivoine.art/frontend dev # The face
# Start the backend (port 4000)
pnpm dev:backend
# Start the frontend (port 3000, proxied to :4000)
pnpm --filter @sexy.pivoine.art/frontend dev
```
Visit `http://localhost:3000` and let the experience begin... 💋
GraphQL playground is available at `http://localhost:4000/graphql` — explore every query.
---
## 🌹 Project Structure
@@ -123,98 +135,116 @@ This monorepo contains three packages, each serving its purpose:
```
sexy.pivoine.art/
├─ 💄 packages/frontend/ → SvelteKit app (the seduction)
├─ 🎭 packages/bundle/ Directus extensions (the power)
├─ packages/backend/Fastify + GraphQL API (the engine)
└─ 🎮 packages/buttplug/ → Hardware control (the connection)
```
---
### 💄 Frontend (`packages/frontend/`)
## 📚 Documentation — Your Guide to Pleasure
SvelteKit 2 application with server-side rendering, i18n, and a clean component library.
Communicates with the backend exclusively via GraphQL using `graphql-request`.
Assets served via `/api/assets/:id?transform=<preset>` — no CDN, no Directus, just raw power.
<div align="center">
### ⚡ Backend (`packages/backend/`)
| Document | Purpose | Emoji |
|----------|---------|-------|
| [QUICKSTART.md](QUICKSTART.md) | Get wet... I mean, get started! | 💦 |
| [COMPOSE.md](COMPOSE.md) | Docker Compose setup guide | 🐳 |
| [DOCKER.md](DOCKER.md) | Standalone Docker deployment | 🐋 |
| [CLAUDE.md](CLAUDE.md) | Architecture & development | 🤖 |
| [.github/workflows/README.md](.github/workflows/README.md) | CI/CD workflows | ⚙️ |
Purpose-built Fastify v5 + GraphQL Yoga server. All business logic lives here:
auth, file uploads, video processing, comments, gamification, and analytics.
Files stored as `<UPLOAD_DIR>/<uuid>/<filename>` with on-demand WebP transforms cached on disk.
</div>
### 🎮 Buttplug (`packages/buttplug/`)
Hybrid TypeScript/Rust package for intimate hardware control via WebBluetooth.
Compiled to WebAssembly for browser-based Bluetooth device communication.
---
## 🎨 Building — Craft Your Masterpiece
## 🗃️ Database Schema
### Build All Packages
Built with Drizzle ORM — clean tables, no `directus_` prefix, full control:
```bash
# Prepare everything
pnpm install
# Build the WASM foundation
pnpm --filter @sexy.pivoine.art/buttplug build:wasm
# Build the packages
pnpm --filter @sexy.pivoine.art/buttplug build
pnpm --filter @sexy.pivoine.art/frontend build
pnpm --filter @sexy.pivoine.art/bundle build
```
### Build Docker Image
```bash
# Quick build
./build.sh
# Manual control
docker build -t sexy.pivoine.art:latest .
# Multi-platform pleasure
docker buildx build --platform linux/amd64,linux/arm64 -t sexy.pivoine.art:latest .
users → profiles, roles (model/viewer/admin), auth tokens
files → uploaded assets with metadata and duration
videos → content with model junctions, likes, plays
articles → magazine / editorial content
recordings → user-created content with play tracking
comments → threaded by collection + item_id
achievements → gamification goals
user_points → points ledger
user_stats → cached leaderboard data
```
---
## 🚀 Deployment — Share Your Creation
## 🔐 Authentication Flow
```
POST /graphql (login mutation)
→ verify argon2 password hash
→ nanoid(32) session token
→ SET session:<token> <user JSON> EX 86400 in Redis
→ set httpOnly cookie: session_token
→ return CurrentUser
Every request:
→ read session_token cookie
→ GET session:<token> from Redis
→ inject currentUser into GraphQL context
```
---
## 🖼️ Image Transforms
Assets are transformed on first request and cached as WebP:
| Preset | Size | Fit | Use |
| ----------- | ----------- | ------ | ---------------- |
| `mini` | 80×80 | cover | Avatars in lists |
| `thumbnail` | 300×300 | cover | Profile photos |
| `preview` | 800px wide | inside | Video teasers |
| `medium` | 1400px wide | inside | Full-size images |
| `banner` | 1600×480 | cover | Profile banners |
---
## 🚀 Deployment
### Production with Docker Compose
```bash
# Configure your secrets
cp .env.production.example .env.production
# Edit .env.production with your intimate details
cp .env.example .env.production
# Edit .env.production — set DB credentials, SMTP, cookie secret, CORS origin
# Deploy with grace (uses Traefik for routing)
docker-compose -f compose.production.yml --env-file .env.production up -d
# Deploy
docker compose --env-file .env.production up -d
```
### Production without Docker
Key environment variables for the backend:
```bash
# Build everything
pnpm build:frontend
# Start serving
pnpm --filter @sexy.pivoine.art/frontend start
```env
DATABASE_URL=postgresql://sexy:sexy@postgres:5432/sexy
REDIS_URL=redis://redis:6379
COOKIE_SECRET=your-very-secret-key
CORS_ORIGIN=https://sexy.pivoine.art
UPLOAD_DIR=/data/uploads
SMTP_HOST=your.smtp.host
SMTP_PORT=587
EMAIL_FROM=noreply@sexy.pivoine.art
PUBLIC_URL=https://sexy.pivoine.art
```
---
### 🎬 CI/CD — Self-Hosted Seduction
## 🌈 Environment Variables
Automated builds run on **[dev.pivoine.art](https://dev.pivoine.art/valknar/sexy)** via Gitea Actions:
### 💖 Required (The Essentials)
- ✅ Frontend image → `dev.pivoine.art/valknar/sexy:latest`
- ✅ Backend image → `dev.pivoine.art/valknar/sexy-backend:latest`
- ✅ Triggers on push to `main`, `develop`, or version tags (`v*.*.*`)
- ✅ Build cache via registry for fast successive builds
- `PUBLIC_API_URL` — Your Directus backend
- `PUBLIC_URL` — Your frontend domain
### 💜 Optional (The Extras)
- `PUBLIC_UMAMI_ID` — Analytics tracking ID
- `PUBLIC_UMAMI_SCRIPT` — Umami script URL
See [.env.production.example](.env.production.example) for the full configuration.
Images are pulled on the production server via Watchtower or manual `docker compose pull && docker compose up -d`.
---
@@ -225,60 +255,54 @@ graph LR
A[💡 Idea] --> B[💻 Code]
B --> C[🧪 Test Locally]
C --> D[🌿 Feature Branch]
D --> E[📤 Push & PR]
E --> F{✅ CI Pass?}
D --> E[📤 Push to dev.pivoine.art]
E --> F{✅ Build Pass?}
F -->|Yes| G[🔀 Merge to Main]
F -->|No| B
G --> H[🚀 Auto Deploy]
H --> I[🏷️ Tag Release]
I --> J[🎉 Celebrate]
G --> H[🚀 Images Built & Pushed]
H --> I[🎉 Deploy to Production]
```
1. Create → `git checkout -b feature/my-sexy-feature`
2. Develop → Write beautiful code
3. Test → `pnpm dev`
4. Push → Create PR (triggers CI build)
5. Merge → Automatic deployment to production
3. Test → `pnpm dev:data && pnpm dev:backend && pnpm dev`
4. Push → `git push` to `dev.pivoine.art` (triggers CI build)
5. Merge → Images published, deploy to production
6. Release → `git tag v1.0.0 && git push origin v1.0.0`
---
## 🔐 Security — Protected Pleasure
## 🌈 Environment Variables
- 🛡️ Daily vulnerability scans with Trivy
- 🔒 Non-root Docker containers
- 📊 Security reports in GitHub Security tab
- 🤐 Confidential issue reporting available
### Backend (required)
*Report security concerns privately via GitHub Security.*
| Variable | Description |
| --------------- | ----------------------------- |
| `DATABASE_URL` | PostgreSQL connection string |
| `REDIS_URL` | Redis connection string |
| `COOKIE_SECRET` | Session cookie signing secret |
| `CORS_ORIGIN` | Allowed frontend origin |
| `UPLOAD_DIR` | Path for uploaded files |
---
### Backend (optional)
## 💝 Contributing — Join the Movement
| Variable | Default | Description |
| ------------ | ------- | ------------------------------ |
| `PORT` | `4000` | Backend listen port |
| `LOG_LEVEL` | `info` | Fastify log level |
| `SMTP_HOST` | — | Email server for auth flows |
| `SMTP_PORT` | `587` | Email server port |
| `EMAIL_FROM` | — | Sender address |
| `PUBLIC_URL` | — | Frontend URL (for email links) |
Like Beate Uhse fought for sexual liberation, we welcome contributors who believe in freedom, pleasure, and quality code.
### Frontend
1. **Fork** this repository
2. **Create** your feature branch
3. **Commit** your changes
4. **Push** to your branch
5. **Submit** a pull request
All contributors are bound by our code of conduct: **Respect, Consent, and Quality.**
---
## 🎯 CI/CD Pipeline — Automated Seduction
Our GitHub Actions workflows handle:
- ✅ Multi-platform Docker builds (AMD64 + ARM64)
- ✅ Automated publishing to GHCR
- ✅ Daily security vulnerability scans
- ✅ Weekly cleanup of old images
- ✅ Semantic versioning from git tags
**Images available at:** `ghcr.io/valknarxxx/sexy`
| Variable | Description |
| --------------------- | --------------------------------------------- |
| `PUBLIC_API_URL` | Backend URL (e.g. `http://sexy_backend:4000`) |
| `PUBLIC_URL` | Frontend public URL |
| `PUBLIC_UMAMI_ID` | Umami analytics site ID (optional) |
| `PUBLIC_UMAMI_SCRIPT` | Umami script URL (optional) |
---
@@ -288,20 +312,25 @@ Our GitHub Actions workflows handle:
### 🌸 Created with Love by 🌸
**[Palina](http://sexy.pivoine.art) & [Valknar](http://sexy.pivoine.art)**
**[Palina](https://sexy.pivoine.art) & [Valknar](https://sexy.pivoine.art)**
*Für die Mäuse...* 🐭💕
_Für die Mäuse..._ 🐭💕
---
### 🙏 Built With
| Technology | Purpose |
|------------|---------|
| [SvelteKit](https://kit.svelte.dev/) | Framework |
| [Directus](https://directus.io/) | CMS |
| [Buttplug.io](https://buttplug.io/) | Hardware |
| [bits-ui](https://www.bits-ui.com/) | Components |
| Technology | Purpose |
| --------------------------------------------------------- | -------------------- |
| [SvelteKit](https://kit.svelte.dev/) | Frontend framework |
| [Fastify](https://fastify.dev/) | HTTP server |
| [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server) | GraphQL server |
| [Pothos](https://pothos-graphql.dev/) | Code-first schema |
| [Drizzle ORM](https://orm.drizzle.team/) | Database |
| [Sharp](https://sharp.pixelplumbing.com/) | Image transforms |
| [Buttplug.io](https://buttplug.io/) | Hardware |
| [bits-ui](https://www.bits-ui.com/) | UI components |
| [Gitea](https://dev.pivoine.art) | Self-hosted VCS & CI |
---
@@ -310,7 +339,7 @@ Our GitHub Actions workflows handle:
Pioneer of sexual liberation (1919-2001)
Pilot, Entrepreneur, Freedom Fighter
*"Eine Frau, die ihre Sexualität selbstbestimmt lebt, ist eine freie Frau."*
_"Eine Frau, die ihre Sexualität selbstbestimmt lebt, ist eine freie Frau."_
![Beate Uhse Quote](https://img.shields.io/badge/Beate_Uhse-Sexual_Liberation_Pioneer-FF1493?style=for-the-badge&logo=heart&logoColor=white&labelColor=8B008B)
@@ -331,9 +360,9 @@ Pilot, Entrepreneur, Freedom Fighter
<div align="center">
[![Issues](https://img.shields.io/badge/🐛_Issues-Report_Here-FF69B4?style=for-the-badge&labelColor=8B008B)](https://github.com/valknarxxx/sexy.pivoine.art/issues)
[![Discussions](https://img.shields.io/badge/💭_Discussions-Join_Here-DA70D6?style=for-the-badge&labelColor=8B008B)](https://github.com/valknarxxx/sexy.pivoine.art/discussions)
[![Website](https://img.shields.io/badge/🌐_Website-Visit_Here-FF1493?style=for-the-badge&labelColor=8B008B)](http://sexy.pivoine.art)
[![Repository](https://img.shields.io/badge/🐙_Repository-dev.pivoine.art-FF69B4?style=for-the-badge&labelColor=8B008B)](https://dev.pivoine.art/valknar/sexy)
[![Issues](https://img.shields.io/badge/🐛_Issues-Report_Here-DA70D6?style=for-the-badge&labelColor=8B008B)](https://dev.pivoine.art/valknar/sexy/issues)
[![Website](https://img.shields.io/badge/🌐_Website-Visit_Here-FF1493?style=for-the-badge&labelColor=8B008B)](https://sexy.pivoine.art)
</div>
@@ -352,8 +381,8 @@ Pilot, Entrepreneur, Freedom Fighter
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝
</pre>
*Pleasure is a human right. Technology is freedom. Together, they are power.*
_Pleasure is a human right. Technology is freedom. Together, they are power._
**[sexy.pivoine.art](http://sexy.pivoine.art)** | © 2025 Palina & Valknar
**[sexy.pivoine.art](https://sexy.pivoine.art)** | © 2025 Palina & Valknar
</div>

View File

@@ -4,6 +4,8 @@ services:
image: postgres:16-alpine
container_name: sexy_postgres
restart: unless-stopped
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
@@ -19,6 +21,8 @@ services:
image: redis:7-alpine
container_name: sexy_redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
@@ -60,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: .
@@ -74,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:

File diff suppressed because it is too large Load Diff

57
eslint.config.js Normal file
View File

@@ -0,0 +1,57 @@
import js from "@eslint/js";
import ts from "typescript-eslint";
import svelte from "eslint-plugin-svelte";
import prettier from "eslint-config-prettier";
import globals from "globals";
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs["flat/recommended"],
prettier,
...svelte.configs["flat/prettier"],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
{
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
parser: ts.parser,
},
},
},
{
rules: {
// Allow unused vars prefixed with _ (common pattern for intentional ignores)
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
// Enforce consistent type imports
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
],
// This rule is meant for onNavigate() callbacks only; standard SvelteKit href/goto is fine
"svelte/no-navigation-without-resolve": "off",
// {@html} is used intentionally for trusted content (e.g. legal page)
"svelte/no-at-html-tags": "warn",
},
},
{
ignores: [
"**/build/",
"**/.svelte-kit/",
"**/dist/",
"**/node_modules/",
"**/migrations/",
"**/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

@@ -1,33 +1,50 @@
{
"name": "sexy.pivoine.art",
"version": "1.0.0",
"description": "",
"main": "index.js",
"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",
"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"
},
"keywords": [],
"author": {
"name": "Valknar",
"email": "valknar@pivoine.art"
},
"license": "MIT",
"packageManager": "pnpm@10.19.0",
"pnpm": {
"onlyBuiltDependencies": [
"es5-ext",
"esbuild",
"svelte-preprocess",
"wasm-pack"
],
"ignoredBuiltDependencies": [
"@tailwindcss/oxide",
"node-sass"
]
}
"name": "sexy.pivoine.art",
"version": "1.0.0",
"description": "",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"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 dev:buttplug & pnpm --filter @sexy.pivoine.art/frontend dev",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "pnpm -r --filter=!sexy.pivoine.art check"
},
"keywords": [],
"author": {
"name": "Valknar",
"email": "valknar@pivoine.art"
},
"license": "MIT",
"packageManager": "pnpm@10.31.0",
"pnpm": {
"onlyBuiltDependencies": [
"argon2",
"es5-ext",
"esbuild",
"svelte-preprocess",
"wasm-pack"
],
"ignoredBuiltDependencies": [
"@tailwindcss/oxide",
"node-sass"
]
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.15.0",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.1",
"typescript-eslint": "^8.56.1"
}
}

View File

@@ -1,7 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema/index.ts",
schema: "./src/db/schema/*.ts",
out: "./src/migrations",
dialect: "postgresql",
dbCredentials: {

View File

@@ -1,16 +1,17 @@
{
"name": "@sexy.pivoine.art/backend",
"version": "1.0.0",
"type": "module",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"dev": "UPLOAD_DIR=../../.data/uploads DATABASE_URL=postgresql://sexy:sexy@localhost:5432/sexy REDIS_URL=redis://localhost:6379 tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"migrate": "tsx src/scripts/data-migration.ts"
"schema:migrate": "tsx src/scripts/migrate.ts",
"migrate": "tsx src/scripts/data-migration.ts",
"check": "tsc --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
@@ -19,7 +20,9 @@
"@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",
@@ -28,21 +31,18 @@
"graphql-ws": "^6.0.4",
"graphql-yoga": "^5.13.4",
"ioredis": "^5.6.1",
"nanoid": "^5.1.5",
"nanoid": "^3.3.11",
"nodemailer": "^7.0.3",
"pg": "^8.16.0",
"sharp": "^0.33.5",
"slugify": "^1.6.6",
"uuid": "^11.1.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"argon2"
]
},
"devDependencies": {
"@types/fluent-ffmpeg": "^2.1.27",
"@types/nodemailer": "^6.4.17",
"@types/pg": "^8.15.4",
"@types/sharp": "^0.32.0",
"@types/uuid": "^10.0.0",
"drizzle-kit": "^0.31.1",
"tsx": "^4.19.4",

View File

@@ -1,6 +1,6 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema/index.js";
import * as schema from "./schema/index";
const pool = new Pool({
connectionString: process.env.DATABASE_URL || "postgresql://sexy:sexy@localhost:5432/sexy",

View File

@@ -1,18 +1,13 @@
import {
pgTable,
text,
timestamp,
boolean,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { users } from "./users.js";
import { files } from "./files.js";
import { pgTable, text, timestamp, boolean, index, uniqueIndex } from "drizzle-orm/pg-core";
import { users } from "./users";
import { files } from "./files";
export const articles = pgTable(
"articles",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
slug: text("slug").notNull(),
title: text("title").notNull(),
excerpt: text("excerpt"),

View File

@@ -1,11 +1,5 @@
import {
pgTable,
text,
timestamp,
index,
integer,
} from "drizzle-orm/pg-core";
import { users } from "./users.js";
import { pgTable, text, timestamp, index, integer } from "drizzle-orm/pg-core";
import { users } from "./users";
export const comments = pgTable(
"comments",

View File

@@ -1,16 +1,11 @@
import {
pgTable,
text,
timestamp,
bigint,
integer,
index,
} from "drizzle-orm/pg-core";
import { pgTable, text, timestamp, bigint, integer, index } from "drizzle-orm/pg-core";
export const files = pgTable(
"files",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
title: text("title"),
description: text("description"),
filename: text("filename").notNull(),

View File

@@ -8,18 +8,18 @@ import {
pgEnum,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { users } from "./users.js";
import { recordings } from "./recordings.js";
import { sql } from "drizzle-orm";
import { users } from "./users";
import { recordings } from "./recordings";
export const achievementStatusEnum = pgEnum("achievement_status", [
"draft",
"published",
]);
export const achievementStatusEnum = pgEnum("achievement_status", ["draft", "published"]);
export const achievements = pgTable(
"achievements",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
code: text("code").notNull(),
name: text("name").notNull(),
description: text("description"),
@@ -69,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

@@ -1,7 +1,7 @@
export * from "./files.js";
export * from "./users.js";
export * from "./videos.js";
export * from "./articles.js";
export * from "./recordings.js";
export * from "./comments.js";
export * from "./gamification.js";
export * from "./files";
export * from "./users";
export * from "./videos";
export * from "./articles";
export * from "./recordings";
export * from "./comments";
export * from "./gamification";

View File

@@ -9,19 +9,17 @@ import {
uniqueIndex,
jsonb,
} from "drizzle-orm/pg-core";
import { users } from "./users.js";
import { videos } from "./videos.js";
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",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
title: text("title").notNull(),
description: text("description"),
slug: text("slug").notNull(),
@@ -53,7 +51,9 @@ export const recordings = pgTable(
export const recording_plays = pgTable(
"recording_plays",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
recording_id: text("recording_id")
.notNull()
.references(() => recordings.id, { onDelete: "cascade" }),

View File

@@ -8,14 +8,16 @@ import {
uniqueIndex,
integer,
} from "drizzle-orm/pg-core";
import { files } from "./files.js";
import { files } from "./files";
export const roleEnum = pgEnum("user_role", ["model", "viewer", "admin"]);
export const users = pgTable(
"users",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
email: text("email").notNull(),
password_hash: text("password_hash").notNull(),
first_name: text("first_name"),
@@ -27,6 +29,8 @@ 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"),
password_reset_token: text("password_reset_token"),

View File

@@ -8,13 +8,15 @@ import {
uniqueIndex,
primaryKey,
} from "drizzle-orm/pg-core";
import { users } from "./users.js";
import { files } from "./files.js";
import { users } from "./users";
import { files } from "./files";
export const videos = pgTable(
"videos",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
slug: text("slug").notNull(),
title: text("title").notNull(),
description: text("description"),
@@ -50,7 +52,9 @@ export const video_models = pgTable(
export const video_likes = pgTable(
"video_likes",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
video_id: text("video_id")
.notNull()
.references(() => videos.id, { onDelete: "cascade" }),
@@ -68,7 +72,9 @@ export const video_likes = pgTable(
export const video_plays = pgTable(
"video_plays",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
video_id: text("video_id")
.notNull()
.references(() => videos.id, { onDelete: "cascade" }),

View File

@@ -1,7 +1,7 @@
import SchemaBuilder from "@pothos/core";
import ErrorsPlugin from "@pothos/plugin-errors";
import type { DB } from "../db/connection.js";
import type { SessionUser } from "../lib/auth.js";
import type { DB } from "../db/connection";
import type { SessionUser } from "../lib/auth";
import type Redis from "ioredis";
import { GraphQLDateTime, GraphQLJSON } from "graphql-scalars";

View File

@@ -1,10 +1,20 @@
import type { YogaInitialContext } from "graphql-yoga";
import type { Context } from "./builder.js";
import { getSession } from "../lib/auth.js";
import { db } from "../db/connection.js";
import { redis } from "../lib/auth.js";
import type { FastifyRequest, FastifyReply } from "fastify";
import type { Context } from "./builder";
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";
export async function buildContext(ctx: YogaInitialContext & { request: Request; reply: unknown; db: typeof db; redis: typeof redis }): Promise<Context> {
type ServerContext = {
req: FastifyRequest;
reply: FastifyReply;
db: typeof db;
redis: typeof redis;
};
export async function buildContext(ctx: YogaInitialContext & ServerContext): Promise<Context> {
const request = ctx.request;
const cookieHeader = request.headers.get("cookie") || "";
@@ -17,7 +27,34 @@ export async function buildContext(ctx: YogaInitialContext & { request: Request;
);
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 { builder } from "./builder.js";
import "./resolvers/queues.js";
import { builder } from "./builder";
export const schema = builder.toSchema();

View File

@@ -1,47 +1,75 @@
import { builder } from "../builder.js";
import { ArticleType } from "../types/index.js";
import { articles, users } from "../../db/schema/index.js";
import { eq, and, lte, desc } from "drizzle-orm";
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, type SQL } from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
import type { DB } from "../../db/connection";
async function enrichArticle(db: DB, article: typeof articles.$inferSelect) {
let author = null;
if (article.author) {
const authorUser = await db
.select({
id: users.id,
artist_name: users.artist_name,
slug: users.slug,
avatar: users.avatar,
description: users.description,
})
.from(users)
.where(eq(users.id, article.author))
.limit(1);
author = authorUser[0] || null;
}
return { ...article, author };
}
builder.queryField("articles", (t) =>
t.field({
type: [ArticleType],
type: ArticleListType,
args: {
featured: t.arg.boolean(),
limit: t.arg.int(),
search: t.arg.string(),
category: t.arg.string(),
offset: t.arg.int(),
sortBy: t.arg.string(),
tag: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
let query = ctx.db
.select()
.from(articles)
.where(lte(articles.publish_date, new Date()))
.orderBy(desc(articles.publish_date));
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
if (args.limit) {
query = (query as any).limit(args.limit);
const conditions: SQL<unknown>[] = [lte(articles.publish_date, new Date())];
if (args.featured !== null && args.featured !== undefined) {
conditions.push(eq(articles.featured, args.featured));
}
if (args.category) conditions.push(eq(articles.category, args.category));
if (args.tag) conditions.push(arrayContains(articles.tags, [args.tag]));
if (args.search) {
conditions.push(
or(
ilike(articles.title, `%${args.search}%`),
ilike(articles.excerpt, `%${args.search}%`),
) as SQL<unknown>,
);
}
const articleList = await query;
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));
return Promise.all(
articleList.map(async (article: any) => {
let author = null;
if (article.author) {
const authorUser = await ctx.db
.select({
first_name: users.first_name,
last_name: users.last_name,
avatar: users.avatar,
description: users.description,
})
.from(users)
.where(eq(users.id, article.author))
.limit(1);
author = authorUser[0] || null;
}
return { ...article, author };
}),
);
const [articleList, totalRows] = await Promise.all([
ordered.limit(pageSize).offset(offset),
ctx.db.select({ total: count() }).from(articles).where(where),
]);
const items = await Promise.all(articleList.map((article) => enrichArticle(ctx.db, article)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
);
@@ -61,23 +89,163 @@ builder.queryField("article", (t) =>
.limit(1);
if (!article[0]) return null;
let author = null;
if (article[0].author) {
const authorUser = await ctx.db
.select({
first_name: users.first_name,
last_name: users.last_name,
avatar: users.avatar,
description: users.description,
})
.from(users)
.where(eq(users.id, article[0].author))
.limit(1);
author = authorUser[0] || null;
}
return { ...article[0], author };
return enrichArticle(ctx.db, article[0]);
},
}),
);
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) =>
t.field({
type: AdminArticleListType,
args: {
search: t.arg.string(),
category: t.arg.string(),
featured: t.arg.boolean(),
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(
or(
ilike(articles.title, `%${args.search}%`),
ilike(articles.excerpt, `%${args.search}%`),
) as SQL<unknown>,
);
}
if (args.category) conditions.push(eq(articles.category, args.category));
if (args.featured !== null && args.featured !== undefined)
conditions.push(eq(articles.featured, args.featured));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [articleList, totalRows] = await Promise.all([
ctx.db
.select()
.from(articles)
.where(where)
.orderBy(desc(articles.publish_date))
.limit(limit)
.offset(offset),
ctx.db.select({ total: count() }).from(articles).where(where),
]);
const items = await Promise.all(articleList.map((article) => enrichArticle(ctx.db, article)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
);
builder.mutationField("createArticle", (t) =>
t.field({
type: ArticleType,
args: {
title: t.arg.string({ required: true }),
slug: t.arg.string({ required: true }),
excerpt: t.arg.string(),
content: t.arg.string(),
imageId: t.arg.string(),
tags: t.arg.stringList(),
category: t.arg.string(),
featured: t.arg.boolean(),
publishDate: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const inserted = await ctx.db
.insert(articles)
.values({
title: args.title,
slug: args.slug,
excerpt: args.excerpt || null,
content: args.content || null,
image: args.imageId || null,
tags: args.tags || [],
category: args.category || null,
featured: args.featured ?? false,
publish_date: args.publishDate ? new Date(args.publishDate) : new Date(),
author: ctx.currentUser!.id,
})
.returning();
return enrichArticle(ctx.db, inserted[0]);
},
}),
);
builder.mutationField("updateArticle", (t) =>
t.field({
type: ArticleType,
nullable: true,
args: {
id: t.arg.string({ required: true }),
title: t.arg.string(),
slug: t.arg.string(),
excerpt: t.arg.string(),
content: t.arg.string(),
imageId: t.arg.string(),
authorId: t.arg.string(),
tags: t.arg.stringList(),
category: t.arg.string(),
featured: t.arg.boolean(),
publishDate: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const updates: Record<string, unknown> = { date_updated: new Date() };
if (args.title !== undefined && args.title !== null) updates.title = args.title;
if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug;
if (args.excerpt !== undefined) updates.excerpt = args.excerpt;
if (args.content !== undefined) updates.content = args.content;
if (args.imageId !== undefined) updates.image = args.imageId;
if (args.authorId !== undefined) updates.author = args.authorId;
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
if (args.category !== undefined) updates.category = args.category;
if (args.featured !== undefined && args.featured !== null) updates.featured = args.featured;
if (args.publishDate !== undefined && args.publishDate !== null)
updates.publish_date = new Date(args.publishDate);
const updated = await ctx.db
.update(articles)
.set(updates as Partial<typeof articles.$inferInsert>)
.where(eq(articles.id, args.id))
.returning();
if (!updated[0]) return null;
return enrichArticle(ctx.db, updated[0]);
},
}),
);
builder.mutationField("deleteArticle", (t) =>
t.field({
type: "Boolean",
args: {
id: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
await ctx.db.delete(articles).where(eq(articles.id, args.id));
return true;
},
}),
);

View File

@@ -1,12 +1,16 @@
import { GraphQLError } from "graphql";
import { builder } from "../builder.js";
import { CurrentUserType } from "../types/index.js";
import { users } from "../../db/schema/index.js";
import { builder } from "../builder";
import { CurrentUserType } from "../types/index";
import { users } from "../../db/schema/index";
import { eq } from "drizzle-orm";
import { hash, verify as verifyArgon } from "../../lib/argon.js";
import { setSession, deleteSession } from "../../lib/auth.js";
import { sendVerification, sendPasswordReset } from "../../lib/email.js";
import { slugify } from "../../lib/slugify.js";
interface ReplyLike {
header?: (name: string, value: string) => void;
}
import { hash, verify as verifyArgon } from "../../lib/argon";
import { setSession, deleteSession } from "../../lib/auth";
import { enqueueVerification, enqueuePasswordReset } from "../../lib/email";
import { slugify } from "../../lib/slugify";
import { nanoid } from "nanoid";
builder.mutationField("login", (t) =>
@@ -32,7 +36,8 @@ builder.mutationField("login", (t) =>
const sessionUser = {
id: user[0].id,
email: user[0].email,
role: user[0].role,
role: (user[0].role === "admin" ? "viewer" : user[0].role) as "model" | "viewer",
is_admin: user[0].is_admin,
first_name: user[0].first_name,
last_name: user[0].last_name,
artist_name: user[0].artist_name,
@@ -44,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];
},
@@ -73,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;
},
}),
@@ -129,7 +130,11 @@ builder.mutationField("register", (t) =>
email_verified: false,
});
await sendVerification(args.email, verifyToken);
try {
await enqueueVerification(args.email, verifyToken);
} catch (e) {
console.warn("Failed to enqueue verification email:", (e as Error).message);
}
return true;
},
}),
@@ -184,7 +189,11 @@ builder.mutationField("requestPasswordReset", (t) =>
.set({ password_reset_token: token, password_reset_expiry: expiry })
.where(eq(users.id, user[0].id));
await sendPasswordReset(args.email, token);
try {
await enqueuePasswordReset(args.email, token);
} catch (e) {
console.warn("Failed to enqueue password reset email:", (e as Error).message);
}
return true;
},
}),

View File

@@ -1,9 +1,10 @@
import { GraphQLError } from "graphql";
import { builder } from "../builder.js";
import { CommentType } from "../types/index.js";
import { comments, users } from "../../db/schema/index.js";
import { eq, and, desc } from "drizzle-orm";
import { awardPoints, checkAchievements } from "../../lib/gamification.js";
import { builder } from "../builder";
import { CommentType, AdminCommentListType } from "../types/index";
import { comments, users } from "../../db/schema/index";
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({
@@ -19,9 +20,15 @@ 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, first_name: users.first_name, last_name: users.last_name, avatar: users.avatar })
.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);
@@ -52,12 +59,25 @@ 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({ id: users.id, first_name: users.first_name, last_name: users.last_name, avatar: users.avatar })
.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, ctx.currentUser.id))
.limit(1);
@@ -66,3 +86,80 @@ builder.mutationField("createCommentForVideo", (t) =>
},
}),
);
builder.mutationField("deleteComment", (t) =>
t.field({
type: "Boolean",
args: {
id: t.arg.int({ required: true }),
},
resolve: async (_root, args, ctx) => {
const comment = await ctx.db.select().from(comments).where(eq(comments.id, args.id)).limit(1);
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

@@ -1,7 +1,13 @@
import { builder } from "../builder.js";
import { LeaderboardEntryType, UserGamificationType, AchievementType } from "../types/index.js";
import { user_stats, users, user_achievements, achievements, user_points } from "../../db/schema/index.js";
import { eq, desc, gt, count, isNotNull } from "drizzle-orm";
import { builder } from "../builder";
import { LeaderboardEntryType, UserGamificationType, AchievementType } from "../types/index";
import {
user_stats,
users,
user_achievements,
achievements,
user_points,
} from "../../db/schema/index";
import { eq, desc, gt, count, isNotNull, and } from "drizzle-orm";
builder.queryField("leaderboard", (t) =>
t.field({
@@ -31,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 }));
},
}),
);
@@ -73,8 +79,12 @@ builder.queryField("userGamification", (t) =>
})
.from(user_achievements)
.leftJoin(achievements, eq(user_achievements.achievement_id, achievements.id))
.where(eq(user_achievements.user_id, args.userId))
.where(isNotNull(user_achievements.date_unlocked))
.where(
and(
eq(user_achievements.user_id, args.userId),
isNotNull(user_achievements.date_unlocked),
),
)
.orderBy(desc(user_achievements.date_unlocked));
const recentPoints = await ctx.db
@@ -91,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.js";
import { ModelType } from "../types/index.js";
import { users, user_photos, files } from "../../db/schema/index.js";
import { eq, and, desc } from "drizzle-orm";
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, 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,32 +13,42 @@ 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) =>
t.field({
type: [ModelType],
type: ModelListType,
args: {
featured: t.arg.boolean(),
limit: t.arg.int(),
search: t.arg.string(),
offset: t.arg.int(),
sortBy: t.arg.string(),
tag: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
let query = ctx.db
.select()
.from(users)
.where(eq(users.role, "model"))
.orderBy(desc(users.date_created));
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
if (args.limit) {
query = (query as any).limit(args.limit);
}
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]));
const modelList = await query;
return Promise.all(modelList.map((m: any) => enrichModel(ctx.db, m)));
const order = args.sortBy === "recent" ? desc(users.date_created) : asc(users.artist_name);
const where = and(...conditions);
const [modelList, totalRows] = await Promise.all([
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) => 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.js";
import { RecordingType } from "../types/index.js";
import { recordings, recording_plays } from "../../db/schema/index.js";
import { eq, and, desc } from "drizzle-orm";
import { slugify } from "../../lib/slugify.js";
import { awardPoints, checkAchievements } from "../../lib/gamification.js";
import { builder } from "../builder";
import { RecordingType, AdminRecordingListType } from "../types/index";
import { recordings, recording_plays } from "../../db/schema/index";
import { eq, and, desc, ilike, count, type SQL } from "drizzle-orm";
import { slugify } from "../../lib/slugify";
import { requireAdmin } from "../../lib/acl";
import { gamificationQueue } from "../../queues/index";
builder.queryField("recordings", (t) =>
t.field({
@@ -20,7 +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));
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;
@@ -114,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;
@@ -162,28 +171,61 @@ builder.mutationField("updateRecording", (t) =>
updates.title = args.title;
updates.slug = slugify(args.title);
}
if (args.description !== null && args.description !== undefined) updates.description = args.description;
if (args.description !== null && args.description !== undefined)
updates.description = args.description;
if (args.tags !== null && args.tags !== undefined) updates.tags = args.tags;
if (args.status !== null && args.status !== undefined) updates.status = args.status;
if (args.public !== null && args.public !== undefined) updates.public = args.public;
if (args.linkedVideoId !== null && args.linkedVideoId !== undefined) updates.linked_video = args.linkedVideoId;
if (args.linkedVideoId !== null && args.linkedVideoId !== undefined)
updates.linked_video = args.linkedVideoId;
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;
@@ -209,10 +251,29 @@ 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");
await ctx.db
.update(recordings)
.set({ status: "archived", date_updated: new Date() })
.where(eq(recordings.id, args.id));
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 };
@@ -319,15 +388,77 @@ builder.mutationField("updateRecordingPlay", (t) =>
await ctx.db
.update(recording_plays)
.set({ duration_played: args.durationPlayed, completed: args.completed, date_updated: new Date() })
.set({
duration_played: args.durationPlayed,
completed: args.completed,
date_updated: new Date(),
})
.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

@@ -1,6 +1,6 @@
import { builder } from "../builder.js";
import { StatsType } from "../types/index.js";
import { users, videos } from "../../db/schema/index.js";
import { builder } from "../builder";
import { StatsType } from "../types/index";
import { users, videos } from "../../db/schema/index";
import { eq, count } from "drizzle-orm";
builder.queryField("stats", (t) =>
@@ -15,9 +15,7 @@ builder.queryField("stats", (t) =>
.select({ count: count() })
.from(users)
.where(eq(users.role, "viewer"));
const videosCount = await ctx.db
.select({ count: count() })
.from(videos);
const videosCount = await ctx.db.select({ count: count() }).from(videos);
return {
models_count: modelsCount[0]?.count || 0,

View File

@@ -1,8 +1,9 @@
import { GraphQLError } from "graphql";
import { builder } from "../builder.js";
import { CurrentUserType, UserType } from "../types/index.js";
import { users } from "../../db/schema/index.js";
import { eq } from "drizzle-orm";
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, asc, type SQL } from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
builder.queryField("me", (t) =>
t.field({
@@ -28,11 +29,7 @@ builder.queryField("userProfile", (t) =>
id: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
const user = await ctx.db
.select()
.from(users)
.where(eq(users.id, args.id))
.limit(1);
const user = await ctx.db.select().from(users).where(eq(users.id, args.id)).limit(1);
return user[0] || null;
},
}),
@@ -48,18 +45,26 @@ 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");
const updates: Record<string, unknown> = { date_updated: new Date() };
if (args.firstName !== undefined && args.firstName !== null) updates.first_name = args.firstName;
if (args.firstName !== undefined && args.firstName !== null)
updates.first_name = args.firstName;
if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName;
if (args.artistName !== undefined && args.artistName !== null) updates.artist_name = args.artistName;
if (args.description !== undefined && args.description !== null) updates.description = args.description;
if (args.artistName !== undefined && args.artistName !== null)
updates.artist_name = args.artistName;
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).where(eq(users.id, ctx.currentUser.id));
await ctx.db
.update(users)
.set(updates as Partial<typeof users.$inferInsert>)
.where(eq(users.id, ctx.currentUser.id));
const updated = await ctx.db
.select()
@@ -70,3 +75,163 @@ builder.mutationField("updateProfile", (t) =>
},
}),
);
// ─── Admin queries & mutations ────────────────────────────────────────────────
builder.queryField("adminListUsers", (t) =>
t.field({
type: AdminUserListType,
args: {
role: t.arg.string(),
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: SQL<unknown>[] = [];
if (args.role) {
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)) as SQL<unknown>,
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [items, totalRows] = await Promise.all([
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 };
},
}),
);
builder.mutationField("adminUpdateUser", (t) =>
t.field({
type: UserType,
nullable: true,
args: {
userId: t.arg.string({ required: true }),
role: t.arg.string(),
isAdmin: t.arg.boolean(),
firstName: t.arg.string(),
lastName: t.arg.string(),
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 "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;
if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName;
if (args.artistName !== undefined && args.artistName !== null)
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 Partial<typeof users.$inferInsert>)
.where(eq(users.id, args.userId))
.returning();
return updated[0] || null;
},
}),
);
builder.mutationField("adminDeleteUser", (t) =>
t.field({
type: "Boolean",
args: {
userId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
if (args.userId === ctx.currentUser!.id) throw new GraphQLError("Cannot delete yourself");
await ctx.db.delete(users).where(eq(users.id, args.userId));
return true;
},
}),
);
builder.queryField("adminGetUser", (t) =>
t.field({
type: AdminUserDetailType,
nullable: true,
args: {
userId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const user = await ctx.db.select().from(users).where(eq(users.id, args.userId)).limit(1);
if (!user[0]) return null;
const photoRows = await ctx.db
.select({ id: files.id, filename: files.filename })
.from(user_photos)
.leftJoin(files, eq(user_photos.file_id, files.id))
.where(eq(user_photos.user_id, args.userId))
.orderBy(user_photos.sort);
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[0], photos };
},
}),
);
builder.mutationField("adminAddUserPhoto", (t) =>
t.field({
type: "Boolean",
args: {
userId: t.arg.string({ required: true }),
fileId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
await ctx.db.insert(user_photos).values({ user_id: args.userId, file_id: args.fileId });
return true;
},
}),
);
builder.mutationField("adminRemoveUserPhoto", (t) =>
t.field({
type: "Boolean",
args: {
userId: t.arg.string({ required: true }),
fileId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
await ctx.db
.delete(user_photos)
.where(and(eq(user_photos.user_id, args.userId), eq(user_photos.file_id, args.fileId)));
return true;
},
}),
);

View File

@@ -1,10 +1,39 @@
import { GraphQLError } from "graphql";
import { builder } from "../builder.js";
import { VideoType, VideoLikeResponseType, VideoPlayResponseType, VideoLikeStatusType } from "../types/index.js";
import { videos, video_models, video_likes, video_plays, users, files } from "../../db/schema/index.js";
import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
import { builder } from "../builder";
import {
VideoType,
VideoListType,
AdminVideoListType,
VideoLikeResponseType,
VideoPlayResponseType,
VideoLikeStatusType,
} from "../types/index";
import {
videos,
video_models,
video_likes,
video_plays,
users,
files,
} from "../../db/schema/index";
import {
eq,
and,
lte,
desc,
asc,
inArray,
count,
ilike,
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({
@@ -12,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))
@@ -25,12 +55,28 @@ async function enrichVideo(db: any, video: any) {
}
// Count likes
const likesCount = await db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, video.id));
const playsCount = await db.select({ count: count() }).from(video_plays).where(eq(video_plays.video_id, video.id));
const likesCount = await db
.select({ count: count() })
.from(video_likes)
.where(eq(video_likes.video_id, video.id));
const playsCount = await db
.select({ count: count() })
.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,
@@ -39,55 +85,93 @@ async function enrichVideo(db: any, video: any) {
builder.queryField("videos", (t) =>
t.field({
type: [VideoType],
type: VideoListType,
args: {
modelId: t.arg.string(),
featured: t.arg.boolean(),
limit: t.arg.int(),
search: t.arg.string(),
offset: t.arg.int(),
sortBy: t.arg.string(),
duration: t.arg.string(),
tag: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
let query = ctx.db
.select({ v: videos })
.from(videos)
.where(lte(videos.upload_date, new Date()))
.orderBy(desc(videos.upload_date));
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
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));
}
if (args.search) {
conditions.push(ilike(videos.title, `%${args.search}%`));
}
if (args.tag) {
conditions.push(arrayContains(videos.tags, [args.tag]));
}
if (args.modelId) {
const videoIds = await ctx.db
.select({ video_id: video_models.video_id })
.from(video_models)
.where(eq(video_models.user_id, args.modelId));
if (videoIds.length === 0) return [];
query = ctx.db
.select({ v: videos })
.from(videos)
.where(and(
lte(videos.upload_date, new Date()),
inArray(videos.id, videoIds.map((v: any) => v.video_id)),
))
.orderBy(desc(videos.upload_date));
if (videoIds.length === 0) return { items: [], total: 0 };
conditions.push(
inArray(
videos.id,
videoIds.map((v) => v.video_id),
),
);
}
if (args.featured !== null && args.featured !== undefined) {
query = ctx.db
.select({ v: videos })
.from(videos)
.where(and(
lte(videos.upload_date, new Date()),
eq(videos.featured, args.featured),
))
.orderBy(desc(videos.upload_date));
const order =
args.sortBy === "most_liked"
? desc(videos.likes_count)
: args.sortBy === "most_played"
? desc(videos.plays_count)
: args.sortBy === "name"
? asc(videos.title)
: desc(videos.upload_date);
const where = and(...conditions);
// Duration filter requires JOIN to files table
if (args.duration && args.duration !== "all") {
const durationCond =
args.duration === "short"
? lt(files.duration, 600)
: args.duration === "medium"
? and(gte(files.duration, 600), lt(files.duration, 1200))
: gte(files.duration, 1200);
const fullWhere = and(where, durationCond);
const [rows, totalRows] = await Promise.all([
ctx.db
.select({ v: videos })
.from(videos)
.leftJoin(files, eq(videos.movie, files.id))
.where(fullWhere)
.orderBy(order)
.limit(pageSize)
.offset(offset),
ctx.db
.select({ total: count() })
.from(videos)
.leftJoin(files, eq(videos.movie, files.id))
.where(fullWhere),
]);
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 };
}
if (args.limit) {
query = (query as any).limit(args.limit);
}
const rows = await query;
const videoList = rows.map((r: any) => r.v || r);
return Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v)));
const [rows, totalRows] = await Promise.all([
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) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
);
@@ -107,6 +191,27 @@ builder.queryField("video", (t) =>
.limit(1);
if (!video[0]) return null;
if (video[0].premium && !ctx.currentUser) {
throw new GraphQLError("Unauthorized");
}
return enrichVideo(ctx.db, video[0]);
},
}),
);
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]);
},
}),
@@ -123,7 +228,9 @@ builder.queryField("videoLikeStatus", (t) =>
const existing = await ctx.db
.select()
.from(video_likes)
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
.where(
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
)
.limit(1);
return { liked: existing.length > 0 };
},
@@ -142,7 +249,9 @@ builder.mutationField("likeVideo", (t) =>
const existing = await ctx.db
.select()
.from(video_likes)
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
.where(
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
)
.limit(1);
if (existing.length > 0) throw new GraphQLError("Already liked");
@@ -154,10 +263,22 @@ builder.mutationField("likeVideo", (t) =>
await ctx.db
.update(videos)
.set({ likes_count: (await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number + 1 || 1 })
.set({
likes_count:
((
await ctx.db
.select({ c: videos.likes_count })
.from(videos)
.where(eq(videos.id, args.videoId))
.limit(1)
)[0]?.c as number) + 1 || 1,
})
.where(eq(videos.id, args.videoId));
const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId));
const likesCount = await ctx.db
.select({ count: count() })
.from(video_likes)
.where(eq(video_likes.video_id, args.videoId));
return { liked: true, likes_count: likesCount[0]?.count || 1 };
},
}),
@@ -175,21 +296,39 @@ builder.mutationField("unlikeVideo", (t) =>
const existing = await ctx.db
.select()
.from(video_likes)
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
.where(
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
)
.limit(1);
if (existing.length === 0) throw new GraphQLError("Not liked");
await ctx.db
.delete(video_likes)
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)));
.where(
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
);
await ctx.db
.update(videos)
.set({ likes_count: Math.max(((await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number || 1) - 1, 0) })
.set({
likes_count: Math.max(
(((
await ctx.db
.select({ c: videos.likes_count })
.from(videos)
.where(eq(videos.id, args.videoId))
.limit(1)
)[0]?.c as number) || 1) - 1,
0,
),
})
.where(eq(videos.id, args.videoId));
const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId));
const likesCount = await ctx.db
.select({ count: count() })
.from(video_likes)
.where(eq(video_likes.video_id, args.videoId));
return { liked: false, likes_count: likesCount[0]?.count || 0 };
},
}),
@@ -203,13 +342,19 @@ builder.mutationField("recordVideoPlay", (t) =>
sessionId: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
const play = await ctx.db.insert(video_plays).values({
video_id: args.videoId,
user_id: ctx.currentUser?.id || null,
session_id: args.sessionId || null,
}).returning({ id: video_plays.id });
const play = await ctx.db
.insert(video_plays)
.values({
video_id: args.videoId,
user_id: ctx.currentUser?.id || null,
session_id: args.sessionId || null,
})
.returning({ id: video_plays.id });
const playsCount = await ctx.db.select({ count: count() }).from(video_plays).where(eq(video_plays.video_id, args.videoId));
const playsCount = await ctx.db
.select({ count: count() })
.from(video_plays)
.where(eq(video_plays.video_id, args.videoId));
await ctx.db
.update(videos)
@@ -235,9 +380,26 @@ builder.mutationField("updateVideoPlay", (t) =>
completed: t.arg.boolean({ required: true }),
},
resolve: async (_root, args, ctx) => {
const play = await ctx.db
.select()
.from(video_plays)
.where(eq(video_plays.id, args.playId))
.limit(1);
if (!play[0]) return false;
// If play belongs to a user, verify ownership
if (play[0].user_id && (!ctx.currentUser || play[0].user_id !== ctx.currentUser.id)) {
throw new GraphQLError("Forbidden");
}
await ctx.db
.update(video_plays)
.set({ duration_watched: args.durationWatched, completed: args.completed, date_updated: new Date() })
.set({
duration_watched: args.durationWatched,
completed: args.completed,
date_updated: new Date(),
})
.where(eq(video_plays.id, args.playId));
return true;
},
@@ -262,25 +424,38 @@ builder.queryField("analytics", (t) =>
.where(eq(video_models.user_id, userId));
if (modelVideoIds.length === 0) {
return { total_videos: 0, total_likes: 0, total_plays: 0, plays_by_date: {}, likes_by_date: {}, videos: [] };
return {
total_videos: 0,
total_likes: 0,
total_plays: 0,
plays_by_date: {},
likes_by_date: {},
videos: [],
};
}
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().from(video_plays).where(inArray(video_plays.video_id, videoIds));
const likes = await ctx.db.select().from(video_likes).where(inArray(video_likes.video_id, videoIds));
const plays = await ctx.db
.select()
.from(video_plays)
.where(inArray(video_plays.video_id, videoIds));
const likes = await ctx.db
.select()
.from(video_likes)
.where(inArray(video_likes.video_id, videoIds));
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]++;
@@ -290,9 +465,10 @@ builder.queryField("analytics", (t) =>
const videoAnalytics = videoList.map((video) => {
const vPlays = plays.filter((p) => p.video_id === video.id);
const completedPlays = vPlays.filter((p) => p.completed).length;
const avgWatchTime = vPlays.length > 0
? vPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) / vPlays.length
: 0;
const avgWatchTime =
vPlays.length > 0
? vPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) / vPlays.length
: 0;
return {
id: video.id,
@@ -318,3 +494,157 @@ builder.queryField("analytics", (t) =>
},
}),
);
// ─── Admin queries & mutations ────────────────────────────────────────────────
builder.queryField("adminListVideos", (t) =>
t.field({
type: AdminVideoListType,
args: {
search: t.arg.string(),
premium: t.arg.boolean(),
featured: t.arg.boolean(),
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(videos.title, `%${args.search}%`));
if (args.premium !== null && args.premium !== undefined)
conditions.push(eq(videos.premium, args.premium));
if (args.featured !== null && args.featured !== undefined)
conditions.push(eq(videos.featured, args.featured));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [rows, totalRows] = await Promise.all([
ctx.db
.select()
.from(videos)
.where(where)
.orderBy(desc(videos.upload_date))
.limit(limit)
.offset(offset),
ctx.db.select({ total: count() }).from(videos).where(where),
]);
const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
);
builder.mutationField("createVideo", (t) =>
t.field({
type: VideoType,
args: {
title: t.arg.string({ required: true }),
slug: t.arg.string({ required: true }),
description: t.arg.string(),
imageId: t.arg.string(),
movieId: t.arg.string(),
tags: t.arg.stringList(),
premium: t.arg.boolean(),
featured: t.arg.boolean(),
uploadDate: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const inserted = await ctx.db
.insert(videos)
.values({
title: args.title,
slug: args.slug,
description: args.description || null,
image: args.imageId || null,
movie: args.movieId || null,
tags: args.tags || [],
premium: args.premium ?? false,
featured: args.featured ?? false,
upload_date: args.uploadDate ? new Date(args.uploadDate) : new Date(),
})
.returning();
return enrichVideo(ctx.db, inserted[0]);
},
}),
);
builder.mutationField("updateVideo", (t) =>
t.field({
type: VideoType,
nullable: true,
args: {
id: t.arg.string({ required: true }),
title: t.arg.string(),
slug: t.arg.string(),
description: t.arg.string(),
imageId: t.arg.string(),
movieId: t.arg.string(),
tags: t.arg.stringList(),
premium: t.arg.boolean(),
featured: t.arg.boolean(),
uploadDate: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const updates: Record<string, unknown> = {};
if (args.title !== undefined && args.title !== null) updates.title = args.title;
if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug;
if (args.description !== undefined) updates.description = args.description;
if (args.imageId !== undefined) updates.image = args.imageId;
if (args.movieId !== undefined) updates.movie = args.movieId;
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
if (args.premium !== undefined && args.premium !== null) updates.premium = args.premium;
if (args.featured !== undefined && args.featured !== null) updates.featured = args.featured;
if (args.uploadDate !== undefined && args.uploadDate !== null)
updates.upload_date = new Date(args.uploadDate);
const updated = await ctx.db
.update(videos)
.set(updates as Partial<typeof videos.$inferInsert>)
.where(eq(videos.id, args.id))
.returning();
if (!updated[0]) return null;
return enrichVideo(ctx.db, updated[0]);
},
}),
);
builder.mutationField("deleteVideo", (t) =>
t.field({
type: "Boolean",
args: {
id: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
await ctx.db.delete(videos).where(eq(videos.id, args.id));
return true;
},
}),
);
builder.mutationField("setVideoModels", (t) =>
t.field({
type: "Boolean",
args: {
videoId: t.arg.string({ required: true }),
userIds: t.arg.stringList({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
await ctx.db.delete(video_models).where(eq(video_models.video_id, args.videoId));
if (args.userIds.length > 0) {
await ctx.db.insert(video_models).values(
args.userIds.map((userId) => ({
video_id: args.videoId,
user_id: userId,
})),
);
}
return true;
},
}),
);

View File

@@ -1,17 +1,33 @@
import { builder } from "../builder.js";
import type {
MediaFile,
User,
VideoModel,
VideoFile,
Video,
ModelPhoto,
Model,
Article,
CommentUser,
Comment,
Stats,
Recording,
VideoLikeStatus,
VideoLikeResponse,
VideoPlayResponse,
VideoAnalytics,
Analytics,
LeaderboardEntry,
UserStats,
UserAchievement,
RecentPoint,
UserGamification,
Achievement,
} from "@sexy.pivoine.art/types";
// File type
export const FileType = builder.objectRef<{
id: string;
title: string | null;
description: string | null;
filename: string;
mime_type: string | null;
filesize: number | null;
duration: number | null;
uploaded_by: string | null;
date_created: Date;
}>("File").implement({
type AdminUserDetail = User & { photos: ModelPhoto[] };
import { builder } from "../builder";
export const FileType = builder.objectRef<MediaFile>("File").implement({
fields: (t) => ({
id: t.exposeString("id"),
title: t.exposeString("title", { nullable: true }),
@@ -25,22 +41,7 @@ export const FileType = builder.objectRef<{
}),
});
// User type
export const UserType = builder.objectRef<{
id: string;
email: string;
first_name: string | null;
last_name: string | null;
artist_name: string | null;
slug: string | null;
description: string | null;
tags: string[] | null;
role: "model" | "viewer" | "admin";
avatar: string | null;
banner: string | null;
email_verified: boolean;
date_created: Date;
}>("User").implement({
export const UserType = builder.objectRef<User>("User").implement({
fields: (t) => ({
id: t.exposeString("id"),
email: t.exposeString("email"),
@@ -51,29 +52,17 @@ export const UserType = builder.objectRef<{
description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"),
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" }),
}),
});
// CurrentUser type (same shape, used for auth context)
export const CurrentUserType = builder.objectRef<{
id: string;
email: string;
first_name: string | null;
last_name: string | null;
artist_name: string | null;
slug: string | null;
description: string | null;
tags: string[] | null;
role: "model" | "viewer" | "admin";
avatar: string | null;
banner: string | null;
email_verified: boolean;
date_created: Date;
}>("CurrentUser").implement({
// CurrentUser is the same shape as User
export const CurrentUserType = builder.objectRef<User>("CurrentUser").implement({
fields: (t) => ({
id: t.exposeString("id"),
email: t.exposeString("email"),
@@ -84,30 +73,35 @@ export const CurrentUserType = builder.objectRef<{
description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"),
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" }),
}),
});
// Video type
export const VideoType = builder.objectRef<{
id: string;
slug: string;
title: string;
description: string | null;
image: string | null;
movie: string | null;
tags: string[] | null;
upload_date: Date;
premium: boolean | null;
featured: boolean | null;
likes_count: number | null;
plays_count: number | null;
models?: { id: string; artist_name: string | null; slug: string | null; avatar: string | null }[];
movie_file?: { id: string; filename: string; mime_type: string | null; duration: number | null } | null;
}>("Video").implement({
export const VideoModelType = builder.objectRef<VideoModel>("VideoModel").implement({
fields: (t) => ({
id: t.exposeString("id"),
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 }),
}),
});
export const VideoFileType = builder.objectRef<VideoFile>("VideoFile").implement({
fields: (t) => ({
id: t.exposeString("id"),
filename: t.exposeString("filename"),
mime_type: t.exposeString("mime_type", { nullable: true }),
duration: t.exposeInt("duration", { nullable: true }),
}),
});
export const VideoType = builder.objectRef<Video>("Video").implement({
fields: (t) => ({
id: t.exposeString("id"),
slug: t.exposeString("slug"),
@@ -126,46 +120,14 @@ export const VideoType = builder.objectRef<{
}),
});
export const VideoModelType = builder.objectRef<{
id: string;
artist_name: string | null;
slug: string | null;
avatar: string | null;
}>("VideoModel").implement({
fields: (t) => ({
id: t.exposeString("id"),
artist_name: t.exposeString("artist_name", { nullable: true }),
slug: t.exposeString("slug", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
}),
});
export const VideoFileType = builder.objectRef<{
id: string;
filename: string;
mime_type: string | null;
duration: number | null;
}>("VideoFile").implement({
export const ModelPhotoType = builder.objectRef<ModelPhoto>("ModelPhoto").implement({
fields: (t) => ({
id: t.exposeString("id"),
filename: t.exposeString("filename"),
mime_type: t.exposeString("mime_type", { nullable: true }),
duration: t.exposeInt("duration", { nullable: true }),
}),
});
// Model type (model profile, enriched user)
export const ModelType = builder.objectRef<{
id: string;
slug: string | null;
artist_name: string | null;
description: string | null;
avatar: string | null;
banner: string | null;
tags: string[] | null;
date_created: Date;
photos?: { id: string; filename: string }[];
}>("Model").implement({
export const ModelType = builder.objectRef<Model>("Model").implement({
fields: (t) => ({
id: t.exposeString("id"),
slug: t.exposeString("slug", { nullable: true }),
@@ -173,36 +135,14 @@ export const ModelType = builder.objectRef<{
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 }),
}),
});
export const ModelPhotoType = builder.objectRef<{
id: string;
filename: string;
}>("ModelPhoto").implement({
fields: (t) => ({
id: t.exposeString("id"),
filename: t.exposeString("filename"),
}),
});
// Article type
export const ArticleType = builder.objectRef<{
id: string;
slug: string;
title: string;
excerpt: string | null;
content: string | null;
image: string | null;
tags: string[] | null;
publish_date: Date;
category: string | null;
featured: boolean | null;
author?: { first_name: string | null; last_name: string | null; avatar: string | null; description: string | null } | null;
}>("Article").implement({
export const ArticleType = builder.objectRef<Article>("Article").implement({
fields: (t) => ({
id: t.exposeString("id"),
slug: t.exposeString("slug"),
@@ -214,42 +154,41 @@ export const ArticleType = builder.objectRef<{
publish_date: t.expose("publish_date", { type: "DateTime" }),
category: t.exposeString("category", { nullable: true }),
featured: t.exposeBoolean("featured", { nullable: true }),
author: t.expose("author", { type: ArticleAuthorType, nullable: true }),
author: t.expose("author", { type: VideoModelType, nullable: true }),
}),
});
export const ArticleAuthorType = builder.objectRef<{
first_name: string | null;
last_name: string | null;
avatar: string | null;
description: string | null;
}>("ArticleAuthor").implement({
export const CommentUserType = builder.objectRef<CommentUser>("CommentUser").implement({
fields: (t) => ({
id: t.exposeString("id"),
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
artist_name: t.exposeString("artist_name", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
}),
});
// Recording type
export const RecordingType = builder.objectRef<{
id: string;
title: string;
description: string | null;
slug: string;
duration: number;
events: object[] | null;
device_info: object[] | null;
user_id: string;
status: string;
tags: string[] | null;
linked_video: string | null;
featured: boolean | null;
public: boolean | null;
date_created: Date;
date_updated: Date | null;
}>("Recording").implement({
export const CommentType = builder.objectRef<Comment>("Comment").implement({
fields: (t) => ({
id: t.exposeInt("id"),
collection: t.exposeString("collection"),
item_id: t.exposeString("item_id"),
comment: t.exposeString("comment"),
user_id: t.exposeString("user_id"),
date_created: t.expose("date_created", { type: "DateTime" }),
user: t.expose("user", { type: CommentUserType, nullable: true }),
}),
});
export const StatsType = builder.objectRef<Stats>("Stats").implement({
fields: (t) => ({
videos_count: t.exposeInt("videos_count"),
models_count: t.exposeInt("models_count"),
viewers_count: t.exposeInt("viewers_count"),
}),
});
export const RecordingType = builder.objectRef<Recording>("Recording").implement({
fields: (t) => ({
id: t.exposeString("id"),
title: t.exposeString("title"),
@@ -269,237 +208,32 @@ export const RecordingType = builder.objectRef<{
}),
});
// Comment type
export const CommentType = builder.objectRef<{
id: number;
collection: string;
item_id: string;
comment: string;
user_id: string;
date_created: Date;
user?: { id: string; first_name: string | null; last_name: string | null; avatar: string | null } | null;
}>("Comment").implement({
export const VideoLikeResponseType = builder
.objectRef<VideoLikeResponse>("VideoLikeResponse")
.implement({
fields: (t) => ({
liked: t.exposeBoolean("liked"),
likes_count: t.exposeInt("likes_count"),
}),
});
export const VideoPlayResponseType = builder
.objectRef<VideoPlayResponse>("VideoPlayResponse")
.implement({
fields: (t) => ({
success: t.exposeBoolean("success"),
play_id: t.exposeString("play_id"),
plays_count: t.exposeInt("plays_count"),
}),
});
export const VideoLikeStatusType = builder.objectRef<VideoLikeStatus>("VideoLikeStatus").implement({
fields: (t) => ({
id: t.exposeInt("id"),
collection: t.exposeString("collection"),
item_id: t.exposeString("item_id"),
comment: t.exposeString("comment"),
user_id: t.exposeString("user_id"),
date_created: t.expose("date_created", { type: "DateTime" }),
user: t.expose("user", { type: CommentUserType, nullable: true }),
liked: t.exposeBoolean("liked"),
}),
});
export const CommentUserType = builder.objectRef<{
id: string;
first_name: string | null;
last_name: string | null;
avatar: string | null;
}>("CommentUser").implement({
fields: (t) => ({
id: t.exposeString("id"),
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
}),
});
// Stats type
export const StatsType = builder.objectRef<{
videos_count: number;
models_count: number;
viewers_count: number;
}>("Stats").implement({
fields: (t) => ({
videos_count: t.exposeInt("videos_count"),
models_count: t.exposeInt("models_count"),
viewers_count: t.exposeInt("viewers_count"),
}),
});
// Gamification types
export const LeaderboardEntryType = builder.objectRef<{
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;
}>("LeaderboardEntry").implement({
fields: (t) => ({
user_id: t.exposeString("user_id"),
display_name: t.exposeString("display_name", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
total_weighted_points: t.exposeFloat("total_weighted_points", { nullable: true }),
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
recordings_count: t.exposeInt("recordings_count", { nullable: true }),
playbacks_count: t.exposeInt("playbacks_count", { nullable: true }),
achievements_count: t.exposeInt("achievements_count", { nullable: true }),
rank: t.exposeInt("rank"),
}),
});
export const AchievementType = builder.objectRef<{
id: string;
code: string;
name: string;
description: string | null;
icon: string | null;
category: string | null;
required_count: number;
points_reward: number;
}>("Achievement").implement({
fields: (t) => ({
id: t.exposeString("id"),
code: t.exposeString("code"),
name: t.exposeString("name"),
description: t.exposeString("description", { nullable: true }),
icon: t.exposeString("icon", { nullable: true }),
category: t.exposeString("category", { nullable: true }),
required_count: t.exposeInt("required_count"),
points_reward: t.exposeInt("points_reward"),
}),
});
export const UserGamificationType = builder.objectRef<{
stats: {
user_id: string;
total_raw_points: number | null;
total_weighted_points: number | null;
recordings_count: number | null;
playbacks_count: number | null;
comments_count: number | null;
achievements_count: number | null;
rank: number;
} | null;
achievements: {
id: string;
code: string;
name: string;
description: string | null;
icon: string | null;
category: string | null;
date_unlocked: Date;
progress: number | null;
required_count: number;
}[];
recent_points: {
action: string;
points: number;
date_created: Date;
recording_id: string | null;
}[];
}>("UserGamification").implement({
fields: (t) => ({
stats: t.expose("stats", { type: UserStatsType, nullable: true }),
achievements: t.expose("achievements", { type: [UserAchievementType] }),
recent_points: t.expose("recent_points", { type: [RecentPointType] }),
}),
});
export const UserStatsType = builder.objectRef<{
user_id: string;
total_raw_points: number | null;
total_weighted_points: number | null;
recordings_count: number | null;
playbacks_count: number | null;
comments_count: number | null;
achievements_count: number | null;
rank: number;
}>("UserStats").implement({
fields: (t) => ({
user_id: t.exposeString("user_id"),
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
total_weighted_points: t.exposeFloat("total_weighted_points", { nullable: true }),
recordings_count: t.exposeInt("recordings_count", { nullable: true }),
playbacks_count: t.exposeInt("playbacks_count", { nullable: true }),
comments_count: t.exposeInt("comments_count", { nullable: true }),
achievements_count: t.exposeInt("achievements_count", { nullable: true }),
rank: t.exposeInt("rank"),
}),
});
export const UserAchievementType = builder.objectRef<{
id: string;
code: string;
name: string;
description: string | null;
icon: string | null;
category: string | null;
date_unlocked: Date;
progress: number | null;
required_count: number;
}>("UserAchievement").implement({
fields: (t) => ({
id: t.exposeString("id"),
code: t.exposeString("code"),
name: t.exposeString("name"),
description: t.exposeString("description", { nullable: true }),
icon: t.exposeString("icon", { nullable: true }),
category: t.exposeString("category", { nullable: true }),
date_unlocked: t.expose("date_unlocked", { type: "DateTime" }),
progress: t.exposeInt("progress", { nullable: true }),
required_count: t.exposeInt("required_count"),
}),
});
export const RecentPointType = builder.objectRef<{
action: string;
points: number;
date_created: Date;
recording_id: string | null;
}>("RecentPoint").implement({
fields: (t) => ({
action: t.exposeString("action"),
points: t.exposeInt("points"),
date_created: t.expose("date_created", { type: "DateTime" }),
recording_id: t.exposeString("recording_id", { nullable: true }),
}),
});
// Analytics types
export const AnalyticsType = builder.objectRef<{
total_videos: number;
total_likes: number;
total_plays: number;
plays_by_date: Record<string, number>;
likes_by_date: Record<string, number>;
videos: {
id: string;
title: string;
slug: string;
upload_date: Date;
likes: number;
plays: number;
completed_plays: number;
completion_rate: number;
avg_watch_time: number;
}[];
}>("Analytics").implement({
fields: (t) => ({
total_videos: t.exposeInt("total_videos"),
total_likes: t.exposeInt("total_likes"),
total_plays: t.exposeInt("total_plays"),
plays_by_date: t.expose("plays_by_date", { type: "JSON" }),
likes_by_date: t.expose("likes_by_date", { type: "JSON" }),
videos: t.expose("videos", { type: [VideoAnalyticsType] }),
}),
});
export const VideoAnalyticsType = builder.objectRef<{
id: string;
title: string;
slug: string;
upload_date: Date;
likes: number;
plays: number;
completed_plays: number;
completion_rate: number;
avg_watch_time: number;
}>("VideoAnalytics").implement({
export const VideoAnalyticsType = builder.objectRef<VideoAnalytics>("VideoAnalytics").implement({
fields: (t) => ({
id: t.exposeString("id"),
title: t.exposeString("title"),
@@ -513,33 +247,249 @@ export const VideoAnalyticsType = builder.objectRef<{
}),
});
// Response types
export const VideoLikeResponseType = builder.objectRef<{
liked: boolean;
likes_count: number;
}>("VideoLikeResponse").implement({
export const AnalyticsType = builder.objectRef<Analytics>("Analytics").implement({
fields: (t) => ({
liked: t.exposeBoolean("liked"),
likes_count: t.exposeInt("likes_count"),
total_videos: t.exposeInt("total_videos"),
total_likes: t.exposeInt("total_likes"),
total_plays: t.exposeInt("total_plays"),
plays_by_date: t.expose("plays_by_date", { type: "JSON" }),
likes_by_date: t.expose("likes_by_date", { type: "JSON" }),
videos: t.expose("videos", { type: [VideoAnalyticsType] }),
}),
});
export const VideoPlayResponseType = builder.objectRef<{
success: boolean;
play_id: string;
plays_count: number;
}>("VideoPlayResponse").implement({
export const LeaderboardEntryType = builder
.objectRef<LeaderboardEntry>("LeaderboardEntry")
.implement({
fields: (t) => ({
user_id: t.exposeString("user_id"),
display_name: t.exposeString("display_name", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
total_weighted_points: t.exposeFloat("total_weighted_points", { nullable: true }),
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
recordings_count: t.exposeInt("recordings_count", { nullable: true }),
playbacks_count: t.exposeInt("playbacks_count", { nullable: true }),
achievements_count: t.exposeInt("achievements_count", { nullable: true }),
rank: t.exposeInt("rank"),
}),
});
export const UserStatsType = builder.objectRef<UserStats>("UserStats").implement({
fields: (t) => ({
success: t.exposeBoolean("success"),
play_id: t.exposeString("play_id"),
plays_count: t.exposeInt("plays_count"),
user_id: t.exposeString("user_id"),
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
total_weighted_points: t.exposeFloat("total_weighted_points", { nullable: true }),
recordings_count: t.exposeInt("recordings_count", { nullable: true }),
playbacks_count: t.exposeInt("playbacks_count", { nullable: true }),
comments_count: t.exposeInt("comments_count", { nullable: true }),
achievements_count: t.exposeInt("achievements_count", { nullable: true }),
rank: t.exposeInt("rank"),
}),
});
export const VideoLikeStatusType = builder.objectRef<{
liked: boolean;
}>("VideoLikeStatus").implement({
export const UserAchievementType = builder.objectRef<UserAchievement>("UserAchievement").implement({
fields: (t) => ({
liked: t.exposeBoolean("liked"),
id: t.exposeString("id"),
code: t.exposeString("code"),
name: t.exposeString("name"),
description: t.exposeString("description", { nullable: true }),
icon: t.exposeString("icon", { nullable: true }),
category: t.exposeString("category", { nullable: true }),
date_unlocked: t.expose("date_unlocked", { type: "DateTime" }),
progress: t.exposeInt("progress", { nullable: true }),
required_count: t.exposeInt("required_count"),
}),
});
export const RecentPointType = builder.objectRef<RecentPoint>("RecentPoint").implement({
fields: (t) => ({
action: t.exposeString("action"),
points: t.exposeInt("points"),
date_created: t.expose("date_created", { type: "DateTime" }),
recording_id: t.exposeString("recording_id", { nullable: true }),
}),
});
export const UserGamificationType = builder
.objectRef<UserGamification>("UserGamification")
.implement({
fields: (t) => ({
stats: t.expose("stats", { type: UserStatsType, nullable: true }),
achievements: t.expose("achievements", { type: [UserAchievementType] }),
recent_points: t.expose("recent_points", { type: [RecentPointType] }),
}),
});
export const AchievementType = builder.objectRef<Achievement>("Achievement").implement({
fields: (t) => ({
id: t.exposeString("id"),
code: t.exposeString("code"),
name: t.exposeString("name"),
description: t.exposeString("description", { nullable: true }),
icon: t.exposeString("icon", { nullable: true }),
category: t.exposeString("category", { nullable: true }),
required_count: t.exposeInt("required_count"),
points_reward: t.exposeInt("points_reward"),
}),
});
// --- 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({
fields: (t) => ({
items: t.expose("items", { type: [VideoType] }),
total: t.exposeInt("total"),
}),
});
export const ArticleListType = builder
.objectRef<{ items: Article[]; total: number }>("ArticleList")
.implement({
fields: (t) => ({
items: t.expose("items", { type: [ArticleType] }),
total: t.exposeInt("total"),
}),
});
export const ModelListType = builder
.objectRef<{ items: Model[]; total: number }>("ModelList")
.implement({
fields: (t) => ({
items: t.expose("items", { type: [ModelType] }),
total: t.exposeInt("total"),
}),
});
export const AdminVideoListType = builder
.objectRef<{ items: Video[]; total: number }>("AdminVideoList")
.implement({
fields: (t) => ({
items: t.expose("items", { type: [VideoType] }),
total: t.exposeInt("total"),
}),
});
export const AdminArticleListType = builder
.objectRef<{ items: Article[]; total: number }>("AdminArticleList")
.implement({
fields: (t) => ({
items: t.expose("items", { type: [ArticleType] }),
total: t.exposeInt("total"),
}),
});
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({
fields: (t) => ({
items: t.expose("items", { type: [UserType] }),
total: t.exposeInt("total"),
}),
});
export const AdminUserDetailType = builder.objectRef<AdminUserDetail>("AdminUserDetail").implement({
fields: (t) => ({
id: t.exposeString("id"),
email: t.exposeString("email"),
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
artist_name: t.exposeString("artist_name", { nullable: true }),
slug: t.exposeString("slug", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"),
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

@@ -1,87 +1,203 @@
import Fastify from "fastify";
import Fastify, { type FastifyRequest, type FastifyReply } from "fastify";
import fastifyCookie from "@fastify/cookie";
import fastifyCors from "@fastify/cors";
import fastifyMultipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import { createYoga } from "graphql-yoga";
import { eq } from "drizzle-orm";
import { files } from "./db/schema/index";
import path from "path";
import { schema } from "./graphql/index.js";
import { buildContext } from "./graphql/context.js";
import { db } from "./db/connection.js";
import { redis } from "./lib/auth.js";
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";
const fastify = Fastify({
logger: {
level: process.env.LOG_LEVEL || "info",
},
});
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");
await fastify.register(fastifyCookie, {
secret: process.env.COOKIE_SECRET || "change-me-in-production",
});
// Start background workers
startMailWorker();
startGamificationWorker();
logger.info("Queue workers started");
await fastify.register(fastifyCors, {
origin: CORS_ORIGIN,
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
});
const fastify = Fastify({ loggerInstance: logger });
await fastify.register(fastifyMultipart, {
limits: {
fileSize: 5 * 1024 * 1024 * 1024, // 5 GB
},
});
await fastify.register(fastifyCookie, {
secret: process.env.COOKIE_SECRET || "change-me-in-production",
});
await fastify.register(fastifyStatic, {
root: path.resolve(UPLOAD_DIR),
prefix: "/assets/",
decorateReply: false,
});
await fastify.register(fastifyCors, {
origin: CORS_ORIGIN,
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
});
const yoga = createYoga({
schema,
context: buildContext,
graphqlEndpoint: "/graphql",
healthCheckEndpoint: "/health",
logging: {
debug: (...args) => fastify.log.debug(...args),
info: (...args) => fastify.log.info(...args),
warn: (...args) => fastify.log.warn(...args),
error: (...args) => fastify.log.error(...args),
},
});
await fastify.register(fastifyMultipart, {
limits: {
fileSize: 5 * 1024 * 1024 * 1024, // 5 GB
},
});
fastify.route({
url: "/graphql",
method: ["GET", "POST", "OPTIONS"],
handler: async (request, reply) => {
const response = await yoga.handleNodeRequestAndResponse(request, reply, {
request,
reply,
db,
redis,
});
reply.status(response.status);
for (const [key, value] of response.headers.entries()) {
reply.header(key, value);
// fastify-static provides reply.sendFile(); files are stored as <UPLOAD_DIR>/<id>/<filename>
await fastify.register(fastifyStatic, {
root: path.resolve(UPLOAD_DIR),
prefix: "/assets/",
serve: false, // disable auto-serving; we use a custom route below
decorateReply: true,
});
const yoga = createYoga<{
req: FastifyRequest;
reply: FastifyReply;
db: typeof db;
redis: typeof redis;
}>({
schema,
context: buildContext,
graphqlEndpoint: "/graphql",
healthCheckEndpoint: "/health",
logging: {
debug: (...args) => args.forEach((arg) => fastify.log.debug(arg)),
info: (...args) => args.forEach((arg) => fastify.log.info(arg)),
warn: (...args) => args.forEach((arg) => fastify.log.warn(arg)),
error: (...args) => args.forEach((arg) => fastify.log.error(arg)),
},
});
fastify.route({
url: "/graphql",
method: ["GET", "POST", "OPTIONS"],
handler: (req, reply) =>
yoga.handleNodeRequestAndResponse(req, reply, { req, reply, db, redis }),
});
// Transform presets — only banner/thumbnail force a crop; others preserve aspect ratio
const TRANSFORMS: Record<string, { width: number; height?: number; fit?: "cover" | "inside" }> = {
mini: { width: 80, height: 80, fit: "cover" },
thumbnail: { width: 300, height: 300, fit: "cover" },
preview: { width: 800, fit: "inside" },
medium: { width: 1400, fit: "inside" },
banner: { width: 1600, height: 480, fit: "cover" },
};
// Serve uploaded files: GET /assets/:id?transform=<preset>
// Files are stored as <UPLOAD_DIR>/<id>/<filename> — look up filename in DB
fastify.get("/assets/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const { transform } = request.query as { transform?: string };
const result = await db
.select({ filename: files.filename, mime_type: files.mime_type })
.from(files)
.where(eq(files.id, id))
.limit(1);
if (!result[0]) return reply.status(404).send({ error: "File not found" });
const { filename, mime_type } = result[0];
reply.header("Cache-Control", "public, max-age=31536000, immutable");
const preset = transform ? TRANSFORMS[transform] : null;
if (preset && mime_type?.startsWith("image/")) {
const cacheFile = path.join(UPLOAD_DIR, id, `${transform}.webp`);
if (!existsSync(cacheFile)) {
const originalPath = path.join(UPLOAD_DIR, id, filename);
await sharp(originalPath)
.resize({
width: preset.width,
height: preset.height,
fit: preset.fit ?? "inside",
withoutEnlargement: true,
})
.webp({ quality: 92 })
.toFile(cacheFile);
}
reply.header("Content-Type", "image/webp");
return reply.sendFile(path.join(id, `${transform}.webp`));
}
return reply.send(response.body);
},
});
fastify.get("/health", async (_request, reply) => {
return reply.send({ status: "ok", timestamp: new Date().toISOString() });
});
reply.header("Content-Type", mime_type);
return reply.sendFile(path.join(id, filename));
});
try {
await fastify.listen({ port: PORT, host: "0.0.0.0" });
fastify.log.info(`Backend running at http://0.0.0.0:${PORT}`);
fastify.log.info(`GraphQL at http://0.0.0.0:${PORT}/graphql`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
// 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() });
});
try {
await fastify.listen({ port: PORT, host: "0.0.0.0" });
fastify.log.info(`Backend running at http://0.0.0.0:${PORT}`);
fastify.log.info(`GraphQL at http://0.0.0.0:${PORT}/graphql`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

View File

@@ -0,0 +1,18 @@
import { GraphQLError } from "graphql";
import type { Context } from "../graphql/builder";
export function requireAuth(ctx: Context): void {
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
}
export function requireAdmin(ctx: Context): void {
requireAuth(ctx);
if (!ctx.currentUser!.is_admin) throw new GraphQLError("Forbidden");
}
export function requireOwnerOrAdmin(ctx: Context, ownerId: string): void {
requireAuth(ctx);
if (ctx.currentUser!.id !== ownerId && !ctx.currentUser!.is_admin) {
throw new GraphQLError("Forbidden");
}
}

View File

@@ -3,7 +3,8 @@ import Redis from "ioredis";
export type SessionUser = {
id: string;
email: string;
role: "model" | "viewer" | "admin";
role: "model" | "viewer";
is_admin: boolean;
first_name: string | null;
last_name: string | null;
artist_name: string | null;
@@ -20,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,13 +1,16 @@
import nodemailer from "nodemailer";
import { mailQueue } from "../queues/index.js";
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || "localhost",
port: parseInt(process.env.SMTP_PORT || "587"),
secure: process.env.SMTP_SECURE === "true",
auth: process.env.SMTP_USER ? {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
} : undefined,
auth: process.env.SMTP_USER
? {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
}
: undefined,
});
const FROM = process.env.EMAIL_FROM || "noreply@sexy.pivoine.art";
@@ -30,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,5 +1,5 @@
import { eq, sql, and, gt, isNotNull, count, sum } from "drizzle-orm";
import type { DB } from "../db/connection.js";
import { eq, sql, and, gt, isNull, isNotNull, count, sum } from "drizzle-orm";
import type { DB } from "../db/connection";
import {
user_points,
user_stats,
@@ -9,7 +9,7 @@ import {
user_achievements,
achievements,
users,
} from "../db/schema/index.js";
} from "../db/schema/index";
export const POINT_VALUES = {
RECORDING_CREATE: 50,
@@ -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> {
@@ -74,14 +110,17 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
.where(eq(recordings.user_id, userId));
const ownIds = ownRecordingIds.map((r) => r.id);
let playbacksCount = 0;
let playbacksCount: number;
if (ownIds.length > 0) {
const playbacksResult = await db.execute(sql`
SELECT COUNT(*) as count FROM recording_plays
WHERE user_id = ${userId}
AND recording_id NOT IN (${sql.join(ownIds.map(id => sql`${id}`), sql`, `)})
AND recording_id NOT IN (${sql.join(
ownIds.map((id) => sql`${id}`),
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() })
@@ -93,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
@@ -135,11 +174,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
}
}
export async function checkAchievements(
db: DB,
userId: string,
category?: string,
): Promise<void> {
export async function checkAchievements(db: DB, userId: string, category?: string): Promise<void> {
let achievementsQuery = db
.select()
.from(achievements)
@@ -176,7 +211,9 @@ export async function checkAchievements(
.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(
@@ -243,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)) {
@@ -258,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;
}
@@ -294,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,101 @@
type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
const LEVEL_VALUES: Record<LogLevel, number> = {
trace: 10,
debug: 20,
info: 30,
warn: 40,
error: 50,
fatal: 60,
};
function createLogger(bindings: Record<string, unknown> = {}, initialLevel: LogLevel = "info") {
let currentLevel = initialLevel;
function shouldLog(level: LogLevel): boolean {
return LEVEL_VALUES[level] >= LEVEL_VALUES[currentLevel];
}
function formatMessage(level: LogLevel, arg: unknown, msg?: string): string {
const timestamp = new Date().toISOString();
let message: string;
const meta: Record<string, unknown> = { ...bindings };
if (typeof arg === "string") {
message = arg;
} else if (arg !== null && typeof arg === "object") {
// Pino-style: log(obj, msg?) — strip internal pino keys
const {
msg: m,
level: _l,
time: _t,
pid: _p,
hostname: _h,
req: _req,
res: _res,
reqId,
...rest
} = arg as Record<string, unknown>;
message = msg || (typeof m === "string" ? m : "");
if (reqId) meta.reqId = reqId;
Object.assign(meta, rest);
} else {
message = String(arg ?? "");
}
const parts = [`[${timestamp}]`, `[${level.toUpperCase()}]`, message];
let result = parts.join(" ");
const metaEntries = Object.entries(meta).filter(([k]) => k !== "reqId");
const reqId = meta.reqId;
if (reqId) result = `[${timestamp}] [${level.toUpperCase()}] [${reqId}] ${message}`;
if (metaEntries.length > 0) {
result += " " + JSON.stringify(Object.fromEntries(metaEntries));
}
return result;
}
function write(level: LogLevel, arg: unknown, msg?: string) {
if (!shouldLog(level)) return;
const formatted = formatMessage(level, arg, msg);
switch (level) {
case "trace":
case "debug":
console.debug(formatted);
break;
case "info":
console.info(formatted);
break;
case "warn":
console.warn(formatted);
break;
case "error":
case "fatal":
console.error(formatted);
break;
}
}
return {
get level() {
return currentLevel;
},
set level(l: string) {
currentLevel = l as LogLevel;
},
trace: (arg: unknown, msg?: string) => write("trace", arg, msg),
debug: (arg: unknown, msg?: string) => write("debug", arg, msg),
info: (arg: unknown, msg?: string) => write("info", arg, msg),
warn: (arg: unknown, msg?: string) => write("warn", arg, msg),
error: (arg: unknown, msg?: string) => write("error", arg, msg),
fatal: (arg: unknown, msg?: string) => write("fatal", arg, msg),
silent: () => {},
child: (newBindings: Record<string, unknown>) =>
createLogger({ ...bindings, ...newBindings }, currentLevel),
};
}
export const logger = createLogger({}, (process.env.LOG_LEVEL as LogLevel) || "info");

View File

@@ -0,0 +1,233 @@
CREATE TYPE "public"."achievement_status" AS ENUM('draft', 'published');--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('model', 'viewer', 'admin');--> statement-breakpoint
CREATE TYPE "public"."recording_status" AS ENUM('draft', 'published', 'archived');--> statement-breakpoint
CREATE TABLE "articles" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"title" text NOT NULL,
"excerpt" text,
"content" text,
"image" text,
"tags" text[] DEFAULT '{}',
"publish_date" timestamp DEFAULT now() NOT NULL,
"author" text,
"category" text,
"featured" boolean DEFAULT false,
"date_created" timestamp DEFAULT now() NOT NULL,
"date_updated" timestamp
);
--> statement-breakpoint
CREATE TABLE "comments" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "comments_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"collection" text NOT NULL,
"item_id" text NOT NULL,
"comment" text NOT NULL,
"user_id" text NOT NULL,
"date_created" timestamp DEFAULT now() NOT NULL,
"date_updated" timestamp
);
--> statement-breakpoint
CREATE TABLE "files" (
"id" text PRIMARY KEY NOT NULL,
"title" text,
"description" text,
"filename" text NOT NULL,
"mime_type" text,
"filesize" bigint,
"duration" integer,
"uploaded_by" text,
"date_created" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "achievements" (
"id" text PRIMARY KEY NOT NULL,
"code" text NOT NULL,
"name" text NOT NULL,
"description" text,
"icon" text,
"category" text,
"required_count" integer DEFAULT 1 NOT NULL,
"points_reward" integer DEFAULT 0 NOT NULL,
"status" "achievement_status" DEFAULT 'published' NOT NULL,
"sort" integer DEFAULT 0
);
--> statement-breakpoint
CREATE TABLE "user_achievements" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "user_achievements_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" text NOT NULL,
"achievement_id" text NOT NULL,
"progress" integer DEFAULT 0,
"date_unlocked" timestamp
);
--> statement-breakpoint
CREATE TABLE "user_points" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "user_points_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" text NOT NULL,
"action" text NOT NULL,
"points" integer NOT NULL,
"recording_id" text,
"date_created" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_stats" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "user_stats_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" text NOT NULL,
"total_raw_points" integer DEFAULT 0,
"total_weighted_points" real DEFAULT 0,
"recordings_count" integer DEFAULT 0,
"playbacks_count" integer DEFAULT 0,
"comments_count" integer DEFAULT 0,
"achievements_count" integer DEFAULT 0,
"last_updated" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "user_photos" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "user_photos_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" text NOT NULL,
"file_id" text NOT NULL,
"sort" integer DEFAULT 0
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"password_hash" text NOT NULL,
"first_name" text,
"last_name" text,
"artist_name" text,
"slug" text,
"description" text,
"tags" text[] DEFAULT '{}',
"role" "user_role" DEFAULT 'viewer' NOT NULL,
"avatar" text,
"banner" text,
"email_verified" boolean DEFAULT false NOT NULL,
"email_verify_token" text,
"password_reset_token" text,
"password_reset_expiry" timestamp,
"date_created" timestamp DEFAULT now() NOT NULL,
"date_updated" timestamp
);
--> statement-breakpoint
CREATE TABLE "video_likes" (
"id" text PRIMARY KEY NOT NULL,
"video_id" text NOT NULL,
"user_id" text NOT NULL,
"date_created" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "video_models" (
"video_id" text NOT NULL,
"user_id" text NOT NULL,
CONSTRAINT "video_models_video_id_user_id_pk" PRIMARY KEY("video_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "video_plays" (
"id" text PRIMARY KEY NOT NULL,
"video_id" text NOT NULL,
"user_id" text,
"session_id" text,
"duration_watched" integer,
"completed" boolean DEFAULT false,
"date_created" timestamp DEFAULT now() NOT NULL,
"date_updated" timestamp
);
--> statement-breakpoint
CREATE TABLE "videos" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"title" text NOT NULL,
"description" text,
"image" text,
"movie" text,
"tags" text[] DEFAULT '{}',
"upload_date" timestamp DEFAULT now() NOT NULL,
"premium" boolean DEFAULT false,
"featured" boolean DEFAULT false,
"likes_count" integer DEFAULT 0,
"plays_count" integer DEFAULT 0
);
--> statement-breakpoint
CREATE TABLE "recording_plays" (
"id" text PRIMARY KEY NOT NULL,
"recording_id" text NOT NULL,
"user_id" text,
"duration_played" integer DEFAULT 0,
"completed" boolean DEFAULT false,
"date_created" timestamp DEFAULT now() NOT NULL,
"date_updated" timestamp
);
--> statement-breakpoint
CREATE TABLE "recordings" (
"id" text PRIMARY KEY NOT NULL,
"title" text NOT NULL,
"description" text,
"slug" text NOT NULL,
"duration" integer NOT NULL,
"events" jsonb DEFAULT '[]'::jsonb,
"device_info" jsonb DEFAULT '[]'::jsonb,
"user_id" text NOT NULL,
"status" "recording_status" DEFAULT 'draft' NOT NULL,
"tags" text[] DEFAULT '{}',
"linked_video" text,
"featured" boolean DEFAULT false,
"public" boolean DEFAULT false,
"original_recording_id" text,
"date_created" timestamp DEFAULT now() NOT NULL,
"date_updated" timestamp
);
--> statement-breakpoint
ALTER TABLE "articles" ADD CONSTRAINT "articles_image_files_id_fk" FOREIGN KEY ("image") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "articles" ADD CONSTRAINT "articles_author_users_id_fk" FOREIGN KEY ("author") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_achievements" ADD CONSTRAINT "user_achievements_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_achievements" ADD CONSTRAINT "user_achievements_achievement_id_achievements_id_fk" FOREIGN KEY ("achievement_id") REFERENCES "public"."achievements"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_points" ADD CONSTRAINT "user_points_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_points" ADD CONSTRAINT "user_points_recording_id_recordings_id_fk" FOREIGN KEY ("recording_id") REFERENCES "public"."recordings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_stats" ADD CONSTRAINT "user_stats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_photos" ADD CONSTRAINT "user_photos_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_photos" ADD CONSTRAINT "user_photos_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_avatar_files_id_fk" FOREIGN KEY ("avatar") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_banner_files_id_fk" FOREIGN KEY ("banner") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "video_likes" ADD CONSTRAINT "video_likes_video_id_videos_id_fk" FOREIGN KEY ("video_id") REFERENCES "public"."videos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "video_likes" ADD CONSTRAINT "video_likes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "video_models" ADD CONSTRAINT "video_models_video_id_videos_id_fk" FOREIGN KEY ("video_id") REFERENCES "public"."videos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "video_models" ADD CONSTRAINT "video_models_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "video_plays" ADD CONSTRAINT "video_plays_video_id_videos_id_fk" FOREIGN KEY ("video_id") REFERENCES "public"."videos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "video_plays" ADD CONSTRAINT "video_plays_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "videos" ADD CONSTRAINT "videos_image_files_id_fk" FOREIGN KEY ("image") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "videos" ADD CONSTRAINT "videos_movie_files_id_fk" FOREIGN KEY ("movie") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recording_plays" ADD CONSTRAINT "recording_plays_recording_id_recordings_id_fk" FOREIGN KEY ("recording_id") REFERENCES "public"."recordings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recording_plays" ADD CONSTRAINT "recording_plays_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recordings" ADD CONSTRAINT "recordings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recordings" ADD CONSTRAINT "recordings_linked_video_videos_id_fk" FOREIGN KEY ("linked_video") REFERENCES "public"."videos"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "articles_slug_idx" ON "articles" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "articles_publish_date_idx" ON "articles" USING btree ("publish_date");--> statement-breakpoint
CREATE INDEX "articles_featured_idx" ON "articles" USING btree ("featured");--> statement-breakpoint
CREATE INDEX "comments_collection_item_idx" ON "comments" USING btree ("collection","item_id");--> statement-breakpoint
CREATE INDEX "comments_user_idx" ON "comments" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "files_uploaded_by_idx" ON "files" USING btree ("uploaded_by");--> statement-breakpoint
CREATE UNIQUE INDEX "achievements_code_idx" ON "achievements" USING btree ("code");--> statement-breakpoint
CREATE INDEX "user_achievements_user_idx" ON "user_achievements" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "user_achievements_unique_idx" ON "user_achievements" USING btree ("user_id","achievement_id");--> statement-breakpoint
CREATE INDEX "user_points_user_idx" ON "user_points" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "user_points_date_idx" ON "user_points" USING btree ("date_created");--> statement-breakpoint
CREATE UNIQUE INDEX "user_stats_user_idx" ON "user_stats" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "user_photos_user_idx" ON "user_photos" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");--> statement-breakpoint
CREATE UNIQUE INDEX "users_slug_idx" ON "users" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "users_role_idx" ON "users" USING btree ("role");--> statement-breakpoint
CREATE INDEX "video_likes_video_idx" ON "video_likes" USING btree ("video_id");--> statement-breakpoint
CREATE INDEX "video_likes_user_idx" ON "video_likes" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "video_plays_video_idx" ON "video_plays" USING btree ("video_id");--> statement-breakpoint
CREATE INDEX "video_plays_user_idx" ON "video_plays" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "video_plays_date_idx" ON "video_plays" USING btree ("date_created");--> statement-breakpoint
CREATE UNIQUE INDEX "videos_slug_idx" ON "videos" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "videos_upload_date_idx" ON "videos" USING btree ("upload_date");--> statement-breakpoint
CREATE INDEX "videos_featured_idx" ON "videos" USING btree ("featured");--> statement-breakpoint
CREATE INDEX "recording_plays_recording_idx" ON "recording_plays" USING btree ("recording_id");--> statement-breakpoint
CREATE INDEX "recording_plays_user_idx" ON "recording_plays" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "recordings_slug_idx" ON "recordings" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "recordings_user_idx" ON "recordings" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "recordings_status_idx" ON "recordings" USING btree ("status");--> statement-breakpoint
CREATE INDEX "recordings_public_idx" ON "recordings" USING btree ("public");

View File

@@ -0,0 +1,3 @@
ALTER TABLE "users" ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;--> statement-breakpoint
UPDATE "users" SET "is_admin" = true WHERE "role" = 'admin';--> statement-breakpoint
UPDATE "users" SET "role" = 'viewer' WHERE "role" = 'admin';

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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1772645674513,
"tag": "0000_pale_hellion",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"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;
}

View File

@@ -44,7 +44,7 @@ function copyFile(src: string, dest: string) {
async function migrateFiles() {
console.log("📁 Migrating files...");
const { rows } = await query(
`SELECT id, title, description, filename_disk, type, filesize, duration, uploaded_by, date_created
`SELECT id, title, description, filename_disk, type, filesize, duration, uploaded_by, uploaded_on as date_created
FROM directus_files`,
);
@@ -95,8 +95,8 @@ async function migrateUsers() {
console.log("👥 Migrating users...");
const { rows } = await query(
`SELECT u.id, u.email, u.password, u.first_name, u.last_name,
u.description, u.avatar, u.date_created,
u.artist_name, u.slug, u.email_notifications_key,
u.description, u.avatar, u.join_date as date_created,
u.artist_name, u.slug,
r.name as role_name
FROM directus_users u
LEFT JOIN directus_roles r ON u.role = r.id
@@ -126,9 +126,11 @@ async function migrateUsers() {
if (tagsRes.rows[0]?.tags) {
tags = Array.isArray(tagsRes.rows[0].tags)
? tagsRes.rows[0].tags
: JSON.parse(tagsRes.rows[0].tags || "[]");
: JSON.parse(String(tagsRes.rows[0].tags || "[]"));
}
} catch {}
} catch {
/* tags column may not exist on older Directus installs */
}
await query(
`INSERT INTO users (id, email, password_hash, first_name, last_name, artist_name, slug,
@@ -144,10 +146,10 @@ async function migrateUsers() {
user.artist_name,
user.slug,
user.description,
JSON.stringify(tags),
tags,
role,
user.avatar,
true, // Assume existing users are verified
true,
user.date_created,
],
);
@@ -160,7 +162,7 @@ async function migrateUsers() {
async function migrateUserPhotos() {
console.log("🖼️ Migrating user photos...");
const { rows } = await query(
`SELECT directus_users_id as user_id, directus_files_id as file_id, sort
`SELECT directus_users_id as user_id, directus_files_id as file_id
FROM junction_directus_users_files`,
);
@@ -173,7 +175,7 @@ async function migrateUserPhotos() {
await query(
`INSERT INTO user_photos (user_id, file_id, sort) VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING`,
[row.user_id, row.file_id, row.sort || 0],
[row.user_id, row.file_id, 0],
);
migrated++;
}
@@ -203,7 +205,7 @@ async function migrateArticles() {
article.excerpt,
article.content,
article.image,
Array.isArray(article.tags) ? JSON.stringify(article.tags) : article.tags,
Array.isArray(article.tags) ? article.tags : JSON.parse(String(article.tags || "[]")),
article.publish_date,
article.author,
article.category,
@@ -222,7 +224,7 @@ async function migrateVideos() {
console.log("🎬 Migrating videos...");
const { rows } = await query(
`SELECT id, slug, title, description, image, movie, tags, upload_date,
premium, featured, likes_count, plays_count
premium, featured
FROM sexy_videos`,
);
@@ -240,12 +242,12 @@ async function migrateVideos() {
video.description,
video.image,
video.movie,
Array.isArray(video.tags) ? JSON.stringify(video.tags) : video.tags,
Array.isArray(video.tags) ? video.tags : JSON.parse(String(video.tags || "[]")),
video.upload_date,
video.premium,
video.featured,
video.likes_count || 0,
video.plays_count || 0,
0,
0,
],
);
migrated++;
@@ -279,9 +281,7 @@ async function migrateVideoModels() {
async function migrateVideoLikes() {
console.log("❤️ Migrating video likes...");
const { rows } = await query(
`SELECT id, video_id, user_id, date_created FROM sexy_video_likes`,
);
const { rows } = await query(`SELECT id, video_id, user_id, date_created FROM sexy_video_likes`);
let migrated = 0;
for (const row of rows) {
@@ -329,7 +329,7 @@ async function migrateRecordings() {
console.log("🎙️ Migrating recordings...");
const { rows } = await query(
`SELECT id, title, description, slug, duration, events, device_info,
user_created as user_id, status, tags, linked_video, featured, public,
user_created as user_id, status, tags, linked_video, public,
original_recording_id, date_created, date_updated
FROM sexy_recordings`,
);
@@ -338,25 +338,24 @@ async function migrateRecordings() {
for (const recording of rows) {
await query(
`INSERT INTO recordings (id, title, description, slug, duration, events, device_info,
user_id, status, tags, linked_video, featured, public,
user_id, status, tags, linked_video, public,
original_recording_id, date_created, date_updated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (id) DO NOTHING`,
[
recording.id,
recording.title,
recording.description,
recording.slug,
recording.duration,
recording.duration != null ? Math.round(Number(recording.duration)) : null,
typeof recording.events === "string" ? recording.events : JSON.stringify(recording.events),
typeof recording.device_info === "string"
? recording.device_info
: JSON.stringify(recording.device_info),
recording.user_id,
recording.status,
Array.isArray(recording.tags) ? JSON.stringify(recording.tags) : recording.tags,
Array.isArray(recording.tags) ? recording.tags : JSON.parse(String(recording.tags || "[]")),
recording.linked_video,
recording.featured,
recording.public,
recording.original_recording_id,
recording.date_created,

View File

@@ -0,0 +1,27 @@
import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import path from "path";
const pool = new Pool({
connectionString: process.env.DATABASE_URL || "postgresql://sexy:sexy@localhost:5432/sexy",
});
const db = drizzle(pool);
async function main() {
console.log("Running schema migrations...");
// In dev (tsx): __dirname = src/scripts → migrations are at src/migrations
// In prod (node dist): __dirname = dist/scripts → migrations are at ../../migrations (package root)
const migrationsFolder = __dirname.includes("/src/")
? path.join(__dirname, "../migrations")
: path.join(__dirname, "../../migrations");
await migrate(db, { migrationsFolder });
console.log("Schema migrations complete.");
await pool.end();
}
main().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});

View File

@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",

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

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

View File

@@ -1,25 +1,27 @@
{
"name": "@sexy.pivoine.art/buttplug",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target bundler --release"
},
"dependencies": {
"eventemitter3": "^5.0.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-wasm": "3.5.0",
"ws": "^8.19.0"
},
"devDependencies": {
"wasm-pack": "^0.14.0"
}
"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",
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target web --release",
"serve": "node serve.mjs"
},
"dependencies": {
"eventemitter3": "^5.0.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-wasm": "3.5.0",
"ws": "^8.19.0"
},
"devDependencies": {
"wasm-pack": "^0.14.0"
}
}

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

@@ -6,11 +6,11 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
'use strict';
"use strict";
import { IButtplugClientConnector } from './IButtplugClientConnector';
import { ButtplugMessage } from '../core/Messages';
import { ButtplugBrowserWebsocketConnector } from '../utils/ButtplugBrowserWebsocketConnector';
import { type IButtplugClientConnector } from "./IButtplugClientConnector";
import { type ButtplugMessage } from "../core/Messages";
import { ButtplugBrowserWebsocketConnector } from "../utils/ButtplugBrowserWebsocketConnector";
export class ButtplugBrowserWebsocketClientConnector
extends ButtplugBrowserWebsocketConnector
@@ -18,7 +18,7 @@ export class ButtplugBrowserWebsocketClientConnector
{
public send = (msg: ButtplugMessage): void => {
if (!this.Connected) {
throw new Error('ButtplugClient not connected');
throw new Error("ButtplugClient not connected");
}
this.sendMessage(msg);
};

View File

@@ -6,20 +6,16 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
'use strict';
"use strict";
import { ButtplugLogger } from '../core/Logging';
import { EventEmitter } from 'eventemitter3';
import { ButtplugClientDevice } from './ButtplugClientDevice';
import { IButtplugClientConnector } from './IButtplugClientConnector';
import { ButtplugMessageSorter } from '../utils/ButtplugMessageSorter';
import * as Messages from '../core/Messages';
import {
ButtplugError,
ButtplugInitError,
ButtplugMessageError,
} from '../core/Exceptions';
import { ButtplugClientConnectorException } from './ButtplugClientConnectorException';
import { ButtplugLogger } from "../core/Logging";
import { EventEmitter } from "eventemitter3";
import { ButtplugClientDevice } from "./ButtplugClientDevice";
import { type IButtplugClientConnector } from "./IButtplugClientConnector";
import { ButtplugMessageSorter } from "../utils/ButtplugMessageSorter";
import * as Messages from "../core/Messages";
import { ButtplugError, ButtplugInitError, ButtplugMessageError } from "../core/Exceptions";
import { ButtplugClientConnectorException } from "./ButtplugClientConnectorException";
export class ButtplugClient extends EventEmitter {
protected _pingTimer: NodeJS.Timeout | null = null;
@@ -30,7 +26,7 @@ export class ButtplugClient extends EventEmitter {
protected _isScanning = false;
private _sorter: ButtplugMessageSorter = new ButtplugMessageSorter(true);
constructor(clientName = 'Generic Buttplug Client') {
constructor(clientName = "Generic Buttplug Client") {
super();
this._clientName = clientName;
this._logger.Debug(`ButtplugClient: Client ${clientName} created.`);
@@ -52,18 +48,16 @@ export class ButtplugClient extends EventEmitter {
}
public connect = async (connector: IButtplugClientConnector) => {
this._logger.Info(
`ButtplugClient: Connecting using ${connector.constructor.name}`
);
this._logger.Info(`ButtplugClient: Connecting using ${connector.constructor.name}`);
await connector.connect();
this._connector = connector;
this._connector.addListener('message', this.parseMessages);
this._connector.addListener('disconnect', this.disconnectHandler);
this._connector.addListener("message", this.parseMessages);
this._connector.addListener("disconnect", this.disconnectHandler);
await this.initializeConnection();
};
public disconnect = async () => {
this._logger.Debug('ButtplugClient: Disconnect called');
this._logger.Debug("ButtplugClient: Disconnect called");
this._devices.clear();
this.checkConnector();
await this.shutdownConnection();
@@ -71,25 +65,33 @@ export class ButtplugClient extends EventEmitter {
};
public startScanning = async () => {
this._logger.Debug('ButtplugClient: StartScanning called');
this._logger.Debug("ButtplugClient: StartScanning called");
this._isScanning = true;
await this.sendMsgExpectOk({ StartScanning: { Id: 1 } });
};
public stopScanning = async () => {
this._logger.Debug('ButtplugClient: StopScanning called');
this._logger.Debug("ButtplugClient: StopScanning called");
this._isScanning = false;
await this.sendMsgExpectOk({ StopScanning: { Id: 1 } });
};
public stopAllDevices = async () => {
this._logger.Debug('ButtplugClient: StopAllDevices');
await this.sendMsgExpectOk({ StopCmd: { Id: 1, DeviceIndex: undefined, FeatureIndex: undefined, Inputs: true, Outputs: true } });
this._logger.Debug("ButtplugClient: StopAllDevices");
await this.sendMsgExpectOk({
StopCmd: {
Id: 1,
DeviceIndex: undefined,
FeatureIndex: undefined,
Inputs: true,
Outputs: true,
},
});
};
protected disconnectHandler = () => {
this._logger.Info('ButtplugClient: Disconnect event receieved.');
this.emit('disconnect');
this._logger.Info("ButtplugClient: Disconnect event receieved.");
this.emit("disconnect");
};
protected parseMessages = (msgs: Messages.ButtplugMessage[]) => {
@@ -100,10 +102,10 @@ export class ButtplugClient extends EventEmitter {
break;
} else if (x.ScanningFinished !== undefined) {
this._isScanning = false;
this.emit('scanningfinished', x);
this.emit("scanningfinished", x);
} else if (x.InputReading !== undefined) {
// TODO this should be emitted from the device or feature, not the client
this.emit('inputreading', x);
this.emit("inputreading", x);
} else {
console.log(`Unhandled message: ${x}`);
}
@@ -112,21 +114,17 @@ export class ButtplugClient extends EventEmitter {
protected initializeConnection = async (): Promise<boolean> => {
this.checkConnector();
const msg = await this.sendMessage(
{
RequestServerInfo: {
ClientName: this._clientName,
Id: 1,
ProtocolVersionMajor: Messages.MESSAGE_SPEC_VERSION_MAJOR,
ProtocolVersionMinor: Messages.MESSAGE_SPEC_VERSION_MINOR
}
}
);
const msg = await this.sendMessage({
RequestServerInfo: {
ClientName: this._clientName,
Id: 1,
ProtocolVersionMajor: Messages.MESSAGE_SPEC_VERSION_MAJOR,
ProtocolVersionMinor: Messages.MESSAGE_SPEC_VERSION_MINOR,
},
});
if (msg.ServerInfo !== undefined) {
const serverinfo = msg as Messages.ServerInfo;
this._logger.Info(
`ButtplugClient: Connected to Server ${serverinfo.ServerName}`
);
this._logger.Info(`ButtplugClient: Connected to Server ${serverinfo.ServerName}`);
// TODO: maybe store server name, do something with message template version?
const ping = serverinfo.MaxPingTime;
// If the server version is lower than the client version, the server will disconnect here.
@@ -153,42 +151,37 @@ export class ButtplugClient extends EventEmitter {
throw ButtplugError.LogAndError(
ButtplugInitError,
this._logger,
`Cannot connect to server. ${err.ErrorMessage}`
`Cannot connect to server. ${err.ErrorMessage}`,
);
}
return false;
}
};
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
);
const device = ButtplugClientDevice.fromMsg(d, this.sendMessageClosure);
this._logger.Debug(`ButtplugClient: Adding Device: ${device}`);
this._devices.set(d.DeviceIndex, device);
this.emit('deviceadded', device);
this.emit("deviceadded", device);
} else {
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);
this.emit("deviceremoved", device);
}
}
}
};
protected requestDeviceList = async () => {
this.checkConnector();
this._logger.Debug('ButtplugClient: ReceiveDeviceList called');
const response = (await this.sendMessage(
{
RequestDeviceList: { Id: 1 }
}
));
this._logger.Debug("ButtplugClient: ReceiveDeviceList called");
const response = await this.sendMessage({
RequestDeviceList: { Id: 1 },
});
this.parseDeviceList(response.DeviceList!);
};
@@ -200,9 +193,7 @@ export class ButtplugClient extends EventEmitter {
}
};
protected async sendMessage(
msg: Messages.ButtplugMessage
): Promise<Messages.ButtplugMessage> {
protected async sendMessage(msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> {
this.checkConnector();
const p = this._sorter.PrepareOutgoingMessage(msg);
await this._connector!.send(msg);
@@ -211,15 +202,11 @@ export class ButtplugClient extends EventEmitter {
protected checkConnector() {
if (!this.connected) {
throw new ButtplugClientConnectorException(
'ButtplugClient not connected'
);
throw new ButtplugClientConnectorException("ButtplugClient not connected");
}
}
protected sendMsgExpectOk = async (
msg: Messages.ButtplugMessage
): Promise<void> => {
protected sendMsgExpectOk = async (msg: Messages.ButtplugMessage): Promise<void> => {
const response = await this.sendMessage(msg);
if (response.Ok !== undefined) {
return;
@@ -229,13 +216,13 @@ export class ButtplugClient extends EventEmitter {
throw ButtplugError.LogAndError(
ButtplugMessageError,
this._logger,
`Message ${response} not handled by SendMsgExpectOk`
`Message ${response} not handled by SendMsgExpectOk`,
);
}
};
protected sendMessageClosure = async (
msg: Messages.ButtplugMessage
msg: Messages.ButtplugMessage,
): Promise<Messages.ButtplugMessage> => {
return await this.sendMessage(msg);
};

View File

@@ -6,8 +6,8 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
import { ButtplugError } from '../core/Exceptions';
import * as Messages from '../core/Messages';
import { ButtplugError } from "../core/Exceptions";
import * as Messages from "../core/Messages";
export class ButtplugClientConnectorException extends ButtplugError {
public constructor(message: string) {

View File

@@ -6,22 +6,17 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
'use strict';
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';
"use strict";
import * as Messages from "../core/Messages";
import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } from "../core/Exceptions";
import { EventEmitter } from "eventemitter3";
import { ButtplugClientDeviceFeature } from "./ButtplugClientDeviceFeature";
import { type DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
/**
* Represents an abstract device, capable of taking certain kinds of messages.
*/
export class ButtplugClientDevice extends EventEmitter {
private _features: Map<number, ButtplugClientDeviceFeature>;
/**
@@ -58,9 +53,7 @@ export class ButtplugClientDevice extends EventEmitter {
public static fromMsg(
msg: Messages.DeviceInfo,
sendClosure: (
msg: Messages.ButtplugMessage
) => Promise<Messages.ButtplugMessage>
sendClosure: (msg: Messages.ButtplugMessage) => Promise<Messages.ButtplugMessage>,
): ButtplugClientDevice {
return new ButtplugClientDevice(msg, sendClosure);
}
@@ -72,25 +65,29 @@ export class ButtplugClientDevice extends EventEmitter {
*/
private constructor(
private _deviceInfo: Messages.DeviceInfo,
private _sendClosure: (
msg: Messages.ButtplugMessage
) => Promise<Messages.ButtplugMessage>
private _sendClosure: (msg: Messages.ButtplugMessage) => Promise<Messages.ButtplugMessage>,
) {
super();
this._features = new Map(Object.entries(_deviceInfo.DeviceFeatures).map(([index, v]) => [parseInt(index), new ButtplugClientDeviceFeature(_deviceInfo.DeviceIndex, _deviceInfo.DeviceName, v, _sendClosure)]));
this._features = new Map(
Object.entries(_deviceInfo.DeviceFeatures).map(([index, v]) => [
parseInt(index),
new ButtplugClientDeviceFeature(
_deviceInfo.DeviceIndex,
_deviceInfo.DeviceName,
v,
_sendClosure,
),
]),
);
}
public async send(
msg: Messages.ButtplugMessage
): Promise<Messages.ButtplugMessage> {
public async send(msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> {
// Assume we're getting the closure from ButtplugClient, which does all of
// the index/existence/connection/message checks for us.
return await this._sendClosure(msg);
}
protected sendMsgExpectOk = async (
msg: Messages.ButtplugMessage
): Promise<void> => {
protected sendMsgExpectOk = async (msg: Messages.ButtplugMessage): Promise<void> => {
const response = await this.send(msg);
if (response.Ok !== undefined) {
return;
@@ -108,25 +105,50 @@ export class ButtplugClientDevice extends EventEmitter {
};
protected isOutputValid(featureIndex: number, type: Messages.OutputType) {
if (!this._deviceInfo.DeviceFeatures.hasOwnProperty(featureIndex.toString())) {
throw new ButtplugDeviceError(`Feature index ${featureIndex} does not exist for device ${this.name}`);
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)) {
throw new ButtplugDeviceError(`Feature index ${featureIndex} does not support type ${type} for device ${this.name}`);
if (
this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs !== undefined &&
!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}`,
);
}
}
public hasOutput(type: Messages.OutputType): boolean {
return this._features.values().filter((f) => f.hasOutput(type)).toArray().length > 0;
return (
this._features
.values()
.filter((f) => f.hasOutput(type))
.toArray().length > 0
);
}
public hasInput(type: Messages.InputType): boolean {
return this._features.values().filter((f) => f.hasInput(type)).toArray().length > 0;
return (
this._features
.values()
.filter((f) => f.hasInput(type))
.toArray().length > 0
);
}
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));
}
@@ -138,15 +160,26 @@ export class ButtplugClientDevice extends EventEmitter {
}
public async stop(): Promise<void> {
await this.sendMsgExpectOk({StopCmd: { Id: 1, DeviceIndex: this.index, FeatureIndex: undefined, Inputs: true, Outputs: true}});
await this.sendMsgExpectOk({
StopCmd: {
Id: 1,
DeviceIndex: this.index,
FeatureIndex: undefined,
Inputs: true,
Outputs: true,
},
});
}
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.");
}
@@ -160,6 +193,6 @@ export class ButtplugClientDevice extends EventEmitter {
}
public emitDisconnected() {
this.emit('deviceremoved');
this.emit("deviceremoved");
}
}

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;
}
@@ -35,8 +35,7 @@ export class DeviceOutputCommand {
private _outputType: OutputType,
private _value: PercentOrSteps,
private _duration?: number,
)
{}
) {}
public get outputType() {
return this._outputType;
@@ -52,26 +51,36 @@ export class DeviceOutputCommand {
}
export class DeviceOutputValueConstructor {
public constructor(
private _outputType: OutputType)
{}
public constructor(private _outputType: OutputType) {}
public steps(steps: number): DeviceOutputCommand {
return new DeviceOutputCommand(this._outputType, PercentOrSteps.createSteps(steps), undefined);
}
public percent(percent: number): DeviceOutputCommand {
return new DeviceOutputCommand(this._outputType, PercentOrSteps.createPercent(percent), undefined);
return new DeviceOutputCommand(
this._outputType,
PercentOrSteps.createPercent(percent),
undefined,
);
}
}
export class DeviceOutputPositionWithDurationConstructor {
public steps(steps: number, duration: number): DeviceOutputCommand {
return new DeviceOutputCommand(OutputType.Position, PercentOrSteps.createSteps(steps), duration);
return new DeviceOutputCommand(
OutputType.Position,
PercentOrSteps.createSteps(steps),
duration,
);
}
public percent(percent: number, duration: number): DeviceOutputCommand {
return new DeviceOutputCommand(OutputType.HwPositionWithDuration, PercentOrSteps.createPercent(percent), duration);
return new DeviceOutputCommand(
OutputType.HwPositionWithDuration,
PercentOrSteps.createPercent(percent),
duration,
);
}
}

View File

@@ -1,25 +1,20 @@
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(
private _deviceIndex: number,
private _deviceName: string,
private _feature: Messages.DeviceFeature,
private _sendClosure: (
msg: Messages.ButtplugMessage
) => Promise<Messages.ButtplugMessage>) {
}
private _sendClosure: (msg: Messages.ButtplugMessage) => Promise<Messages.ButtplugMessage>,
) {}
protected send = async (msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> => {
return await this._sendClosure(msg);
}
};
protected sendMsgExpectOk = async (
msg: Messages.ButtplugMessage
): Promise<void> => {
protected sendMsgExpectOk = async (msg: Messages.ButtplugMessage): Promise<void> => {
const response = await this.send(msg);
if (response.Ok !== undefined) {
return;
@@ -31,14 +26,24 @@ export class ButtplugClientDeviceFeature {
};
protected isOutputValid(type: Messages.OutputType) {
if (this._feature.Output !== undefined && !this._feature.Output.hasOwnProperty(type)) {
throw new ButtplugDeviceError(`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`);
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}`,
);
}
}
protected isInputValid(type: Messages.InputType) {
if (this._feature.Input !== undefined && !this._feature.Input.hasOwnProperty(type)) {
throw new ButtplugDeviceError(`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`);
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}`,
);
}
}
@@ -49,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) {
@@ -58,24 +63,24 @@ 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,
FeatureIndex: this._feature.FeatureIndex,
Command: outCommand
}
Command: outCommand,
},
};
await this.sendMsgExpectOk(cmd);
}
@@ -112,43 +117,52 @@ 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;
}
public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
if (this._feature.Output !== undefined && this._feature.Output.hasOwnProperty(cmd.outputType.toString())) {
if (
this._feature.Output !== undefined &&
Object.prototype.hasOwnProperty.call(this._feature.Output, cmd.outputType.toString())
) {
return this.sendOutputCmd(cmd);
}
throw new ButtplugDeviceError(`Output type ${cmd.outputType} not supported by feature.`);
}
public async runInput(inputType: Messages.InputType, inputCommand: Messages.InputCommandType): Promise<Messages.InputReading | undefined> {
public async runInput(
inputType: Messages.InputType,
inputCommand: Messages.InputCommandType,
): 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 && !inputAttributes.Command.includes(Messages.InputCommandType.Subscribe)) && !inputAttributes.Command.includes(inputCommand)) {
if (
inputCommand === Messages.InputCommandType.Unsubscribe &&
!inputAttributes.Command.includes(Messages.InputCommandType.Subscribe) &&
!inputAttributes.Command.includes(inputCommand)
) {
throw new ButtplugDeviceError(`${inputType} does not support command ${inputCommand}`);
}
let cmd: Messages.ButtplugMessage = {
const cmd: Messages.ButtplugMessage = {
InputCmd: {
Id: 1,
DeviceIndex: this._deviceIndex,
FeatureIndex: this._feature.FeatureIndex,
Type: inputType,
Command: inputCommand,
}
},
};
if (inputCommand == Messages.InputCommandType.Read) {
const response = await this.send(cmd);

View File

@@ -6,12 +6,11 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
'use strict';
"use strict";
import { ButtplugBrowserWebsocketClientConnector } from './ButtplugBrowserWebsocketClientConnector';
import { WebSocket as NodeWebSocket } from 'ws';
import { ButtplugBrowserWebsocketClientConnector } from "./ButtplugBrowserWebsocketClientConnector";
import { WebSocket as NodeWebSocket } from "ws";
export class ButtplugNodeWebsocketClientConnector extends ButtplugBrowserWebsocketClientConnector {
protected _websocketConstructor =
NodeWebSocket as unknown as typeof WebSocket;
protected _websocketConstructor = NodeWebSocket as unknown as typeof WebSocket;
}

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

@@ -6,8 +6,8 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
import * as Messages from './Messages';
import { ButtplugLogger } from './Logging';
import * as Messages from "./Messages";
import { type ButtplugLogger } from "./Logging";
export class ButtplugError extends Error {
public get ErrorClass(): Messages.ErrorClass {
@@ -27,16 +27,16 @@ export class ButtplugError extends Error {
Error: {
Id: this.Id,
ErrorCode: this.ErrorClass,
ErrorMessage: this.message
}
}
ErrorMessage: this.message,
},
};
}
public static LogAndError<T extends ButtplugError>(
constructor: new (str: string, num: number) => T,
logger: ButtplugLogger,
message: string,
id: number = Messages.SYSTEM_MESSAGE_ID
id: number = Messages.SYSTEM_MESSAGE_ID,
): T {
logger.Error(message);
return new constructor(message, id);
@@ -67,7 +67,7 @@ export class ButtplugError extends Error {
message: string,
errorClass: Messages.ErrorClass,
id: number = Messages.SYSTEM_MESSAGE_ID,
inner?: Error
inner?: Error,
) {
super(message);
this.errorClass = errorClass;

View File

@@ -6,7 +6,7 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
import { EventEmitter } from 'eventemitter3';
import { EventEmitter } from "eventemitter3";
export enum ButtplugLogLevel {
Off,
@@ -69,9 +69,7 @@ export class LogMessage {
* Returns a formatted string with timestamp, level, and message.
*/
public get FormattedMessage() {
return `${ButtplugLogLevel[this.logLevel]} : ${this.timestamp} : ${
this.logMessage
}`;
return `${ButtplugLogLevel[this.logLevel]} : ${this.timestamp} : ${this.logMessage}`;
}
}
@@ -176,10 +174,7 @@ export class ButtplugLogger extends EventEmitter {
*/
protected AddLogMessage(msg: string, level: ButtplugLogLevel) {
// If nothing wants the log message we have, ignore it.
if (
level > this.maximumEventLogLevel &&
level > this.maximumConsoleLogLevel
) {
if (level > this.maximumEventLogLevel && level > this.maximumConsoleLogLevel) {
return;
}
const logMsg = new LogMessage(msg, level);
@@ -191,7 +186,7 @@ export class ButtplugLogger extends EventEmitter {
console.log(logMsg.FormattedMessage);
}
if (level <= this.maximumEventLogLevel) {
this.emit('log', logMsg);
this.emit("log", logMsg);
}
}
}

View File

@@ -7,9 +7,9 @@
*/
// tslint:disable:max-classes-per-file
'use strict';
"use strict";
import { ButtplugMessageError } from './Exceptions';
import { ButtplugMessageError } from "./Exceptions";
export const SYSTEM_MESSAGE_ID = 0;
export const DEFAULT_MESSAGE_ID = 1;
@@ -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;
@@ -132,34 +132,34 @@ export interface DeviceList {
}
export enum OutputType {
Unknown = 'Unknown',
Vibrate = 'Vibrate',
Rotate = 'Rotate',
Oscillate = 'Oscillate',
Constrict = 'Constrict',
Inflate = 'Inflate',
Position = 'Position',
HwPositionWithDuration = 'HwPositionWithDuration',
Temperature = 'Temperature',
Spray = 'Spray',
Led = 'Led',
Unknown = "Unknown",
Vibrate = "Vibrate",
Rotate = "Rotate",
Oscillate = "Oscillate",
Constrict = "Constrict",
Inflate = "Inflate",
Position = "Position",
HwPositionWithDuration = "HwPositionWithDuration",
Temperature = "Temperature",
Spray = "Spray",
Led = "Led",
}
export enum InputType {
Unknown = 'Unknown',
Battery = 'Battery',
RSSI = 'RSSI',
Button = 'Button',
Pressure = 'Pressure',
Unknown = "Unknown",
Battery = "Battery",
RSSI = "RSSI",
Button = "Button",
Pressure = "Pressure",
// Temperature,
// Accelerometer,
// Gyro,
}
export enum InputCommandType {
Read = 'Read',
Subscribe = 'Subscribe',
Unsubscribe = 'Unsubscribe',
Read = "Read",
Subscribe = "Subscribe",
Unsubscribe = "Unsubscribe",
}
export interface DeviceFeatureInput {

View File

@@ -1,4 +1,4 @@
declare module "*.json" {
const content: string;
export default content;
const content: string;
export default content;
}

View File

@@ -6,27 +6,24 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
import { ButtplugMessage } from './core/Messages';
import { IButtplugClientConnector } from './client/IButtplugClientConnector';
import { EventEmitter } from 'eventemitter3';
import { type ButtplugMessage } from "./core/Messages";
import { type IButtplugClientConnector } from "./client/IButtplugClientConnector";
import { EventEmitter } from "eventemitter3";
export * from './client/ButtplugClient';
export * from './client/ButtplugClientDevice';
export * from './client/ButtplugBrowserWebsocketClientConnector';
export * from './client/ButtplugNodeWebsocketClientConnector';
export * from './client/ButtplugClientConnectorException';
export * from './utils/ButtplugMessageSorter';
export * from './client/ButtplugClientDeviceCommand';
export * from './client/ButtplugClientDeviceFeature';
export * from './client/IButtplugClientConnector';
export * from './core/Messages';
export * from './core/Logging';
export * from './core/Exceptions';
export * from "./client/ButtplugClient";
export * from "./client/ButtplugClientDevice";
export * from "./client/ButtplugBrowserWebsocketClientConnector";
export * from "./client/ButtplugNodeWebsocketClientConnector";
export * from "./client/ButtplugClientConnectorException";
export * from "./utils/ButtplugMessageSorter";
export * from "./client/ButtplugClientDeviceCommand";
export * from "./client/ButtplugClientDeviceFeature";
export * from "./client/IButtplugClientConnector";
export * from "./core/Messages";
export * from "./core/Logging";
export * from "./core/Exceptions";
export class ButtplugWasmClientConnector
extends EventEmitter
implements IButtplugClientConnector
{
export class ButtplugWasmClientConnector extends EventEmitter implements IButtplugClientConnector {
private static _loggingActivated = false;
private static wasmInstance;
private _connected: boolean = false;
@@ -43,35 +40,32 @@ export class ButtplugWasmClientConnector
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;
}
};
public static activateLogging = async (logLevel: string = 'debug') => {
public static activateLogging = async (logLevel: string = "debug") => {
await ButtplugWasmClientConnector.maybeLoadWasm();
if (this._loggingActivated) {
console.log('Logging already activated, ignoring.');
console.log("Logging already activated, ignoring.");
return;
}
console.log('Turning on logging.');
ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger(
logLevel,
);
console.log("Turning on logging.");
ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger(logLevel);
};
public initialize = async (): Promise<void> => {};
public connect = async (): Promise<void> => {
await ButtplugWasmClientConnector.maybeLoadWasm();
this.client =
ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server(
(msgs) => {
this.emitMessage(msgs);
},
this.serverPtr,
);
this.client = ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server(
(msgs) => {
this.emitMessage(msgs);
},
this.serverPtr,
);
this._connected = true;
};
@@ -80,7 +74,7 @@ export class ButtplugWasmClientConnector
public send = (msg: ButtplugMessage): void => {
ButtplugWasmClientConnector.wasmInstance.buttplug_client_send_json_message(
this.client,
new TextEncoder().encode('[' + JSON.stringify(msg) + ']'),
new TextEncoder().encode("[" + JSON.stringify(msg) + "]"),
(output) => {
this.emitMessage(output);
},
@@ -90,6 +84,6 @@ export class ButtplugWasmClientConnector
private emitMessage = (msg: Uint8Array) => {
const str = new TextDecoder().decode(msg);
const msgs: ButtplugMessage[] = JSON.parse(str);
this.emit('message', msgs);
this.emit("message", msgs);
};
}

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

@@ -6,10 +6,10 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
'use strict';
"use strict";
import { EventEmitter } from 'eventemitter3';
import { ButtplugMessage } from '../core/Messages';
import { EventEmitter } from "eventemitter3";
import { type ButtplugMessage } from "../core/Messages";
export class ButtplugBrowserWebsocketConnector extends EventEmitter {
protected _ws: WebSocket | undefined;
@@ -26,18 +26,20 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
public connect = async (): Promise<void> => {
return new Promise<void>((resolve, reject) => {
const ws = new (this._websocketConstructor ?? WebSocket)(this._url);
const onErrorCallback = (event: Event) => {reject(event)}
const onCloseCallback = (event: CloseEvent) => reject(event.reason)
ws.addEventListener('open', async () => {
const onErrorCallback = (event: Event) => {
reject(event);
};
const onCloseCallback = (event: CloseEvent) => reject(event.reason);
ws.addEventListener("open", async () => {
this._ws = ws;
try {
await this.initialize();
this._ws.addEventListener('message', (msg) => {
this._ws.addEventListener("message", (msg) => {
this.parseIncomingMessage(msg);
});
this._ws.removeEventListener('close', onCloseCallback);
this._ws.removeEventListener('error', onErrorCallback);
this._ws.addEventListener('close', this.disconnect);
this._ws.removeEventListener("close", onCloseCallback);
this._ws.removeEventListener("error", onErrorCallback);
this._ws.addEventListener("close", this.disconnect);
resolve();
} catch (e) {
reject(e);
@@ -47,8 +49,8 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
// browsers usually only throw Error Code 1006. It's up to those using this
// library to state what the problem might be.
ws.addEventListener('error', onErrorCallback)
ws.addEventListener('close', onCloseCallback);
ws.addEventListener("error", onErrorCallback);
ws.addEventListener("close", onCloseCallback);
});
};
@@ -58,14 +60,14 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
}
this._ws!.close();
this._ws = undefined;
this.emit('disconnect');
this.emit("disconnect");
};
public sendMessage(msg: ButtplugMessage) {
if (!this.Connected) {
throw new Error('ButtplugBrowserWebsocketConnector not connected');
throw new Error("ButtplugBrowserWebsocketConnector not connected");
}
this._ws!.send('[' + JSON.stringify(msg) + ']');
this._ws!.send("[" + JSON.stringify(msg) + "]");
}
public initialize = async (): Promise<void> => {
@@ -73,9 +75,9 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
};
protected parseIncomingMessage(event: MessageEvent) {
if (typeof event.data === 'string') {
if (typeof event.data === "string") {
const msgs: ButtplugMessage[] = JSON.parse(event.data);
this.emit('message', msgs);
this.emit("message", msgs);
} else if (event.data instanceof Blob) {
// No-op, we only use text message types.
}
@@ -83,6 +85,6 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
protected onReaderLoad(event: Event) {
const msgs: ButtplugMessage[] = JSON.parse((event.target as FileReader).result as string);
this.emit('message', msgs);
this.emit("message", msgs);
}
}

View File

@@ -6,8 +6,8 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
import * as Messages from '../core/Messages';
import { ButtplugError } from '../core/Exceptions';
import * as Messages from "../core/Messages";
import { ButtplugError } from "../core/Exceptions";
export class ButtplugMessageSorter {
protected _counter = 1;
@@ -21,9 +21,7 @@ export class ButtplugMessageSorter {
// One of the places we should actually return a promise, as we need to store
// them while waiting for them to return across the line.
// tslint:disable:promise-function-async
public PrepareOutgoingMessage(
msg: Messages.ButtplugMessage
): Promise<Messages.ButtplugMessage> {
public PrepareOutgoingMessage(msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> {
if (this._useCounter) {
Messages.setMsgId(msg, this._counter);
// Always increment last, otherwise we might lose sync
@@ -31,22 +29,18 @@ export class ButtplugMessageSorter {
}
let res;
let rej;
const msgPromise = new Promise<Messages.ButtplugMessage>(
(resolve, reject) => {
res = resolve;
rej = reject;
}
);
const msgPromise = new Promise<Messages.ButtplugMessage>((resolve, reject) => {
res = resolve;
rej = reject;
});
this._waitingMsgs.set(Messages.msgId(msg), [res, rej]);
return msgPromise;
}
public ParseIncomingMessages(
msgs: Messages.ButtplugMessage[]
): Messages.ButtplugMessage[] {
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

@@ -1,3 +1,3 @@
export function getRandomInt(max: number) {
return Math.floor(Math.random() * Math.floor(max));
return Math.floor(Math.random() * Math.floor(max));
}

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,11 +1,11 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"outDir": "dist",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"outDir": "dist",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}

View File

@@ -3,19 +3,19 @@ import path from "path";
import wasm from "vite-plugin-wasm";
export default defineConfig({
plugins: [wasm()], // include wasm plugin
build: {
lib: {
entry: path.resolve(__dirname, "src/index.ts"),
name: "buttplug",
fileName: "index",
formats: ["es"], // this is important
},
minify: false, // for demo purposes
target: "esnext", // this is important as well
outDir: "dist",
rollupOptions: {
external: [/\.\/wasm\//, /\.\.\/wasm\//],
},
},
plugins: [wasm()], // include wasm plugin
build: {
lib: {
entry: path.resolve(__dirname, "src/index.ts"),
name: "buttplug",
fileName: "index",
formats: ["es"], // this is important
},
minify: false, // for demo purposes
target: "esnext", // this is important as well
outDir: "dist",
rollupOptions: {
external: [/\.\/wasm\//, /\.\.\/wasm\//],
},
},
});

View File

@@ -1,16 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

View File

@@ -1,16 +1,16 @@
{
"$schema": "https://unpkg.com/jsrepo@2.4.9/schemas/project-config.json",
"repos": ["@ieedan/shadcn-svelte-extras"],
"includeTests": false,
"includeDocs": false,
"watermark": true,
"formatter": "prettier",
"configFiles": {},
"paths": {
"*": "$lib/blocks",
"ui": "$lib/components/ui",
"actions": "$lib/actions",
"hooks": "$lib/hooks",
"utils": "$lib/utils"
}
"$schema": "https://unpkg.com/jsrepo@2.4.9/schemas/project-config.json",
"repos": ["@ieedan/shadcn-svelte-extras"],
"includeTests": false,
"includeDocs": false,
"watermark": true,
"formatter": "prettier",
"configFiles": {},
"paths": {
"*": "$lib/blocks",
"ui": "$lib/components/ui",
"actions": "$lib/actions",
"hooks": "$lib/hooks",
"utils": "$lib/utils"
}
}

View File

@@ -1,20 +1,21 @@
{
"name": "@sexy.pivoine.art/frontend",
"version": "1.0.0",
"author": "valknarogg",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"start": "node ./build"
"start": "node ./build",
"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",
"@lucide/svelte": "^0.577.0",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.53.4",
@@ -28,22 +29,22 @@
"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",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"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",
"javascript-time-ago": "^2.6.4",
"marked": "^17.0.4",
"media-chrome": "^4.18.0",
"svelte-i18n": "^4.0.1"
}

View File

@@ -3,85 +3,94 @@
@plugin "@iconify/tailwind4";
@utility scrollbar-none {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant hover (&:hover);
@theme {
--animate-vibrate: vibrate 0.3s linear infinite;
--animate-fade-in: fadeIn 0.3s ease-out;
--animate-slide-up: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--animate-zoom-in: zoomIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--animate-pulse-glow: pulseGlow 2s infinite;
--animate-vibrate: vibrate 0.3s linear infinite;
--animate-fade-in: fadeIn 0.3s ease-out;
--animate-slide-up: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--animate-zoom-in: zoomIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--animate-pulse-glow: pulseGlow 2s infinite;
@keyframes vibrate {
0% {
transform: translate(0);
}
@keyframes vibrate {
0% {
transform: translate(0);
}
20% {
transform: translate(-2px, 2px);
}
20% {
transform: translate(-2px, 2px);
}
40% {
transform: translate(-2px, -2px);
}
40% {
transform: translate(-2px, -2px);
}
60% {
transform: translate(2px, 2px);
}
60% {
transform: translate(2px, 2px);
}
80% {
transform: translate(2px, -2px);
}
80% {
transform: translate(2px, -2px);
}
100% {
transform: translate(0);
}
}
100% {
transform: translate(0);
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
100% {
opacity: 1;
}
}
@keyframes slideUp {
0% {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
@keyframes slideUp {
0% {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes zoomIn {
0% {
opacity: 0;
transform: scale(0.9);
}
@keyframes zoomIn {
0% {
opacity: 0;
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulseGlow {
0%,
100% {
boxShadow: 0 0 20px rgba(183, 0, 217, 0.3);
}
@keyframes pulseGlow {
0%,
100% {
boxshadow: 0 0 20px rgba(183, 0, 217, 0.3);
}
50% {
boxShadow: 0 0 40px rgba(183, 0, 217, 0.6);
}
}
50% {
boxshadow: 0 0 40px rgba(183, 0, 217, 0.6);
}
}
}
/*
@@ -93,134 +102,159 @@
color utility to any element that depends on these defaults.
*/
@layer base {
* {
@supports (color: color-mix(in lab, red, red)) {
outline-color: color-mix(in oklab, var(--ring) 50%, transparent);
}
}
* {
@supports (color: color-mix(in lab, red, red)) {
outline-color: color-mix(in oklab, var(--ring) 50%, transparent);
}
}
* {
border-color: var(--border);
outline-color: var(--ring);
}
* {
border-color: var(--border);
outline-color: var(--ring);
scrollbar-width: thin;
scrollbar-color: color-mix(in oklab, var(--primary) 40%, transparent) transparent;
}
.prose h2 {
@apply text-2xl font-bold mt-8 mb-4 text-foreground;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.prose h3 {
@apply text-xl font-semibold mt-6 mb-3 text-foreground;
}
*::-webkit-scrollbar-track {
background: transparent;
}
.prose p {
@apply mb-4 leading-relaxed;
}
*::-webkit-scrollbar-thumb {
background-color: color-mix(in oklab, var(--primary) 40%, transparent);
border-radius: 9999px;
}
.prose ul {
@apply mb-4 pl-6;
}
*::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in oklab, var(--primary) 70%, transparent);
}
.prose li {
@apply mb-2;
}
html {
scrollbar-width: thin;
scrollbar-color: color-mix(in oklab, var(--primary) 40%, transparent) transparent;
}
.prose h2 {
@apply text-2xl font-bold mt-8 mb-4 text-foreground;
}
.prose h3 {
@apply text-xl font-semibold mt-6 mb-3 text-foreground;
}
.prose p {
@apply mb-4 leading-relaxed;
}
.prose ul {
@apply mb-4 pl-6;
}
.prose li {
@apply mb-2;
}
}
:root {
--default-font-family: "Noto Sans", sans-serif;
--background: oklch(0.98 0.01 320);
--foreground: oklch(0.08 0.02 280);
--muted: oklch(0.95 0.01 280);
--muted-foreground: oklch(0.4 0.02 280);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--card: oklch(0.99 0.005 320);
--card-foreground: oklch(0.08 0.02 280);
--border: oklch(0.85 0.02 280);
--input: oklch(0.922 0 0);
--primary: oklch(56.971% 0.27455 319.257);
--primary-foreground: oklch(0.98 0.01 320);
--secondary: oklch(0.92 0.02 260);
--secondary-foreground: oklch(0.15 0.05 260);
--accent: oklch(0.45 0.35 280);
--accent-foreground: oklch(0.98 0.01 280);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--ring: oklch(0.55 0.3 320);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--default-font-family: "Noto Sans", sans-serif;
--background: oklch(0.98 0.01 320);
--foreground: oklch(0.08 0.02 280);
--muted: oklch(0.95 0.01 280);
--muted-foreground: oklch(0.4 0.02 280);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--card: oklch(0.99 0.005 320);
--card-foreground: oklch(0.08 0.02 280);
--border: oklch(0.85 0.02 280);
--input: oklch(0.922 0 0);
--primary: oklch(56.971% 0.27455 319.257);
--primary-foreground: oklch(0.98 0.01 320);
--secondary: oklch(0.92 0.02 260);
--secondary-foreground: oklch(0.15 0.05 260);
--accent: oklch(0.45 0.35 280);
--accent-foreground: oklch(0.98 0.01 280);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--ring: oklch(0.55 0.3 320);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.08 0.02 280);
--foreground: oklch(0.98 0.01 280);
--muted: oklch(0.12 0.03 280);
--muted-foreground: oklch(0.6 0.02 280);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--card: oklch(0.1 0.02 280);
--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-foreground: oklch(0.98 0.01 320);
--secondary: oklch(0.15 0.05 260);
--secondary-foreground: oklch(0.9 0.02 260);
--accent: oklch(0.55 0.3 280);
--accent-foreground: oklch(0.98 0.01 280);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--ring: oklch(0.65 0.25 320);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 0.1);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.08 0.02 280);
--foreground: oklch(0.98 0.01 280);
--muted: oklch(0.12 0.03 280);
--muted-foreground: oklch(0.6 0.02 280);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--card: oklch(0.1 0.02 280);
--card-foreground: oklch(0.95 0.01 280);
--border: oklch(0.2 0.05 280);
--input: oklch(1 0 0 / 0.15);
--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);
--accent: oklch(0.55 0.3 280);
--accent-foreground: oklch(0.98 0.01 280);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--ring: oklch(0.65 0.25 320);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 0.1);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
}

View File

@@ -4,22 +4,22 @@ import type { AuthStatus } from "$lib/types";
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
authStatus: AuthStatus;
requestId: string;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
interface Window {
sidebar: {
addPanel: () => void;
};
opera: object;
}
namespace App {
// interface Error {}
interface Locals {
authStatus: AuthStatus;
requestId: string;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
interface Window {
sidebar: {
addPanel: () => void;
};
opera: object;
}
}
export {};

View File

@@ -1,24 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<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" rel="stylesheet">
<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=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet"
/>
<link rel="manifest" href="/site.webmanifest" />
%sveltekit.head%
</head>
</head>
<body data-sveltekit-preload-data="hover" class="dark">
<body data-sveltekit-preload-data="hover" class="dark">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
</body>
</html>

View File

@@ -2,96 +2,92 @@ import { isAuthenticated } from "$lib/services";
import { logger, generateRequestId } from "$lib/logger";
import type { Handle } from "@sveltejs/kit";
// Log startup info once
let hasLoggedStartup = false;
if (!hasLoggedStartup) {
logger.startup();
hasLoggedStartup = true;
}
// Log startup info once (module-level code runs exactly once on import)
logger.startup();
export const handle: Handle = async ({ event, resolve }) => {
const { cookies, locals, url, request } = event;
const startTime = Date.now();
const { cookies, locals, url, request } = event;
const startTime = Date.now();
// Generate unique request ID
const requestId = generateRequestId();
// Generate unique request ID
const requestId = generateRequestId();
// Add request ID to locals for access in other handlers
locals.requestId = requestId;
// Add request ID to locals for access in other handlers
locals.requestId = requestId;
// Log incoming request
logger.request(request.method, url.pathname, {
requestId,
context: {
userAgent: request.headers.get('user-agent')?.substring(0, 100),
referer: request.headers.get('referer'),
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
},
});
// Log incoming request
logger.request(request.method, url.pathname, {
requestId,
context: {
userAgent: request.headers.get("user-agent")?.substring(0, 100),
referer: request.headers.get("referer"),
ip: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"),
},
});
// Handle authentication
const token = cookies.get("session_token");
// Handle authentication
const token = cookies.get("session_token");
if (token) {
try {
locals.authStatus = await isAuthenticated(token);
if (token) {
try {
locals.authStatus = await isAuthenticated(token);
if (locals.authStatus.authenticated) {
logger.auth('Token validated', true, {
requestId,
userId: locals.authStatus.user?.id,
context: {
email: locals.authStatus.user?.email,
role: locals.authStatus.user?.role,
},
});
} else {
logger.auth('Token invalid', false, { requestId });
}
} catch (error) {
logger.error('Authentication check failed', {
requestId,
error: error instanceof Error ? error : new Error(String(error)),
});
locals.authStatus = { authenticated: false };
}
} else {
logger.debug('No session token found', { requestId });
locals.authStatus = { authenticated: false };
}
if (locals.authStatus.authenticated) {
logger.auth("Token validated", true, {
requestId,
userId: locals.authStatus.user?.id,
context: {
email: locals.authStatus.user?.email,
role: locals.authStatus.user?.role,
},
});
} else {
logger.auth("Token invalid", false, { requestId });
}
} catch (error) {
logger.error("Authentication check failed", {
requestId,
error: error instanceof Error ? error : new Error(String(error)),
});
locals.authStatus = { authenticated: false };
}
} else {
logger.debug("No session token found", { requestId });
locals.authStatus = { authenticated: false };
}
// Resolve the request
let response: Response;
try {
response = await resolve(event, {
filterSerializedResponseHeaders: (key) => {
return key.toLowerCase() === "content-type";
},
});
} catch (error) {
const duration = Date.now() - startTime;
logger.error('Request handler error', {
requestId,
method: request.method,
path: url.pathname,
duration,
error: error instanceof Error ? error : new Error(String(error)),
});
throw error;
}
// Resolve the request
let response: Response;
try {
response = await resolve(event, {
filterSerializedResponseHeaders: (key) => {
return key.toLowerCase() === "content-type";
},
});
} catch (error) {
const duration = Date.now() - startTime;
logger.error("Request handler error", {
requestId,
method: request.method,
path: url.pathname,
duration,
error: error instanceof Error ? error : new Error(String(error)),
});
throw error;
}
// Log response
const duration = Date.now() - startTime;
logger.response(request.method, url.pathname, response.status, duration, {
requestId,
userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined,
context: {
cached: response.headers.get('x-sveltekit-page') === 'true',
},
});
// Log response
const duration = Date.now() - startTime;
logger.response(request.method, url.pathname, response.status, duration, {
requestId,
userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined,
context: {
cached: response.headers.get("x-sveltekit-page") === "true",
},
});
// Add request ID to response headers (useful for debugging)
response.headers.set('x-request-id', requestId);
// Add request ID to response headers (useful for debugging)
response.headers.set("x-request-id", requestId);
return response;
return response;
};

View File

@@ -11,7 +11,7 @@ export const getGraphQLClient = (fetchFn?: typeof globalThis.fetch) =>
});
export const getAssetUrl = (
id: string,
id: string | null | undefined,
transform?: "mini" | "thumbnail" | "preview" | "medium" | "banner",
) => {
if (!id) {

View File

@@ -1,77 +1,69 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import { onMount } from "svelte";
import { _ } from "svelte-i18n";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import { onMount } from "svelte";
const AGE_VERIFICATION_KEY = "age-verified";
const AGE_VERIFICATION_KEY = "age-verified";
let isOpen = true;
let isOpen = $state(false);
function handleAgeConfirmation() {
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
isOpen = false;
}
function handleAgeConfirmation() {
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
isOpen = false;
}
onMount(() => {
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
if (storedVerification === "true") {
isOpen = false;
}
});
onMount(() => {
if (localStorage.getItem(AGE_VERIFICATION_KEY) !== "true") {
isOpen = true;
}
});
</script>
<Dialog bind:open={isOpen}>
<DialogContent
class="sm:max-w-md"
onInteractOutside={(e) => e.preventDefault()}
showCloseButton={false}
>
<DialogHeader class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
>
<span class="text-primary-foreground text-sm"
>{$_("age_verification_dialog.age")}</span
>
</div>
<div class="">
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
>{$_("age_verification_dialog.title")}</DialogTitle
>
<DialogDescription class="text-left text-sm">
{$_("age_verification_dialog.description")}
</DialogDescription>
</div>
</div>
</div>
</DialogHeader>
<Separator class="my-4" />
<!-- Close Button -->
<div class="flex justify-end gap-4">
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
{$_("age_verification_dialog.exit")}
</Button>
<Button
variant="default"
size="sm"
onclick={handleAgeConfirmation}
class="cursor-pointer"
<DialogContent
class="sm:max-w-md"
onInteractOutside={(e) => e.preventDefault()}
showCloseButton={false}
>
<DialogHeader class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
>
<span class="text-primary-foreground text-sm">{$_("age_verification_dialog.age")}</span>
</div>
<div class="">
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
>{$_("age_verification_dialog.title")}</DialogTitle
>
<span class="icon-[ri--check-line]"></span>
{$_("age_verification_dialog.confirm")}
</Button>
<DialogDescription class="text-left text-sm">
{$_("age_verification_dialog.description")}
</DialogDescription>
</div>
</div>
</DialogContent>
</div>
</DialogHeader>
<Separator class="my-4" />
<!-- Close Button -->
<div class="flex justify-end gap-4">
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
{$_("age_verification_dialog.exit")}
</Button>
<Button variant="default" size="sm" onclick={handleAgeConfirmation} class="cursor-pointer">
<span class="icon-[ri--check-line]"></span>
{$_("age_verification_dialog.confirm")}
</Button>
</div>
</DialogContent>
</Dialog>

View File

@@ -1,55 +1,55 @@
<!-- Advanced Plasma Background -->
<div class="absolute inset-0 pointer-events-none">
<!-- Primary gradient layers -->
<div
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
></div>
<div
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
></div>
<!-- Primary gradient layers -->
<div
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
></div>
<div
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
></div>
<!-- Large floating orbs -->
<!-- <div
<!-- Large floating orbs -->
<!-- <div
class="absolute top-20 left-20 w-80 h-80 bg-gradient-to-br from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-slow"
></div>
<div
class="absolute bottom-20 right-20 w-96 h-96 bg-gradient-to-tl from-accent/12 via-primary/18 to-accent/8 rounded-full blur-3xl animate-blob-slow animation-delay-6000"
></div> -->
<!-- Medium morphing elements -->
<!-- <div
<!-- Medium morphing elements -->
<!-- <div
class="absolute top-1/2 left-1/3 w-64 h-64 bg-gradient-to-r from-primary/10 via-accent/15 to-primary/8 rounded-full blur-2xl animate-blob-reverse animation-delay-3000"
></div>
<div
class="absolute bottom-1/3 right-1/3 w-72 h-72 bg-gradient-to-l from-accent/10 via-primary/15 to-accent/8 rounded-full blur-2xl animate-blob-reverse animation-delay-9000"
></div> -->
<!-- Soft particle effects -->
<!-- <div
<!-- Soft particle effects -->
<!-- <div
class="absolute top-1/4 right-1/4 w-48 h-48 bg-gradient-to-br from-primary/15 to-accent/12 rounded-full blur-xl animate-float animation-delay-2000"
></div>
<div
class="absolute bottom-1/4 left-1/4 w-56 h-56 bg-gradient-to-tl from-accent/15 to-primary/12 rounded-full blur-xl animate-float animation-delay-8000"
></div> -->
<!-- Premium glassmorphism overlay -->
<!-- <div
<!-- Premium glassmorphism overlay -->
<!-- <div
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/3 backdrop-blur-[1px]"
></div> -->
<!-- Animated Plasma Background -->
<div
class="absolute top-1/3 left-1/3 w-72 h-72 bg-gradient-to-r from-accent/20 via-primary/25 to-accent/15 rounded-full blur-2xl animate-blob"
></div>
<div
class="absolute bottom-1/3 right-1/3 w-88 h-88 bg-gradient-to-r from-primary/20 via-accent/25 to-primary/15 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
></div>
<div
class="absolute top-1/2 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/20 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1000"
></div>
<!-- Animated Plasma Background -->
<div
class="absolute top-1/3 left-1/3 w-72 h-72 bg-gradient-to-r from-accent/20 via-primary/25 to-accent/15 rounded-full blur-2xl animate-blob"
></div>
<div
class="absolute bottom-1/3 right-1/3 w-88 h-88 bg-gradient-to-r from-primary/20 via-accent/25 to-primary/15 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
></div>
<div
class="absolute top-1/2 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/20 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1000"
></div>
<!-- Global Plasma Background -->
<!-- <div
<!-- Global Plasma Background -->
<!-- <div
class="absolute top-32 right-32 w-72 h-72 bg-gradient-to-r from-accent/18 via-primary/22 to-accent/12 rounded-full blur-3xl animate-blob"
></div>
<div

View File

@@ -1,12 +1,8 @@
<script lang="ts">
const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
</script>
<button
class="block rounded-full cursor-pointer"
onclick={onclick}
aria-label={label}
>
<button class="block rounded-full cursor-pointer" {onclick} aria-label={label}>
<div
class="relative flex overflow-hidden items-center justify-center rounded-full w-[50px] h-[50px] transform transition-all duration-200 shadow-md opacity-90 translate-x-3"
>
@@ -14,23 +10,23 @@ const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
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' : ''}`}
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

@@ -1,99 +1,92 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { Slider } from "$lib/components/ui/slider";
import { Label } from "$lib/components/ui/label";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import type { BluetoothDevice } from "$lib/types";
import { _ } from "svelte-i18n";
import { cn } from "$lib/utils";
import { Slider } from "$lib/components/ui/slider";
import { Label } from "$lib/components/ui/label";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import type { BluetoothDevice } from "$lib/types";
import { _ } from "svelte-i18n";
interface Props {
device: BluetoothDevice;
onChange: (scalarIndex: number, val: number) => void;
onStop: () => void;
}
interface Props {
device: BluetoothDevice;
onChange: (scalarIndex: number, val: number) => void;
onStop: () => void;
}
let { device, onChange, onStop }: Props = $props();
let { device, onChange, onStop }: Props = $props();
function getBatteryColor(level: number) {
if (!device.hasBattery) {
return "text-gray-400";
}
if (level > 60) return "text-green-400";
if (level > 30) return "text-yellow-400";
return "text-red-400";
}
function getBatteryColor(level: number) {
if (!device.hasBattery) {
return "text-gray-400";
}
if (level > 60) return "text-green-400";
if (level > 30) return "text-yellow-400";
return "text-red-400";
}
function getBatteryBgColor(level: number) {
if (!device.hasBattery) {
return "bg-gray-400/20";
}
if (level > 60) return "bg-green-400/20";
if (level > 30) return "bg-yellow-400/20";
return "bg-red-400/20";
}
function getBatteryBgColor(level: number) {
if (!device.hasBattery) {
return "bg-gray-400/20";
}
if (level > 60) return "bg-green-400/20";
if (level > 30) return "bg-yellow-400/20";
return "bg-red-400/20";
}
function getScalarAnimations() {
return device.actuators
.filter((a) => a.value > 0)
.map((a) => `animate-${a.outputType.toLowerCase()}`);
}
function getScalarAnimations() {
return device.actuators
.filter((a) => a.value > 0)
.map((a) => `animate-${a.outputType.toLowerCase()}`);
}
function isActive() {
return device.actuators.some((a) => a.value > 0);
}
function isActive() {
return device.actuators.some((a) => a.value > 0);
}
</script>
<Card
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
>
<CardHeader class="pb-3">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
>
<span class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}></span>
</div>
<div>
<h3
class={`font-semibold text-card-foreground group-hover:text-primary transition-colors`}
>
{device.name}
</h3>
<!-- <p class="text-sm text-muted-foreground">
<CardHeader class="pb-3">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
>
<span
class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}
></span>
</div>
<div>
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
{device.name}
</h3>
<!-- <p class="text-sm text-muted-foreground">
{device.deviceType}
</p> -->
</div>
</div>
<button class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`} onclick={() => isActive() && onStop()}>
<div class="relative">
<div
class="w-2 h-2 rounded-full {isActive()
? 'bg-green-400'
: 'bg-red-400'}"
></div>
{#if isActive()}
<div
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
></div>
{/if}
</div>
<span
class="text-xs font-medium {isActive()
? 'text-green-400'
: 'text-red-400'}"
>
{isActive()
? $_("device_card.active")
: $_("device_card.paused")}
</span>
</button>
</div>
</CardHeader>
</div>
<button
class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`}
onclick={() => isActive() && onStop()}
>
<div class="relative">
<div class="w-2 h-2 rounded-full {isActive() ? 'bg-green-400' : 'bg-red-400'}"></div>
{#if isActive()}
<div
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
></div>
{/if}
</div>
<span class="text-xs font-medium {isActive() ? 'text-green-400' : 'text-red-400'}">
{isActive() ? $_("device_card.active") : $_("device_card.paused")}
</span>
</button>
</div>
</CardHeader>
<CardContent class="space-y-4">
<!-- Current Value -->
<!-- <div
<CardContent class="space-y-4">
<!-- Current Value -->
<!-- <div
class="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/30"
>
<span class="text-sm text-muted-foreground"
@@ -103,58 +96,54 @@ function isActive() {
>
</div> -->
<!-- Battery Level -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(
device.batteryLevel,
)}"
></span>
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
</div>
{#if device.hasBattery}
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
{device.batteryLevel}%
</span>
{/if}
</div>
<div class="w-full bg-muted/50 rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 {getBatteryBgColor(
device.batteryLevel,
)} bg-gradient-to-r from-current to-current/80"
style="width: {device.batteryLevel}%"
></div>
</div>
<!-- Battery Level -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(device.batteryLevel)}"
></span>
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
</div>
{#if device.hasBattery}
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
{device.batteryLevel}%
</span>
{/if}
</div>
<div class="w-full bg-muted/50 rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 {getBatteryBgColor(
device.batteryLevel,
)} bg-gradient-to-r from-current to-current/80"
style="width: {device.batteryLevel}%"
></div>
</div>
</div>
<!-- Last Seen -->
<!-- <div
<!-- Last Seen -->
<!-- <div
class="flex items-center justify-between text-xs text-muted-foreground"
>
<span>{$_("device_card.last_seen")}</span>
<span>{device.lastSeen.toLocaleTimeString()}</span>
</div> -->
<!-- Action Button -->
{#each device.actuators as actuator, idx}
<div class="space-y-2">
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
>{$_(
`device_card.actuator_types.${actuator.outputType.toLowerCase()}`,
)}</Label
>
<Slider
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
type="single"
value={actuator.value}
onValueChange={(val) => onChange(idx, val)}
max={actuator.maxSteps}
step={1}
/>
</div>
{/each}
</CardContent>
<!-- Action Button -->
{#each device.actuators as actuator, idx (idx)}
<div class="space-y-2">
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
>{$_(`device_card.actuator_types.${actuator.outputType.toLowerCase()}`)}</Label
>
<Slider
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
type="single"
value={actuator.value}
onValueChange={(val) => onChange(idx, val)}
max={actuator.maxSteps}
step={1}
/>
</div>
{/each}
</CardContent>
</Card>

View File

@@ -1,121 +1,120 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
import Logo from "../logo/logo.svelte";
import { _ } from "svelte-i18n";
import Logo from "../logo/logo.svelte";
</script>
<footer
class="bg-gradient-to-t from-card/95 to-card/85 backdrop-blur-xl mt-20 shadow-2xl shadow-primary/10"
class="bg-gradient-to-t from-card/95 to-card/85 backdrop-blur-xl mt-20 shadow-2xl shadow-primary/10"
>
<div class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="space-y-4">
<div class="flex items-center gap-3 text-xl font-bold">
<Logo />
</div>
<p class="text-sm text-muted-foreground">{$_("brand.description")}</p>
<div class="flex gap-3">
<a
aria-label="Email"
href="mailto:{$_('footer.contact.email')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--mail-line] w-4 h-4 text-primary"></span>
</a>
<a
aria-label="X"
href="https://www.x.com/{$_('footer.contact.x')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--twitter-x-line] w-4 h-4 text-primary"></span>
</a>
<a
aria-label="YouTube"
href="https://www.youtube.com/@{$_('footer.contact.youtube')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--youtube-line] w-4 h-4 text-primary"></span>
</a>
</div>
</div>
<!-- Quick Links -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">
{$_("footer.quick_links")}
</h3>
<div class="space-y-2">
<a
href="/models"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.models")}</a
>
<a
href="/videos"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.videos")}</a
>
<a
href="/magazine"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.magazine")}</a
>
<a
href="/about"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.about")}</a
>
</div>
</div>
<!-- Support -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">{$_("footer.support")}</h3>
<div class="space-y-2">
<a
href="mailto:{$_('footer.contact_support_email')}"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.contact_support")}</a
>
<a
href="mailto:{$_('footer.model_applications_email')}"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.model_applications")}</a
>
<a
href="/faq"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.faq")}</a
>
</div>
</div>
<!-- Legal -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">{$_("footer.legal")}</h3>
<div class="space-y-2">
<a
href="/legal"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.privacy_policy")}</a
>
<a
href="/legal"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.terms_of_service")}</a
>
<a
href="/imprint"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.imprint")}</a
>
</div>
</div>
<div class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="space-y-4">
<div class="flex items-center gap-3 text-xl font-bold">
<Logo />
</div>
<div class="border-t border-border/50 mt-8 pt-8 text-center">
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p>
<p class="text-sm text-muted-foreground">{$_("brand.description")}</p>
<div class="flex gap-3">
<a
aria-label="Email"
href="mailto:{$_('footer.contact.email')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--mail-line] w-4 h-4 text-primary"></span>
</a>
<a
aria-label="X"
href="https://www.x.com/{$_('footer.contact.x')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--twitter-x-line] w-4 h-4 text-primary"></span>
</a>
<a
aria-label="YouTube"
href="https://www.youtube.com/@{$_('footer.contact.youtube')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--youtube-line] w-4 h-4 text-primary"></span>
</a>
</div>
</div>
<!-- Quick Links -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">
{$_("footer.quick_links")}
</h3>
<div class="space-y-2">
<a
href="/models"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.models")}</a
>
<a
href="/videos"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.videos")}</a
>
<a
href="/magazine"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.magazine")}</a
>
<a
href="/about"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.about")}</a
>
</div>
</div>
<!-- Support -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">{$_("footer.support")}</h3>
<div class="space-y-2">
<a
href="mailto:{$_('footer.contact_support_email')}"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.contact_support")}</a
>
<a
href="mailto:{$_('footer.model_applications_email')}"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.model_applications")}</a
>
<a
href="/faq"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.faq")}</a
>
</div>
</div>
<!-- Legal -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">{$_("footer.legal")}</h3>
<div class="space-y-2">
<a
href="/legal"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.privacy_policy")}</a
>
<a
href="/legal"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.terms_of_service")}</a
>
<a
href="/imprint"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.imprint")}</a
>
</div>
</div>
</div>
<div class="border-t border-border/50 mt-8 pt-8 text-center">
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p>
</div>
</div>
</footer>

View File

@@ -1,120 +0,0 @@
<div class="w-full h-auto">
<svg
version="1.0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1280.000000 904.000000"
stroke-width="5"
stroke="#ce47eb"
preserveAspectRatio="xMidYMid meet"
>
<metadata>
Created by potrace 1.15, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,904.000000) scale(0.100000,-0.100000)">
<path
d="M7930 7043 c-73 -10 -95 -18 -134 -51 -25 -20 -66 -53 -91 -72 -26
-19 -69 -66 -96 -104 -116 -164 -130 -314 -59 -664 32 -164 36 -217 18 -256
-13 -30 -14 -30 -140 -52 -75 -12 -105 -13 -129 -5 -18 6 -59 11 -93 11 -123
-1 -213 -66 -379 -275 -245 -308 -501 -567 -686 -693 l-92 -64 -82 7 c-53 5
-88 13 -100 23 -21 18 -66 20 -167 7 -73 -9 -124 -31 -159 -69 -22 -23 -23
-31 -18 -94 6 -58 4 -71 -11 -84 -44 -40 -203 -119 -295 -149 -56 -18 -144
-50 -195 -71 -50 -21 -138 -51 -195 -67 -232 -65 -369 -131 -595 -284 -182
-124 -172 -123 -208 -27 -23 60 -39 81 -189 245 -279 305 -319 354 -368 458
-46 94 -47 98 -32 127 8 16 15 36 15 43 0 8 14 41 30 72 17 31 30 63 30 70 0
7 7 18 15 25 8 7 15 26 15 42 0 42 15 65 49 71 17 4 37 17 46 30 14 23 14 30
-9 101 -28 88 -21 130 22 141 20 5 23 10 18 31 -4 13 -1 34 5 46 13 25 33 239
31 336 0 42 -8 78 -23 108 -31 65 -121 158 -209 217 -41 28 -77 55 -79 60 -2
5 -17 24 -33 43 -23 26 -48 39 -111 58 -183 55 -239 61 -361 36 -156 -33 -333
-185 -425 -368 -72 -143 -93 -280 -96 -622 -2 -240 -5 -288 -24 -379 -12 -57
-30 -120 -40 -140 -11 -20 -61 -84 -113 -142 -52 -58 -105 -121 -118 -140 -13
-19 -45 -58 -72 -88 -93 -106 -127 -193 -237 -616 -33 -127 -67 -251 -76 -275
-9 -25 -48 -153 -86 -285 -78 -264 -163 -502 -334 -935 -135 -340 -194 -526
-290 -910 -20 -80 -47 -180 -61 -223 -13 -43 -24 -92 -24 -109 0 -42 -43 -79
-132 -112 -56 -20 -108 -52 -213 -132 -77 -58 -162 -117 -190 -131 -85 -43
-107 -75 -62 -89 12 -3 30 -15 40 -25 10 -11 30 -19 45 -19 29 0 146 52 175
77 9 9 19 14 22 12 2 -3 -21 -24 -51 -47 -55 -43 -63 -59 -42 -80 30 -30 130
5 198 69 54 52 127 109 139 109 20 0 11 -27 -25 -80 -38 -56 -38 -74 0 -91 33
-16 67 7 135 89 31 37 70 71 95 84 l42 20 82 -21 c45 -11 95 -21 111 -21 17 0
50 -11 75 -25 58 -32 136 -35 166 -5 35 35 26 57 -40 90 -59 30 -156 132 -186
195 -30 63 -31 124 -3 258 43 213 95 336 279 657 126 219 231 423 267 520 14
36 40 128 58 205 19 77 50 185 69 240 55 159 182 450 195 447 7 -1 9 7 5 23
-10 38 0 30 37 -30 42 -69 60 -53 28 27 -36 92 -39 98 -34 98 3 0 14 -18 25
-41 14 -26 26 -39 35 -35 9 3 28 -22 59 -81 65 -121 162 -266 237 -353 35 -41
174 -196 309 -345 359 -394 379 -421 409 -549 25 -103 90 -214 169 -287 74
-67 203 -135 332 -173 110 -33 472 -112 575 -125 325 -44 688 -30 1453 54 172
19 352 35 400 35 112 1 156 11 272 66 139 66 171 103 171 197 0 64 -11 95 -52
141 -17 20 -30 38 -28 39 2 1 13 7 24 13 11 6 21 23 23 38 2 14 12 31 23 36
12 7 19 21 19 38 0 19 7 30 23 37 14 6 23 21 25 39 2 16 10 36 18 44 10 9 13
24 9 41 -4 20 -1 28 16 36 58 26 47 86 -21 106 -38 12 -40 14 -40 51 0 51 -18
82 -82 145 -73 70 -132 105 -358 213 -547 260 -919 419 -1210 517 -13 5 -13 6
0 10 8 3 22 13 30 22 23 26 363 124 434 125 l60 1 21 -85 c29 -118 59 -175
129 -245 118 -117 234 -156 461 -158 171 -1 271 17 445 80 268 96 361 157 602
396 93 92 171 159 246 209 155 105 513 381 595 458 131 122 189 224 277 485
109 325 149 342 163 70 9 -163 30 -242 143 -531 53 -137 98 -258 101 -270 3
-14 -5 -28 -29 -46 -18 -14 -94 -80 -168 -147 -137 -123 -261 -216 -306 -227
-17 -4 -46 4 -92 27 -60 29 -80 34 -192 41 -69 4 -144 11 -166 14 -103 15
-115 -61 -15 -95 19 -6 46 -11 61 -11 44 0 91 -20 88 -38 -2 -8 -15 -24 -30
-35 -22 -17 -30 -18 -42 -7 -21 16 -46 6 -46 -19 0 -25 -29 -35 -110 -35 -57
-1 -65 -3 -68 -21 -4 -29 44 -54 120 -62 35 -3 66 -12 71 -19 4 -7 31 -25 59
-39 41 -21 60 -24 93 -19 25 3 45 2 49 -4 3 -5 34 -9 69 -7 52 1 72 7 108 32
58 40 97 59 135 66 32 6 462 230 516 269 18 12 33 17 35 12 2 -6 30 -62 62
-126 l58 -116 -3 -112 c-2 -61 -6 -115 -9 -119 -2 -5 -100 -8 -217 -8 -221 0
-452 -23 -868 -88 -85 -13 -225 -33 -310 -45 -189 -26 -314 -52 -440 -92 -203
-65 -284 -132 -304 -254 -15 -90 30 -173 137 -251 28 -20 113 -85 187 -142 74
-58 171 -129 215 -158 105 -71 324 -181 563 -283 106 -45 194 -86 197 -90 9
-14 -260 -265 -361 -337 -100 -71 -130 -102 -188 -193 -16 -24 -53 -73 -82
-107 -30 -35 -67 -89 -83 -121 -20 -41 -63 -92 -135 -163 -86 -87 -106 -112
-112 -144 -4 -22 -15 -53 -26 -70 -23 -38 -23 -73 -1 -105 39 -56 94 -81 132
-60 18 9 21 8 21 -9 0 -33 11 -51 41 -67 20 -10 35 -12 46 -5 13 7 21 3 36
-15 11 -14 29 -24 44 -24 15 0 34 -7 44 -16 9 -8 27 -16 40 -16 13 -1 33 -8
44 -15 11 -7 29 -13 40 -13 50 0 129 132 140 232 21 203 78 389 136 444 17 16
51 56 74 89 89 124 200 212 433 343 l142 81 14 -27 c16 -32 36 -151 36 -220 0
-35 6 -54 21 -71 43 -46 143 -68 168 -37 6 8 14 37 18 65 5 46 11 56 47 85 23
18 61 44 86 58 91 53 151 145 153 234 0 38 -5 50 -33 79 -19 19 -53 42 -77 51
-24 9 -43 19 -43 23 0 3 28 24 62 46 81 52 213 178 298 284 63 79 75 89 148
122 l80 37 32 -49 c79 -122 233 -192 370 -170 222 37 395 196 428 396 18 107
35 427 30 560 -9 217 -63 344 -223 514 -52 56 -95 106 -95 111 0 5 4 12 10 15
55 34 235 523 290 785 10 52 28 118 39 145 10 28 29 103 41 169 27 142 24 271
-7 352 -28 72 -115 215 -185 303 -65 82 -118 184 -125 241 -11 82 59 182 93
135 9 -12 17 -14 31 -7 10 6 25 7 33 2 8 -4 27 -6 41 -3 28 5 44 45 33 80 -5
15 -4 15 4 4 12 -17 17 -6 76 144 39 99 43 100 22 10 -8 -33 -13 -62 -10 -64
10 -10 65 154 83 249 6 30 16 80 22 110 19 85 16 216 -5 278 -11 32 -22 50
-29 45 -7 -4 -8 0 -3 13 4 10 4 15 0 12 -6 -7 -89 109 -89 124 0 4 -6 13 -14
20 -10 10 -12 10 -7 1 14 -24 -10 -13 -40 19 -16 17 -23 27 -15 23 9 -5 12 -4
8 2 -11 18 -131 71 -188 82 -50 11 -127 14 -259 12 -25 -1 -57 -7 -72 -15 -17
-9 -28 -11 -28 -4 0 6 -9 8 -22 3 -13 -4 -31 -7 -41 -6 -9 0 -15 -4 -12 -9 3
-6 0 -7 -8 -4 -20 7 -127 -84 -176 -149 -43 -57 -111 -185 -111 -208 0 -19
-55 -135 -69 -143 -6 -4 -11 -12 -11 -18 0 -19 29 13 66 73 19 33 37 59 40 59
10 0 -65 -126 -103 -173 -30 -36 -39 -53 -30 -59 9 -6 9 -8 0 -8 -9 0 -10 -7
-2 -27 6 -16 10 -29 10 -30 -1 -11 23 -63 29 -63 4 0 20 10 36 22 30 24 26 14
-13 -39 -13 -18 -20 -33 -14 -33 19 0 74 65 97 115 13 27 24 43 24 34 0 -25
-21 -81 -42 -111 -23 -34 -23 -46 0 -25 18 16 19 14 21 -70 3 -183 25 -289 76
-381 26 -46 33 -96 15 -107 -6 -3 -86 -17 -178 -30 -240 -35 -301 -61 -360
-152 -62 -96 -73 -147 -83 -378 -9 -214 -20 -312 -32 -285 -20 45 -77 356 -91
492 -18 174 -34 243 -72 325 -58 121 -120 163 -243 163 -63 0 -80 3 -85 16
-11 29 -6 103 13 196 43 209 51 282 51 479 -1 301 -22 464 -76 571 -32 64
-132 168 -191 200 -79 43 -224 72 -303 61z m2438 -421 c18 -14 38 -35 44 -46
9 -16 -39 22 -102 82 -11 11 27 -13 58 -36z m142 -188 c17 -52 7 -51 -11 1 -9
25 -13 42 -8 40 4 -3 13 -21 19 -41z m-1000 -42 c0 -5 -7 -17 -15 -28 -14 -18
-14 -17 -4 9 12 27 19 34 19 19z m1037 -14 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13
3 -3 4 -12 1 -19z m10 -40 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13 3 -3 4 -12 1
-19z m-53 -327 c-4 -23 -9 -40 -11 -37 -3 3 -2 23 2 46 4 23 9 39 11 37 3 -2
2 -23 -2 -46z m-17 -73 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13 3 -3 4 -12 1 -19z
m-3487 -790 c-17 -35 -55 -110 -84 -168 -29 -58 -72 -163 -96 -235 -45 -134
-64 -175 -84 -175 -6 1 -23 18 -38 40 -31 44 -71 60 -155 60 -29 0 -53 3 -52
8 0 4 63 59 141 122 182 149 293 258 347 343 24 37 45 67 47 67 3 0 -10 -28
-26 -62z m-4768 -415 c-37 -46 -160 -176 -140 -148 21 29 160 185 165 185 3 0
-9 -17 -25 -37z m38 -52 c-11 -21 -30 -37 -30 -25 0 8 30 44 37 44 2 0 -1 -9
-7 -19z m1692 -588 c22 -30 39 -56 36 -58 -5 -5 -107 115 -122 143 -15 28 42
-29 86 -85z m-100 -108 c6 -11 -13 3 -42 30 -28 28 -56 59 -62 70 -6 11 13 -2
42 -30 28 -27 56 -59 62 -70z m1587 -1 c29 -6 22 -10 -71 -40 -57 -19 -128
-41 -158 -49 -58 -15 -288 -41 -296 -33 -2 3 23 19 56 37 45 24 98 40 208 61
153 29 208 34 261 24z m-860 -1488 c150 -59 299 -94 495 -114 l68 -7 -42 -27
-42 -28 -111 20 c-62 11 -196 28 -300 38 -103 10 -189 21 -192 23 -2 3 -1 21
4 40 5 19 12 46 15 62 4 15 9 27 13 27 3 0 45 -15 92 -34z m3893 -371 l37 -6
-55 -72 c-31 -40 -59 -72 -62 -73 -4 -1 -51 44 -104 100 l-97 101 122 -22 c67
-13 139 -25 159 -28z"
/>
</g>
</svg>
</div>

View File

@@ -1,56 +1,53 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { page } from "$app/state";
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
import { Button } from "$lib/components/ui/button";
import type { AuthStatus } from "$lib/types";
import { logout } from "$lib/services";
import { goto } from "$app/navigation";
import { getAssetUrl, isModel } from "$lib/directus";
import LogoutButton from "../logout-button/logout-button.svelte";
import Separator from "../ui/separator/separator.svelte";
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
import Girls from "../girls/girls.svelte";
import Logo from "../logo/logo.svelte";
import { _ } from "svelte-i18n";
import { page } from "$app/state";
import { Button } from "$lib/components/ui/button";
import type { AuthStatus } from "$lib/types";
import { logout } from "$lib/services";
import { goto } from "$app/navigation";
import { getAssetUrl } from "$lib/api";
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";
interface Props {
authStatus: AuthStatus;
}
interface Props {
authStatus: AuthStatus;
}
let { authStatus }: Props = $props();
let { authStatus }: Props = $props();
let isMobileMenuOpen = $state(false);
let isMobileMenuOpen = $state(false);
const navLinks = [
{ name: $_("header.home"), href: "/" },
{ name: $_("header.models"), href: "/models" },
{ name: $_("header.videos"), href: "/videos" },
{ name: $_("header.magazine"), href: "/magazine" },
{ name: $_("header.about"), href: "/about" },
];
const navLinks = [
{ name: $_("header.home"), href: "/" },
{ name: $_("header.models"), href: "/models" },
{ name: $_("header.videos"), href: "/videos" },
{ name: $_("header.magazine"), href: "/magazine" },
{ name: $_("header.about"), href: "/about" },
];
async function handleLogout() {
closeMenu();
await logout();
goto("/login", { invalidateAll: true });
}
async function handleLogout() {
closeMenu();
await logout();
goto("/login", { invalidateAll: true });
}
function closeMenu() {
isMobileMenuOpen = false;
}
function closeMenu() {
isMobileMenuOpen = false;
}
function isActiveLink(link: any) {
return (
(page.url.pathname === "/" && link === navLinks[0]) ||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
);
}
function isActiveLink(link: { name?: string; href: string }) {
return (
(page.url.pathname === "/" && link === navLinks[0]) ||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
);
}
</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">
@@ -59,336 +56,301 @@ function isActiveLink(link: any) {
href="/"
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
>
<Logo hideName={true} />
<Logo />
</a>
<!-- Desktop Navigation -->
<nav class="hidden w-full lg:flex items-center justify-center gap-8">
{#each navLinks as link}
{#each navLinks as link (link.href)}
<a
href={link.href}
class={`text-sm hover:text-foreground transition-colors duration-200 font-medium relative group ${
isActiveLink(link) ? 'text-foreground' : 'text-foreground/85'
isActiveLink(link) ? "text-foreground" : "text-foreground/85"
}`}
>
{link.name}
<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(link) ? 'w-full' : 'group-hover:w-full'}`}
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink(link) ? "w-full" : "group-hover:w-full"}`}
></span>
</a>
{/each}
</nav>
<!-- Desktop Login Button -->
<!-- Auth Actions -->
{#if authStatus.authenticated}
<div class="w-full flex items-center justify-end">
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
<!-- Notifications -->
<!-- <Button variant="ghost" size="sm" class="relative h-9 w-9 rounded-full p-0 hover:bg-background/80">
<BellIcon class="h-4 w-4" />
<Badge class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-gradient-to-r from-primary to-accent p-0 text-xs text-primary-foreground">3</Badge>
<span class="sr-only">Notifications</span>
</Button> -->
<!-- <Separator orientation="vertical" class="mx-1 h-6 bg-border/50" /> -->
<!-- User Actions -->
<Button
variant="link"
size="icon"
class={`hidden sm:flex 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={`hidden sm:flex 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')}
title={$_("header.play")}
>
<span class="icon-[ri--rocket-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: '/play' }) ? 'w-full' : 'group-hover:w-full'}`}
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: "/play" }) ? "w-full" : "group-hover:w-full"}`}
></span>
<span class="sr-only">{$_('header.play')}</span>
<span class="sr-only">{$_("header.play")}</span>
</Button>
<Separator orientation="vertical" class="hidden md:flex mx-1 h-6 bg-border/50" />
{#if authStatus.user?.is_admin}
<Button
variant="link"
size="icon"
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"
>
<span class="icon-[ri--settings-3-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: "/admin" }) ? "w-full" : "group-hover:w-full"}`}
></span>
<span class="sr-only">Admin</span>
</Button>
{/if}
<!-- Slide Logout Button -->
<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?.id, '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="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}
<BurgerMenuButton
label={$_('header.navigation')}
bind:isMobileMenuOpen
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
/>
</div>
</div>
<!-- Mobile Navigation -->
<div
class={`border-t border-border/20 bg-background/95 bg-gradient-to-br from-primary to-accent backdrop-blur-xl max-h-[calc(100vh-4rem)] overflow-y-auto shadow-xl/30 transition-all duration-250 ${isMobileMenuOpen ? 'opacity-100' : 'opacity-0'}`}
>
{#if isMobileMenuOpen}
<div class="container mx-auto grid grid-cols-1 lg:grid-cols-3">
<div class="hidden lg:flex col-span-2">
<Girls />
</div>
<div class="py-6 px-4 space-y-6 lg:col-start-3 border-t border-border/20 bg-background/95 ">
<!-- User Profile Card -->
{#if authStatus.authenticated}
<div
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4 backdrop-blur-sm"
>
<div
class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"
></div>
<div class="relative flex items-center gap-4">
<Avatar class="h-14 w-14 ring-2 ring-primary/30">
<AvatarImage
src={getAssetUrl(authStatus.user!.avatar?.id, 'mini')}
alt={authStatus.user!.artist_name}
/>
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground font-semibold"
>
{getUserInitials(authStatus.user!.artist_name)}
</AvatarFallback>
</Avatar>
<div class="flex flex-1 flex-col gap-1">
<p class="text-base font-semibold text-foreground">
{authStatus.user!.artist_name}
</p>
<p class="text-sm text-muted-foreground">
{authStatus.user!.email}
</p>
<div class="flex items-center gap-2 mt-1">
<div class="h-2 w-2 rounded-full bg-green-500"></div>
<span class="text-xs text-muted-foreground">Online</span>
</div>
</div>
<!-- Notifications Badge -->
<!-- <Button
variant="ghost"
size="sm"
class="relative h-10 w-10 rounded-full p-0"
>
<BellIcon class="h-4 w-4" />
<Badge
class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-gradient-to-r from-primary to-accent p-0 text-xs text-primary-foreground"
>3</Badge
>
</Button> -->
</div>
</div>
{/if}
<!-- Navigation Cards -->
<div class="space-y-3">
<h3
class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider"
>
{$_('header.navigation')}
</h3>
<div class="grid gap-2">
{#each navLinks as link}
<a
href={link.href}
class="flex items-center justify-between rounded-xl border border-border/50 bg-card/50 p-4 backdrop-blur-sm transition-all hover:bg-card hover:border-primary/20 {isActiveLink(
link
)
? 'border-primary/30 bg-primary/5'
: ''}"
onclick={() => (isMobileMenuOpen = false)}
>
<span class="font-medium text-foreground">{link.name}</span>
<div class="flex items-center gap-2">
<!-- {#if isActiveLink(link)}
<div class="h-2 w-2 rounded-full bg-primary"></div>
{/if} -->
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground"
></span>
</div>
</a>
{/each}
</div>
</div>
<!-- Account Actions -->
<div class="space-y-3">
<h3
class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider"
>
{$_('header.account')}
</h3>
<div class="grid gap-2">
{#if authStatus.authenticated}
<a
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/me' }) ? 'border-primary/30 bg-primary/5' : ''}`}
href="/me"
onclick={closeMenu}
>
<div
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
>
<span
class="icon-[ri--dashboard-2-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground"
>{$_('header.dashboard')}</span
>
</div>
<span class="text-sm text-muted-foreground"
>{$_('header.dashboard_hint')}</span
>
</div>
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
></span>
</a>
<a
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/play' }) ? 'border-primary/30 bg-primary/5' : ''}`}
href="/play"
onclick={closeMenu}
>
<div
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
>
<span
class="icon-[ri--rocket-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground"
>{$_('header.play')}</span
>
</div>
<span class="text-sm text-muted-foreground"
>{$_('header.play_hint')}</span
>
</div>
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
></span>
</a>
{:else}
<a
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/login' }) ? 'border-primary/30 bg-primary/5' : ''}`}
href="/login"
onclick={closeMenu}
>
<div
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
>
<span
class="icon-[ri--login-circle-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground"
>{$_('header.login')}</span
>
</div>
<span class="text-sm text-muted-foreground"
>{$_('header.login_hint')}</span
>
</div>
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
></span>
</a>
<a
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/signup' }) ? 'border-primary/30 bg-primary/5' : ''}`}
href="/signup"
onclick={closeMenu}
>
<div
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
>
<span
class="icon-[ri--heart-add-2-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground"
>{$_('header.signup')}</span
>
</div>
<span class="text-sm text-muted-foreground"
>{$_('header.signup_hint')}</span
>
</div>
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
></span>
</a>
{/if}
</div>
</div>
{#if authStatus.authenticated}
<!-- Logout Button -->
<button
class="cursor-pointer flex w-full items-center gap-4 rounded-xl border border-destructive/20 bg-destructive/5 p-4 text-left backdrop-blur-sm transition-all hover:bg-destructive/10 hover:border-destructive/30 group"
onclick={handleLogout}
>
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-destructive/10 group-hover:bg-destructive/20 transition-all"
>
<span
class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"
></span>
</div>
<div class="flex flex-1 flex-col gap-1">
<span class="font-medium text-foreground"
>{$_('header.logout')}</span
>
<span class="text-sm text-muted-foreground"
>{$_('header.logout_hint')}</span
>
</div>
</button>
{/if}
</header>
<!-- Backdrop -->
<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"}`}
onclick={closeMenu}
></div>
<!-- Flyout panel -->
<div
class={`fixed inset-y-0 left-0 z-50 w-80 max-w-[85vw] bg-card/95 backdrop-blur-xl shadow-2xl shadow-primary/20 border-r border-border/30 transform transition-transform duration-300 ease-in-out lg:hidden overflow-y-auto flex flex-col ${isMobileMenuOpen ? "translate-x-0" : "-translate-x-full"}`}
inert={!isMobileMenuOpen || undefined}
>
<!-- Panel header -->
<div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
<Logo />
</div>
<div class="flex-1 py-6 px-5 space-y-6">
<!-- User card -->
{#if authStatus.authenticated}
<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 -->
<div class="space-y-2">
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{$_("header.navigation")}
</h3>
<div class="grid gap-1.5">
{#each navLinks as link (link.href)}
<a
href={link.href}
class={`flex items-center justify-between rounded-xl border px-4 py-3 transition-all duration-200 hover:border-primary/30 hover:bg-primary/5 ${
isActiveLink(link)
? "border-primary/40 bg-primary/8 text-foreground"
: "border-border/40 bg-card/50 text-foreground/85"
}`}
onclick={closeMenu}
>
<span class="font-medium text-sm">{link.name}</span>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
{/each}
</div>
</div>
<!-- Account -->
<div class="space-y-2">
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{$_("header.account")}
</h3>
<div class="grid gap-1.5">
{#if authStatus.authenticated}
<a
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/me" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
href="/me"
onclick={closeMenu}
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
>
<span
class="icon-[ri--dashboard-2-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-0.5">
<span class="text-sm font-medium text-foreground">{$_("header.dashboard")}</span>
<span class="text-xs text-muted-foreground">{$_("header.dashboard_hint")}</span>
</div>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
<a
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/play" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
href="/play"
onclick={closeMenu}
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
>
<span
class="icon-[ri--rocket-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-0.5">
<span class="text-sm font-medium text-foreground">{$_("header.play")}</span>
<span class="text-xs text-muted-foreground">{$_("header.play_hint")}</span>
</div>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
{#if authStatus.user?.is_admin}
<a
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/admin" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
href="/admin/users"
onclick={closeMenu}
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
>
<span
class="icon-[ri--settings-3-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-0.5">
<span class="text-sm font-medium text-foreground">Admin</span>
<span class="text-xs text-muted-foreground">Manage content</span>
</div>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
{/if}
{:else}
<a
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/login" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
href="/login"
onclick={closeMenu}
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
>
<span
class="icon-[ri--login-circle-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-0.5">
<span class="text-sm font-medium text-foreground">{$_("header.login")}</span>
<span class="text-xs text-muted-foreground">{$_("header.login_hint")}</span>
</div>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
<a
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/signup" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
href="/signup"
onclick={closeMenu}
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-accent/10 transition-colors"
>
<span
class="icon-[ri--heart-add-2-line] h-4 w-4 text-muted-foreground group-hover:text-accent transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-0.5">
<span class="text-sm font-medium text-foreground">{$_("header.signup")}</span>
<span class="text-xs text-muted-foreground">{$_("header.signup_hint")}</span>
</div>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
{/if}
</div>
</div>
</div>
</header>
</div>

File diff suppressed because one or more lines are too long

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