Compare commits

..

75 Commits

Author SHA1 Message Date
1b660dde9e fix: dockerfile package scope renaming
All checks were successful
Build and Push Backend Image / build (push) Successful in 58s
Build and Push Buttplug Image / build (push) Successful in 3m24s
Build and Push Frontend Image / build (push) Successful in 1m20s
2026-03-11 17:03:39 +01:00
a5ad58ac7f chore: add LICENSE 2026-03-11 16:59:15 +01:00
3e21b88e07 chore: remove sexy.pivoine.art
Some checks failed
Build and Push Buttplug Image / build (push) Failing after 27s
Build and Push Backend Image / build (push) Failing after 21s
Build and Push Frontend Image / build (push) Failing after 29s
2026-03-11 16:53:52 +01:00
c3436233f4 chore: remove sexy.pivoine.art 2026-03-11 16:49:28 +01:00
b3596d0b0a refactor: rename package scope from @sexy.pivoine.art to @sexy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 16:46:26 +01:00
9b8b07c653 chore: lint and format
All checks were successful
Build and Push Backend Image / build (push) Successful in 50s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:36:18 +01:00
22a2e63687 chore: gitea backend workflow with email
All checks were successful
Build and Push Backend Image / build (push) Successful in 49s
2026-03-11 11:32:51 +01:00
a05a96a8aa fix: install email devDeps in builder and copy email artifacts to runner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:31:01 +01:00
d2deb3a218 docs: update README with email package, buttplug CI badge, and 2026 copyright
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:23:12 +01:00
d0f0d865b6 refactor(email): externalize styles to email.css, inject via expandLinkTag
Some checks failed
Build and Push Backend Image / build (push) Failing after 22s
- Move @import "@maizzle/tailwindcss" + @theme tokens to packages/email/email.css
- Layout uses <link rel="stylesheet" href="{{ cssPath }}" inline> — Maizzle's
  expandLinkTag reads the absolute path and expands it to a <style> tag, which
  the second compileCss pass then processes with @tailwindcss/postcss + LightningCSS
- render.ts passes cssPath as a local so the expression resolves inside the layout
- Layout head is now clean HTML with no inline style logic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 10:56:30 +01:00
a30692b1ac refactor(email): align templates with frontend design tokens from app.css
- @theme now mirrors all :root variables from app.css (background, foreground,
  card, muted, muted-foreground, border, primary, primary-foreground)
- Replaced all zinc-* utilities with semantic token classes (bg-background,
  bg-card, bg-muted, text-foreground, text-muted-foreground, border-border, etc.)
- Added Noto Sans via Google Fonts import (progressive enhancement — skips
  Tailwind processing via `plain` attribute)
- Font family @theme token set to Noto Sans with system-font fallbacks
- Button inline styles updated to use hex equivalent of --primary-foreground

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 10:49:23 +01:00
60531771cf feat: packages/email — Maizzle v6 + Tailwind CSS v4 HTML email templates
- New @sexy.pivoine.art/email package with @maizzle/framework@6.0.0-15
- Uses @maizzle/tailwindcss (TW v4 preset) with @theme brand tokens
  derived from the frontend's app.css oklch primary color
- LightningCSS automatically lowers oklch/lab to hex for email clients
- Real HTML template files (templates/layouts/main.html, verification.html,
  password-reset.html) — not JS template strings
- PostCSS `from` override so @import "@maizzle/tailwindcss" resolves from
  the email package's own node_modules
- Backend lib/email.ts now calls renderVerification/renderPasswordReset
  instead of inline HTML strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 10:41:12 +01:00
bb6bf7ca11 chore: cleanup
All checks were successful
Build and Push Buttplug Image / build (push) Successful in 3m20s
Build and Push Frontend Image / build (push) Successful in 1m17s
2026-03-11 09:32:05 +01:00
fdc16957a4 refactor: move card descriptions under page headings on profile and security pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 09:28:29 +01:00
f8cb365e09 chore: cleanup
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m16s
2026-03-11 09:18:45 +01:00
ad4f5b3700 fix: global Unauthorized handling — redirect to /login, suppress log spam
Some checks failed
Build and Push Frontend Image / build (push) Has been cancelled
- Add UnauthorizedError class exported from services.ts
- loggedApiCall now detects Unauthorized GraphQL errors, logs at DEBUG
  instead of ERROR, and throws UnauthorizedError (no more stack dumps)
- hooks.server.ts catches UnauthorizedError from any load function and
  redirects to /login?redirect=<original-path>
- getRecordings, getRecording, getAnalytics now accept an optional token
  and use getAuthClient server-side so cross-origin cookie forwarding works
- Update play/recordings, play/buttplug, me/analytics page.server.ts to
  pass the session token — prevents Unauthorized on auth-protected pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 09:01:47 +01:00
3fd876180a ci: link Docker images to Gitea repository via OCI source label
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m11s
docker/metadata-action uses github.* context vars which are empty in
Gitea Actions. Explicitly set org.opencontainers.image.source using
gitea.server_url and gitea.repository so the container registry links
each image back to this repository.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:04:45 +01:00
c5b04be981 fix: align How It Works card padding with leaderboard card
Use CardHeader + CardTitle instead of an h3 inside CardContent,
so both cards get the same pt-0 treatment on CardContent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:03:16 +01:00
96cffb9be1 fix: tighten leaderboard entry layout for mobile
- px-2 py-2 / gap-2 on mobile (was p-4 / gap-4)
- Rank badge w-8 on mobile (was w-14), font scaled down
- Avatar h-9 w-9 on mobile (was h-12 w-12)
- Score text-lg on mobile (was text-2xl), "points" label hidden on mobile
- Stats always visible, icons/gaps scaled down for mobile
- Arrow indicator hidden on mobile (hover-only, useless on touch)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:01:44 +01:00
9b1771ed6a fix: prevent horizontal overflow on mobile in /play layout
Add overflow-hidden to outer wrapper so the absolutely-positioned
SexyBackground is clipped, matching how public pages handle it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 16:55:31 +01:00
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
107 changed files with 6780 additions and 5380 deletions

View File

@@ -7,9 +7,19 @@ on:
- develop - develop
tags: tags:
- "v*.*.*" - "v*.*.*"
paths:
- "packages/backend/**"
- "packages/types/**"
- "packages/email/**"
- "Dockerfile.backend"
pull_request: pull_request:
branches: branches:
- main - main
paths:
- "packages/backend/**"
- "packages/types/**"
- "packages/email/**"
- "Dockerfile.backend"
workflow_dispatch: workflow_dispatch:
env: env:
@@ -40,6 +50,8 @@ jobs:
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
labels: |
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
tags: | tags: |
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch type=ref,event=branch

View File

@@ -48,6 +48,8 @@ jobs:
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
labels: |
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
tags: | tags: |
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch type=ref,event=branch

View File

@@ -7,9 +7,17 @@ on:
- develop - develop
tags: tags:
- "v*.*.*" - "v*.*.*"
paths:
- "packages/frontend/**"
- "packages/types/**"
- "Dockerfile"
pull_request: pull_request:
branches: branches:
- main - main
paths:
- "packages/frontend/**"
- "packages/types/**"
- "Dockerfile"
workflow_dispatch: workflow_dispatch:
env: env:
@@ -40,6 +48,8 @@ jobs:
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
labels: |
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
tags: | tags: |
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch type=ref,event=branch

View File

@@ -3,7 +3,7 @@
# ============================================================================ # ============================================================================
# Base stage - shared dependencies # Base stage - shared dependencies
# ============================================================================ # ============================================================================
FROM node:22.11.0-slim AS base FROM node:22.14.0-slim AS base
# Enable corepack for pnpm # Enable corepack for pnpm
RUN npm install -g corepack@latest && corepack enable RUN npm install -g corepack@latest && corepack enable
@@ -32,8 +32,11 @@ COPY packages ./packages
# Install all dependencies # Install all dependencies
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# Generate SvelteKit type definitions (creates .svelte-kit/tsconfig.json)
RUN pnpm --filter @sexy/frontend exec svelte-kit sync
# Build frontend # Build frontend
RUN pnpm --filter @sexy.pivoine.art/frontend build RUN pnpm --filter @sexy/frontend build
# Prune dev dependencies for production # Prune dev dependencies for production
RUN CI=true pnpm install -rP RUN CI=true pnpm install -rP
@@ -41,7 +44,7 @@ RUN CI=true pnpm install -rP
# ============================================================================ # ============================================================================
# Runner stage - minimal production image # 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 # Install dumb-init for proper signal handling
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \

View File

@@ -3,7 +3,7 @@
# ============================================================================ # ============================================================================
# Builder stage # 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 RUN npm install -g corepack@latest && corepack enable
@@ -16,25 +16,29 @@ COPY packages/backend/package.json ./packages/backend/package.json
COPY packages/frontend/package.json ./packages/frontend/package.json COPY packages/frontend/package.json ./packages/frontend/package.json
COPY packages/buttplug/package.json ./packages/buttplug/package.json COPY packages/buttplug/package.json ./packages/buttplug/package.json
COPY packages/types/package.json ./packages/types/package.json COPY packages/types/package.json ./packages/types/package.json
COPY packages/email/package.json ./packages/email/package.json
RUN pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend --ignore-scripts RUN pnpm install --frozen-lockfile --filter @sexy/backend --filter @sexy/email --ignore-scripts
# Rebuild native bindings (argon2, sharp) # Rebuild native bindings (argon2, sharp)
RUN pnpm rebuild argon2 sharp RUN pnpm rebuild argon2 sharp
COPY packages/types ./packages/types COPY packages/types ./packages/types
COPY packages/email ./packages/email
COPY packages/backend ./packages/backend COPY packages/backend ./packages/backend
RUN pnpm --filter @sexy.pivoine.art/backend build RUN pnpm --filter @sexy/email build
RUN CI=true pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend --prod --ignore-scripts RUN pnpm --filter @sexy/backend build
RUN CI=true pnpm install --frozen-lockfile --filter @sexy/backend --prod --ignore-scripts
RUN pnpm rebuild argon2 sharp RUN pnpm rebuild argon2 sharp
# ============================================================================ # ============================================================================
# Runner stage # 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 \ RUN apt-get update && apt-get install -y \
dumb-init \ dumb-init \
@@ -48,7 +52,7 @@ RUN userdel -r node && \
WORKDIR /home/node/app WORKDIR /home/node/app
RUN mkdir -p packages/backend RUN mkdir -p packages/backend packages/email
COPY --from=builder --chown=node:node /app/node_modules ./node_modules 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/package.json ./package.json
@@ -56,6 +60,11 @@ COPY --from=builder --chown=node:node /app/packages/backend/dist ./packages/back
COPY --from=builder --chown=node:node /app/packages/backend/node_modules ./packages/backend/node_modules 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/package.json ./packages/backend/package.json
COPY --from=builder --chown=node:node /app/packages/backend/src/migrations ./packages/backend/dist/migrations COPY --from=builder --chown=node:node /app/packages/backend/src/migrations ./packages/backend/dist/migrations
COPY --from=builder --chown=node:node /app/packages/email/dist ./packages/email/dist
COPY --from=builder --chown=node:node /app/packages/email/node_modules ./packages/email/node_modules
COPY --from=builder --chown=node:node /app/packages/email/email.css ./packages/email/email.css
COPY --from=builder --chown=node:node /app/packages/email/templates ./packages/email/templates
COPY --from=builder --chown=node:node /app/packages/email/package.json ./packages/email/package.json
RUN mkdir -p /data/uploads && chown node:node /data/uploads RUN mkdir -p /data/uploads && chown node:node /data/uploads

View File

@@ -3,7 +3,7 @@
# ============================================================================ # ============================================================================
# Builder stage - compile Rust/WASM and TypeScript # Builder stage - compile Rust/WASM and TypeScript
# ============================================================================ # ============================================================================
FROM node:22.11.0-slim AS builder FROM node:22.14.0-slim AS builder
# Install build dependencies for Rust # Install build dependencies for Rust
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
@@ -35,14 +35,14 @@ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY packages/buttplug ./packages/buttplug COPY packages/buttplug ./packages/buttplug
# Install dependencies # Install dependencies
RUN pnpm install --frozen-lockfile --filter @sexy.pivoine.art/buttplug RUN pnpm install --frozen-lockfile --filter @sexy/buttplug
# Build WASM # Build WASM
RUN RUSTFLAGS='--cfg getrandom_backend="wasm_js" --cfg=web_sys_unstable_apis' \ RUN RUSTFLAGS='--cfg getrandom_backend="wasm_js" --cfg=web_sys_unstable_apis' \
pnpm --filter @sexy.pivoine.art/buttplug build:wasm pnpm --filter @sexy/buttplug build:wasm
# Build TypeScript # Build TypeScript
RUN pnpm --filter @sexy.pivoine.art/buttplug build RUN pnpm --filter @sexy/buttplug build
# ============================================================================ # ============================================================================
# Runner stage - nginx serving dist/ and wasm/ # Runner stage - nginx serving dist/ and wasm/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Palina & Valknar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,4 +1,4 @@
# 💋 sexy.pivoine.art # 💋 SEXY
<div align="center"> <div align="center">
@@ -15,8 +15,9 @@ Built with passion, technology, and the fearless spirit of sexual empowerment
[![Build Frontend](https://dev.pivoine.art/valknar/sexy/actions/workflows/docker-build-frontend.yml/badge.svg)](https://dev.pivoine.art/valknar/sexy/actions) [![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) [![Build Backend](https://dev.pivoine.art/valknar/sexy/actions/workflows/docker-build-backend.yml/badge.svg)](https://dev.pivoine.art/valknar/sexy/actions)
[![Build Buttplug](https://dev.pivoine.art/valknar/sexy/actions/workflows/docker-build-buttplug.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) [![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)](https://sexy.pivoine.art) [![Made with Love](https://img.shields.io/badge/Made_with-💜_Love-FF69B4?style=for-the-badge&labelColor=8B008B)](https://pivoine.art)
</div> </div>
@@ -24,7 +25,7 @@ Built with passion, technology, and the fearless spirit of sexual empowerment
## 👅 What Is This Delicious Creation? ## 👅 What Is This Delicious Creation?
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. Welcome, dear pleasure-seeker! This is **sexy** — 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. 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.
@@ -39,6 +40,7 @@ Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom
- 🌍 **Internationalization** — pleasure speaks all languages - 🌍 **Internationalization** — pleasure speaks all languages
- 🏆 **Gamification** — achievements, leaderboards, and reward points - 🏆 **Gamification** — achievements, leaderboards, and reward points
- 💬 **Comments & Social** — build your community - 💬 **Comments & Social** — build your community
- 💌 **Professional HTML Emails** — Maizzle v6 + Tailwind CSS 4 templated email (verification, password reset)
- 📊 **Analytics Integration** (Umami) — know your admirers - 📊 **Analytics Integration** (Umami) — know your admirers
- 🐳 **Self-hosted CI/CD** via Gitea Actions on `dev.pivoine.art` - 🐳 **Self-hosted CI/CD** via Gitea Actions on `dev.pivoine.art`
@@ -72,6 +74,11 @@ Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom
│ ├─ TypeScript + Rust → Power and precision │ │ ├─ TypeScript + Rust → Power and precision │
│ └─ WebBluetooth API → Wireless intimacy │ │ └─ WebBluetooth API → Wireless intimacy │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ 💌 Email Layer │
│ ├─ Maizzle v6 → HTML email framework │
│ ├─ @maizzle/tailwindcss → Email-safe Tailwind CSS 4 │
│ └─ Nodemailer → SMTP delivery │
├─────────────────────────────────────────────────────────────┤
│ 🌸 DevOps Layer │ │ 🌸 DevOps Layer │
│ ├─ Docker → Containerized ecstasy │ │ ├─ Docker → Containerized ecstasy │
│ ├─ Gitea Actions → Self-hosted seduction │ │ ├─ Gitea Actions → Self-hosted seduction │
@@ -88,7 +95,7 @@ Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom
```bash ```bash
# Clone the repository # Clone the repository
git clone https://dev.pivoine.art/valknar/sexy.git git clone https://dev.pivoine.art/valknar/sexy.git
cd sexy.pivoine.art cd sexy
# Configure your secrets # Configure your secrets
cp .env.example .env cp .env.example .env
@@ -119,7 +126,7 @@ pnpm dev:data
pnpm dev:backend pnpm dev:backend
# Start the frontend (port 3000, proxied to :4000) # Start the frontend (port 3000, proxied to :4000)
pnpm --filter @sexy.pivoine.art/frontend dev pnpm --filter @sexy/frontend dev
``` ```
Visit `http://localhost:3000` and let the experience begin... 💋 Visit `http://localhost:3000` and let the experience begin... 💋
@@ -130,13 +137,14 @@ GraphQL playground is available at `http://localhost:4000/graphql` — explore e
## 🌹 Project Structure ## 🌹 Project Structure
This monorepo contains three packages, each serving its purpose: This monorepo contains four packages, each serving its purpose:
``` ```
sexy.pivoine.art/ sexy/
├─ 💄 packages/frontend/ → SvelteKit app (the seduction) ├─ 💄 packages/frontend/ → SvelteKit app (the seduction)
├─ ⚡ packages/backend/ → Fastify + GraphQL API (the engine) ├─ ⚡ packages/backend/ → Fastify + GraphQL API (the engine)
─ 🎮 packages/buttplug/ → Hardware control (the connection) ─ 🎮 packages/buttplug/ → Hardware control (the connection)
└─ 💌 packages/email/ → Maizzle HTML email templates
``` ```
### 💄 Frontend (`packages/frontend/`) ### 💄 Frontend (`packages/frontend/`)
@@ -156,6 +164,13 @@ Files stored as `<UPLOAD_DIR>/<uuid>/<filename>` with on-demand WebP transforms
Hybrid TypeScript/Rust package for intimate hardware control via WebBluetooth. Hybrid TypeScript/Rust package for intimate hardware control via WebBluetooth.
Compiled to WebAssembly for browser-based Bluetooth device communication. Compiled to WebAssembly for browser-based Bluetooth device communication.
### 💌 Email (`packages/email/`)
Professional HTML email templates built with **Maizzle v6** + **Tailwind CSS 4** (`@maizzle/tailwindcss`).
Design tokens mirror the frontend's `app.css` exactly — same oklch colors, Noto Sans font, semantic classes.
LightningCSS automatically converts oklch values to hex for email client compatibility.
Exported functions: `renderVerification({ token })` and `renderPasswordReset({ token })` — each returns `{ subject, html }`.
--- ---
## 🗃️ Database Schema ## 🗃️ Database Schema
@@ -241,6 +256,7 @@ Automated builds run on **[dev.pivoine.art](https://dev.pivoine.art/valknar/sexy
- ✅ Frontend image → `dev.pivoine.art/valknar/sexy:latest` - ✅ Frontend image → `dev.pivoine.art/valknar/sexy:latest`
- ✅ Backend image → `dev.pivoine.art/valknar/sexy-backend:latest` - ✅ Backend image → `dev.pivoine.art/valknar/sexy-backend:latest`
- ✅ Buttplug image → `dev.pivoine.art/valknar/sexy-buttplug:latest`
- ✅ Triggers on push to `main`, `develop`, or version tags (`v*.*.*`) - ✅ Triggers on push to `main`, `develop`, or version tags (`v*.*.*`)
- ✅ Build cache via registry for fast successive builds - ✅ Build cache via registry for fast successive builds
@@ -312,7 +328,7 @@ graph LR
### 🌸 Created with Love by 🌸 ### 🌸 Created with Love by 🌸
**[Palina](https://sexy.pivoine.art) & [Valknar](https://sexy.pivoine.art)** **[Palina](https://palina.pivoine.art) & [Valknar](https://pivoine.art)**
_Für die Mäuse..._ 🐭💕 _Für die Mäuse..._ 🐭💕
@@ -329,6 +345,8 @@ _Für die Mäuse..._ 🐭💕
| [Drizzle ORM](https://orm.drizzle.team/) | Database | | [Drizzle ORM](https://orm.drizzle.team/) | Database |
| [Sharp](https://sharp.pixelplumbing.com/) | Image transforms | | [Sharp](https://sharp.pixelplumbing.com/) | Image transforms |
| [Buttplug.io](https://buttplug.io/) | Hardware | | [Buttplug.io](https://buttplug.io/) | Hardware |
| [Maizzle](https://maizzle.com/) | HTML email framework |
| [Nodemailer](https://nodemailer.com/) | Email delivery |
| [bits-ui](https://www.bits-ui.com/) | UI components | | [bits-ui](https://www.bits-ui.com/) | UI components |
| [Gitea](https://dev.pivoine.art) | Self-hosted VCS & CI | | [Gitea](https://dev.pivoine.art) | Self-hosted VCS & CI |
@@ -362,7 +380,7 @@ _"Eine Frau, die ihre Sexualität selbstbestimmt lebt, ist eine freie Frau."_
[![Repository](https://img.shields.io/badge/🐙_Repository-dev.pivoine.art-FF69B4?style=for-the-badge&labelColor=8B008B)](https://dev.pivoine.art/valknar/sexy) [![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) [![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) [![Website](https://img.shields.io/badge/🌐_Website-Visit_Here-FF1493?style=for-the-badge&labelColor=8B008B)](https://pivoine.art)
</div> </div>
@@ -383,6 +401,6 @@ _"Eine Frau, die ihre Sexualität selbstbestimmt lebt, ist eine freie Frau."_
_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](https://sexy.pivoine.art)** | © 2025 Palina & Valknar **[pivoine.art](https://pivoine.art)** | © 2026 Palina & Valknar
</div> </div>

View File

@@ -51,7 +51,7 @@ services:
COOKIE_SECRET: change-me-in-production COOKIE_SECRET: change-me-in-production
SMTP_HOST: localhost SMTP_HOST: localhost
SMTP_PORT: 587 SMTP_PORT: 587
EMAIL_FROM: noreply@sexy.pivoine.art EMAIL_FROM: noreply@sexy
PUBLIC_URL: http://localhost:3000 PUBLIC_URL: http://localhost:3000
depends_on: depends_on:
postgres: postgres:

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
pgEnum, pgEnum,
uniqueIndex, uniqueIndex,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { users } from "./users"; import { users } from "./users";
import { recordings } from "./recordings"; import { recordings } from "./recordings";
@@ -68,6 +69,11 @@ export const user_points = pgTable(
(t) => [ (t) => [
index("user_points_user_idx").on(t.user_id), index("user_points_user_idx").on(t.user_id),
index("user_points_date_idx").on(t.date_created), 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

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

View File

@@ -9,7 +9,7 @@ interface ReplyLike {
} }
import { hash, verify as verifyArgon } from "../../lib/argon"; import { hash, verify as verifyArgon } from "../../lib/argon";
import { setSession, deleteSession } from "../../lib/auth"; import { setSession, deleteSession } from "../../lib/auth";
import { sendVerification, sendPasswordReset } from "../../lib/email"; import { enqueueVerification, enqueuePasswordReset } from "../../lib/email";
import { slugify } from "../../lib/slugify"; import { slugify } from "../../lib/slugify";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -131,9 +131,9 @@ builder.mutationField("register", (t) =>
}); });
try { try {
await sendVerification(args.email, verifyToken); await enqueueVerification(args.email, verifyToken);
} catch (e) { } catch (e) {
console.warn("Failed to send verification email:", (e as Error).message); console.warn("Failed to enqueue verification email:", (e as Error).message);
} }
return true; return true;
}, },
@@ -190,9 +190,9 @@ builder.mutationField("requestPasswordReset", (t) =>
.where(eq(users.id, user[0].id)); .where(eq(users.id, user[0].id));
try { try {
await sendPasswordReset(args.email, token); await enqueuePasswordReset(args.email, token);
} catch (e) { } catch (e) {
console.warn("Failed to send password reset email:", (e as Error).message); console.warn("Failed to enqueue password reset email:", (e as Error).message);
} }
return true; return true;
}, },

View File

@@ -3,8 +3,8 @@ import { builder } from "../builder";
import { CommentType, AdminCommentListType } from "../types/index"; import { CommentType, AdminCommentListType } from "../types/index";
import { comments, users } from "../../db/schema/index"; import { comments, users } from "../../db/schema/index";
import { eq, and, desc, ilike, count } from "drizzle-orm"; import { eq, and, desc, ilike, count } from "drizzle-orm";
import { awardPoints, checkAchievements } from "../../lib/gamification";
import { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl"; import { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl";
import { gamificationQueue } from "../../queues/index";
builder.queryField("commentsForVideo", (t) => builder.queryField("commentsForVideo", (t) =>
t.field({ t.field({
@@ -59,10 +59,16 @@ builder.mutationField("createCommentForVideo", (t) =>
}) })
.returning(); .returning();
// Gamification (non-blocking) await gamificationQueue.add("awardPoints", {
awardPoints(ctx.db, ctx.currentUser.id, "COMMENT_CREATE") job: "awardPoints",
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "social")) userId: ctx.currentUser.id,
.catch((e) => console.error("Gamification error on comment:", e)); action: "COMMENT_CREATE",
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "social",
});
const user = await ctx.db const user = await ctx.db
.select({ .select({
@@ -92,6 +98,18 @@ builder.mutationField("deleteComment", (t) =>
if (!comment[0]) throw new GraphQLError("Comment not found"); if (!comment[0]) throw new GraphQLError("Comment not found");
requireOwnerOrAdmin(ctx, comment[0].user_id); requireOwnerOrAdmin(ctx, comment[0].user_id);
await ctx.db.delete(comments).where(eq(comments.id, args.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; return true;
}, },
}), }),

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

@@ -4,8 +4,8 @@ import { RecordingType, AdminRecordingListType } from "../types/index";
import { recordings, recording_plays } from "../../db/schema/index"; import { recordings, recording_plays } from "../../db/schema/index";
import { eq, and, desc, ilike, count, type SQL } from "drizzle-orm"; import { eq, and, desc, ilike, count, type SQL } from "drizzle-orm";
import { slugify } from "../../lib/slugify"; import { slugify } from "../../lib/slugify";
import { awardPoints, checkAchievements } from "../../lib/gamification";
import { requireAdmin } from "../../lib/acl"; import { requireAdmin } from "../../lib/acl";
import { gamificationQueue } from "../../queues/index";
builder.queryField("recordings", (t) => builder.queryField("recordings", (t) =>
t.field({ t.field({
@@ -122,11 +122,18 @@ builder.mutationField("createRecording", (t) =>
const recording = newRecording[0]; const recording = newRecording[0];
// Gamification (non-blocking)
if (recording.status === "published") { if (recording.status === "published") {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id) await gamificationQueue.add("awardPoints", {
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings")) job: "awardPoints",
.catch((e) => console.error("Gamification error on recording create:", e)); userId: ctx.currentUser.id,
action: "RECORDING_CREATE",
recordingId: recording.id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "recordings",
});
} }
return recording; return recording;
@@ -180,15 +187,45 @@ builder.mutationField("updateRecording", (t) =>
const recording = updated[0]; const recording = updated[0];
// Gamification (non-blocking)
if (args.status === "published" && existing[0].status !== "published") { if (args.status === "published" && existing[0].status !== "published") {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id) // draft → published: award creation points
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings")) await gamificationQueue.add("awardPoints", {
.catch((e) => console.error("Gamification error on recording publish:", e)); 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) { } else if (args.status === "published" && recording.featured && !existing[0].featured) {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_FEATURED", recording.id) // newly featured while published: award featured bonus
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings")) await gamificationQueue.add("awardPoints", {
.catch((e) => console.error("Gamification error on recording feature:", e)); 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; return recording;
@@ -214,6 +251,28 @@ builder.mutationField("deleteRecording", (t) =>
if (!existing[0]) throw new GraphQLError("Recording not found"); if (!existing[0]) throw new GraphQLError("Recording not found");
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden"); if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
if (existing[0].status === "published") {
await gamificationQueue.add("revokePoints", {
job: "revokePoints",
userId: ctx.currentUser.id,
action: "RECORDING_CREATE",
recordingId: args.id,
});
if (existing[0].featured) {
await gamificationQueue.add("revokePoints", {
job: "revokePoints",
userId: ctx.currentUser.id,
action: "RECORDING_FEATURED",
recordingId: args.id,
});
}
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "content",
});
}
await ctx.db.delete(recordings).where(eq(recordings.id, args.id)); await ctx.db.delete(recordings).where(eq(recordings.id, args.id));
return true; return true;
@@ -290,11 +349,18 @@ builder.mutationField("recordRecordingPlay", (t) =>
}) })
.returning({ id: recording_plays.id }); .returning({ id: recording_plays.id });
// Gamification (non-blocking)
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) { if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_PLAY", args.recordingId) await gamificationQueue.add("awardPoints", {
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "playback")) job: "awardPoints",
.catch((e) => console.error("Gamification error on recording play:", e)); 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 }; return { success: true, play_id: play[0].id };
@@ -329,11 +395,18 @@ builder.mutationField("updateRecordingPlay", (t) =>
}) })
.where(eq(recording_plays.id, args.playId)); .where(eq(recording_plays.id, args.playId));
// Gamification (non-blocking)
if (args.completed && !wasCompleted && ctx.currentUser) { if (args.completed && !wasCompleted && ctx.currentUser) {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_COMPLETE", existing[0].recording_id) await gamificationQueue.add("awardPoints", {
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "playback")) job: "awardPoints",
.catch((e) => console.error("Gamification error on recording complete:", e)); 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; return true;

View File

@@ -22,7 +22,7 @@ import type {
RecentPoint, RecentPoint,
UserGamification, UserGamification,
Achievement, Achievement,
} from "@sexy.pivoine.art/types"; } from "@sexy/types";
type AdminUserDetail = User & { photos: ModelPhoto[] }; type AdminUserDetail = User & { photos: ModelPhoto[] };
import { builder } from "../builder"; import { builder } from "../builder";
@@ -333,6 +333,74 @@ export const AchievementType = builder.objectRef<Achievement>("Achievement").imp
}), }),
}); });
// --- Queue / Job types (admin only, not in shared types package) ---
type JobCounts = {
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
paused: number;
};
type JobData = {
id: string;
name: string;
queue: string;
status: string;
data: unknown;
result: unknown;
failedReason: string | null;
attemptsMade: number;
createdAt: Date;
processedAt: Date | null;
finishedAt: Date | null;
progress: number | null;
};
type QueueInfoData = {
name: string;
counts: JobCounts;
isPaused: boolean;
};
export const JobCountsType = builder.objectRef<JobCounts>("JobCounts").implement({
fields: (t) => ({
waiting: t.exposeInt("waiting"),
active: t.exposeInt("active"),
completed: t.exposeInt("completed"),
failed: t.exposeInt("failed"),
delayed: t.exposeInt("delayed"),
paused: t.exposeInt("paused"),
}),
});
export const JobType = builder.objectRef<JobData>("Job").implement({
fields: (t) => ({
id: t.exposeString("id"),
name: t.exposeString("name"),
queue: t.exposeString("queue"),
status: t.exposeString("status"),
data: t.expose("data", { type: "JSON" }),
result: t.expose("result", { type: "JSON", nullable: true }),
failedReason: t.exposeString("failedReason", { nullable: true }),
attemptsMade: t.exposeInt("attemptsMade"),
createdAt: t.expose("createdAt", { type: "DateTime" }),
processedAt: t.expose("processedAt", { type: "DateTime", nullable: true }),
finishedAt: t.expose("finishedAt", { type: "DateTime", nullable: true }),
progress: t.exposeFloat("progress", { nullable: true }),
}),
});
export const QueueInfoType = builder.objectRef<QueueInfoData>("QueueInfo").implement({
fields: (t) => ({
name: t.exposeString("name"),
counts: t.expose("counts", { type: JobCountsType }),
isPaused: t.exposeBoolean("isPaused"),
}),
});
export const VideoListType = builder export const VideoListType = builder
.objectRef<{ items: Video[]; total: number }>("VideoList") .objectRef<{ items: Video[]; total: number }>("VideoList")
.implement({ .implement({

View File

@@ -16,6 +16,8 @@ import { db } from "./db/connection";
import { redis } from "./lib/auth"; import { redis } from "./lib/auth";
import { logger } from "./lib/logger"; import { logger } from "./lib/logger";
import { migrate } from "drizzle-orm/node-postgres/migrator"; 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 PORT = parseInt(process.env.PORT || "4000");
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads"; const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
@@ -28,6 +30,11 @@ async function main() {
await migrate(db, { migrationsFolder }); await migrate(db, { migrationsFolder });
logger.info("Migrations complete"); logger.info("Migrations complete");
// Start background workers
startMailWorker();
startGamificationWorker();
logger.info("Queue workers started");
const fastify = Fastify({ loggerInstance: logger }); const fastify = Fastify({ loggerInstance: logger });
await fastify.register(fastifyCookie, { await fastify.register(fastifyCookie, {

View File

@@ -1,4 +1,6 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { renderVerification, renderPasswordReset } from "@sexy/email";
import { mailQueue } from "../queues/index.js";
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || "localhost", host: process.env.SMTP_HOST || "localhost",
@@ -12,23 +14,24 @@ const transporter = nodemailer.createTransport({
: undefined, : undefined,
}); });
const FROM = process.env.EMAIL_FROM || "noreply@sexy.pivoine.art"; const FROM = process.env.EMAIL_FROM || "noreply@sexy";
const BASE_URL = process.env.PUBLIC_URL || "http://localhost:3000";
export async function sendVerification(email: string, token: string): Promise<void> { export async function sendVerification(email: string, token: string): Promise<void> {
await transporter.sendMail({ const { subject, html } = await renderVerification({ token });
from: FROM, await transporter.sendMail({ from: FROM, to: email, subject, html });
to: email,
subject: "Verify your email",
html: `<p>Click <a href="${BASE_URL}/signup/verify?token=${token}">here</a> to verify your email.</p>`,
});
} }
export async function sendPasswordReset(email: string, token: string): Promise<void> { export async function sendPasswordReset(email: string, token: string): Promise<void> {
await transporter.sendMail({ const { subject, html } = await renderPasswordReset({ token });
from: FROM, await transporter.sendMail({ from: FROM, to: email, subject, html });
to: email, }
subject: "Reset your password",
html: `<p>Click <a href="${BASE_URL}/password/reset?token=${token}">here</a> to reset your password.</p>`, const jobOpts = { attempts: 3, backoff: { type: "exponential" as const, delay: 5000 } };
});
export async function enqueueVerification(email: string, token: string): Promise<void> {
await mailQueue.add("sendVerification", { email, token }, jobOpts);
}
export async function enqueuePasswordReset(email: string, token: string): Promise<void> {
await mailQueue.add("sendPasswordReset", { email, token }, jobOpts);
} }

View File

@@ -1,4 +1,4 @@
import { eq, sql, and, gt, isNotNull, count, sum } from "drizzle-orm"; import { eq, sql, and, gt, isNull, isNotNull, count, sum } from "drizzle-orm";
import type { DB } from "../db/connection"; import type { DB } from "../db/connection";
import { import {
user_points, user_points,
@@ -28,21 +28,57 @@ export async function awardPoints(
recordingId?: string, recordingId?: string,
): Promise<void> { ): Promise<void> {
const points = POINT_VALUES[action]; const points = POINT_VALUES[action];
await db.insert(user_points).values({ await db
.insert(user_points)
.values({
user_id: userId, user_id: userId,
action, action,
points, points,
recording_id: recordingId || null, recording_id: recordingId || null,
date_created: new Date(), 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); await updateUserStats(db, userId);
} }
export async function calculateWeightedScore(db: DB, userId: string): Promise<number> { export async function calculateWeightedScore(db: DB, userId: string): Promise<number> {
const now = new Date();
const result = await db.execute(sql` const result = await db.execute(sql`
SELECT SUM( 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 ) as weighted_score
FROM user_points FROM user_points
WHERE user_id = ${userId} WHERE user_id = ${userId}
@@ -96,7 +132,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
const commentsResult = await db const commentsResult = await db
.select({ count: count() }) .select({ count: count() })
.from(comments) .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 commentsCount = commentsResult[0]?.count || 0;
const achievementsResult = await db const achievementsResult = await db
@@ -175,7 +211,9 @@ export async function checkAchievements(db: DB, userId: string, category?: strin
.update(user_achievements) .update(user_achievements)
.set({ .set({
progress, 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( .where(
and( and(
@@ -257,7 +295,7 @@ async function getAchievementProgress(
const result = await db const result = await db
.select({ count: count() }) .select({ count: count() })
.from(comments) .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; return result[0]?.count || 0;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
{ {
"name": "@sexy.pivoine.art/buttplug", "name": "@sexy/buttplug",
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"private": true,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",

View File

@@ -146,7 +146,6 @@ export class ButtplugClientDeviceFeature {
// Make sure the requested feature is valid // Make sure the requested feature is valid
this.isInputValid(inputType); this.isInputValid(inputType);
const inputAttributes = this._feature.Input[inputType]; const inputAttributes = this._feature.Input[inputType];
console.log(this._feature.Input);
if ( if (
inputCommand === Messages.InputCommandType.Unsubscribe && inputCommand === Messages.InputCommandType.Unsubscribe &&
!inputAttributes.Command.includes(Messages.InputCommandType.Subscribe) && !inputAttributes.Command.includes(Messages.InputCommandType.Subscribe) &&

View File

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

View File

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

View File

@@ -1 +0,0 @@
*

View File

@@ -1,64 +0,0 @@
/* tslint:disable */
/* eslint-disable */
export function buttplug_activate_env_logger(_max_level: string): void;
export function buttplug_client_send_json_message(server_ptr: number, buf: Uint8Array, callback: Function): void;
export function buttplug_create_embedded_wasm_server(callback: Function): number;
export function buttplug_free_embedded_wasm_server(ptr: number): void;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly create_test_dcm: (a: number, b: number) => void;
readonly buttplug_activate_env_logger: (a: number, b: number) => void;
readonly buttplug_client_send_json_message: (a: number, b: number, c: number, d: any) => void;
readonly buttplug_create_embedded_wasm_server: (a: any) => number;
readonly buttplug_free_embedded_wasm_server: (a: number) => void;
readonly wasm_bindgen__closure__destroy__h72b504abf7ea70fd: (a: number, b: number) => void;
readonly wasm_bindgen__closure__destroy__ha3c8e2c9b0cf79cd: (a: number, b: number) => void;
readonly wasm_bindgen__closure__destroy__h0f95d90d24796def: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd: (a: number, b: number) => void;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __externref_table_alloc: () => number;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_exn_store: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;

View File

@@ -1,814 +0,0 @@
/* @ts-self-types="./index.d.ts" */
/**
* @param {string} _max_level
*/
export function buttplug_activate_env_logger(_max_level) {
const ptr0 = passStringToWasm0(_max_level, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
wasm.buttplug_activate_env_logger(ptr0, len0);
}
/**
* @param {number} server_ptr
* @param {Uint8Array} buf
* @param {Function} callback
*/
export function buttplug_client_send_json_message(server_ptr, buf, callback) {
const ptr0 = passArray8ToWasm0(buf, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
wasm.buttplug_client_send_json_message(server_ptr, ptr0, len0, callback);
}
/**
* @param {Function} callback
* @returns {number}
*/
export function buttplug_create_embedded_wasm_server(callback) {
const ret = wasm.buttplug_create_embedded_wasm_server(callback);
return ret >>> 0;
}
/**
* @param {number} ptr
*/
export function buttplug_free_embedded_wasm_server(ptr) {
wasm.buttplug_free_embedded_wasm_server(ptr);
}
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbg___wbindgen_debug_string_a1b3fd0656850da8: function(arg0, arg1) {
const ret = debugString(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg___wbindgen_is_function_82aa5b8e9371b250: function(arg0) {
const ret = typeof(arg0) === 'function';
return ret;
},
__wbg___wbindgen_is_object_61452b678ecf7ecf: function(arg0) {
const val = arg0;
const ret = typeof(val) === 'object' && val !== null;
return ret;
},
__wbg___wbindgen_is_string_91960b7ba9d4d76b: function(arg0) {
const ret = typeof(arg0) === 'string';
return ret;
},
__wbg___wbindgen_is_undefined_7b12045c262a3121: function(arg0) {
const ret = arg0 === undefined;
return ret;
},
__wbg___wbindgen_throw_83ebd457a191bc2a: function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
},
__wbg__wbg_cb_unref_4fc42a417bb095f4: function(arg0) {
arg0._wbg_cb_unref();
},
__wbg_bluetooth_5967024a158f671e: function(arg0) {
const ret = arg0.bluetooth;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_buffer_bfae6dde33a7e5a0: function(arg0) {
const ret = arg0.buffer;
return ret;
},
__wbg_byteLength_5fbecf2b9f6cc625: function(arg0) {
const ret = arg0.byteLength;
return ret;
},
__wbg_call_72a54043615c73e3: function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.call(arg1, arg2);
return ret;
}, arguments); },
__wbg_connect_61050e3a8e3f5f1c: function(arg0) {
const ret = arg0.connect();
return ret;
},
__wbg_crypto_38df2bab126b63dc: function(arg0) {
const ret = arg0.crypto;
return ret;
},
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.error(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
},
__wbg_gatt_aeca65a254a75f07: function(arg0) {
const ret = arg0.gatt;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_getCharacteristic_4daa5211272f941c: function(arg0, arg1, arg2) {
const ret = arg0.getCharacteristic(getStringFromWasm0(arg1, arg2));
return ret;
},
__wbg_getPrimaryService_8b6197119664f448: function(arg0, arg1, arg2) {
const ret = arg0.getPrimaryService(getStringFromWasm0(arg1, arg2));
return ret;
},
__wbg_getRandomValues_3f44b700395062e5: function() { return handleError(function (arg0, arg1) {
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
}, arguments); },
__wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) {
arg0.getRandomValues(arg1);
}, arguments); },
__wbg_getRandomValues_ef8a9e8b447216e2: function() { return handleError(function (arg0, arg1) {
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
}, arguments); },
__wbg_get_bda2de250e7f67d3: function() { return handleError(function (arg0, arg1) {
const ret = Reflect.get(arg0, arg1);
return ret;
}, arguments); },
__wbg_id_c3a9ae039584e9f6: function(arg0, arg1) {
const ret = arg1.id;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg_instanceof_Window_3bc43738919f4587: function(arg0) {
let result;
try {
result = arg0 instanceof Window;
} catch (_) {
result = false;
}
const ret = result;
return ret;
},
__wbg_length_684e7f4ac265724c: function(arg0) {
const ret = arg0.length;
return ret;
},
__wbg_log_0c201ade58bb55e1: function(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.log(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3), getStringFromWasm0(arg4, arg5), getStringFromWasm0(arg6, arg7));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
},
__wbg_log_ce2c4456b290c5e7: function(arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.log(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
},
__wbg_mark_b4d943f3bc2d2404: function(arg0, arg1) {
performance.mark(getStringFromWasm0(arg0, arg1));
},
__wbg_measure_84362959e621a2c1: function() { return handleError(function (arg0, arg1, arg2, arg3) {
let deferred0_0;
let deferred0_1;
let deferred1_0;
let deferred1_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
deferred1_0 = arg2;
deferred1_1 = arg3;
performance.measure(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
wasm.__wbindgen_free(deferred1_0, deferred1_1, 1);
}
}, arguments); },
__wbg_msCrypto_bd5a034af96bcba6: function(arg0) {
const ret = arg0.msCrypto;
return ret;
},
__wbg_name_0a4944b9b89a9be1: function(arg0, arg1) {
const ret = arg1.name;
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg_navigator_91b141c3f3b6b96b: function(arg0) {
const ret = arg0.navigator;
return ret;
},
__wbg_new_18cda2e4779f118c: function(arg0) {
const ret = new Uint8Array(arg0);
return ret;
},
__wbg_new_227d7c05414eb861: function() {
const ret = new Error();
return ret;
},
__wbg_new_5c365a7570baea64: function() {
const ret = new Object();
return ret;
},
__wbg_new_with_byte_offset_ba99c41da925551a: function(arg0, arg1) {
const ret = new Uint8Array(arg0, arg1 >>> 0);
return ret;
},
__wbg_new_with_length_875a3f1ab82a1a1f: function(arg0) {
const ret = new Uint8Array(arg0 >>> 0);
return ret;
},
__wbg_node_84ea875411254db1: function(arg0) {
const ret = arg0.node;
return ret;
},
__wbg_now_55c5352b4b61d145: function(arg0) {
const ret = arg0.now();
return ret;
},
__wbg_now_7627eff456aa5959: function(arg0) {
const ret = arg0.now();
return ret;
},
__wbg_performance_aa4d78060a5b8a2f: function(arg0) {
const ret = arg0.performance;
return ret;
},
__wbg_process_44c7a14e11e9f69e: function(arg0) {
const ret = arg0.process;
return ret;
},
__wbg_prototypesetcall_7c3092bff32833dc: function(arg0, arg1, arg2) {
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
},
__wbg_queueMicrotask_17a58d631cc9ab4b: function(arg0) {
queueMicrotask(arg0);
},
__wbg_queueMicrotask_4114767fcf2790b9: function(arg0) {
const ret = arg0.queueMicrotask;
return ret;
},
__wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) {
arg0.randomFillSync(arg1);
}, arguments); },
__wbg_readValue_c83601164f2a0105: function(arg0) {
const ret = arg0.readValue();
return ret;
},
__wbg_requestDevice_26b08548120ca1d9: function(arg0, arg1) {
const ret = arg0.requestDevice(arg1);
return ret;
},
__wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () {
const ret = module.require;
return ret;
}, arguments); },
__wbg_resolve_67a1b1ca24efbc5c: function(arg0) {
const ret = Promise.resolve(arg0);
return ret;
},
__wbg_setTimeout_05a790c35d76ff25: function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.setTimeout(arg1, arg2);
return ret;
}, arguments); },
__wbg_set_filters_7e7f78143ddef831: function(arg0, arg1, arg2) {
arg0.filters = getArrayJsValueViewFromWasm0(arg1, arg2);
},
__wbg_set_name_be5429dc123f1dd9: function(arg0, arg1, arg2) {
arg0.name = getStringFromWasm0(arg1, arg2);
},
__wbg_set_name_prefix_41b281c72726c519: function(arg0, arg1, arg2) {
arg0.namePrefix = getStringFromWasm0(arg1, arg2);
},
__wbg_set_oncharacteristicvaluechanged_d54b71ecfbe76b96: function(arg0, arg1) {
arg0.oncharacteristicvaluechanged = arg1;
},
__wbg_set_ongattserverdisconnected_960815a2e872d5a0: function(arg0, arg1) {
arg0.ongattserverdisconnected = arg1;
},
__wbg_set_optional_services_e183aa24417dc7cb: function(arg0, arg1, arg2) {
arg0.optionalServices = getArrayJsValueViewFromWasm0(arg1, arg2);
},
__wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
const ret = arg1.stack;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg_startNotifications_acc2fc1198e7dd6f: function(arg0) {
const ret = arg0.startNotifications();
return ret;
},
__wbg_static_accessor_GLOBAL_833a66cb4996dbd8: function() {
const ret = typeof global === 'undefined' ? null : global;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_GLOBAL_THIS_fc74cdbdccd80770: function() {
const ret = typeof globalThis === 'undefined' ? null : globalThis;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_SELF_066699022f35d48b: function() {
const ret = typeof self === 'undefined' ? null : self;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_WINDOW_f821c7eb05393790: function() {
const ret = typeof window === 'undefined' ? null : window;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_subarray_22ac454570db4e4f: function(arg0, arg1, arg2) {
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
return ret;
},
__wbg_target_188bf8368fa5f001: function(arg0) {
const ret = arg0.target;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_then_420f698ab0b99678: function(arg0, arg1) {
const ret = arg0.then(arg1);
return ret;
},
__wbg_then_95c29fbd346ee84e: function(arg0, arg1, arg2) {
const ret = arg0.then(arg1, arg2);
return ret;
},
__wbg_value_d1ef9362b7bf7a47: function(arg0) {
const ret = arg0.value;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_versions_276b2795b1c6a219: function(arg0) {
const ret = arg0.versions;
return ret;
},
__wbg_writeValue_99570c64ee612498: function() { return handleError(function (arg0, arg1) {
const ret = arg0.writeValue(arg1);
return ret;
}, arguments); },
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 3402, function: Function { arguments: [Externref], shim_idx: 3403, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h72b504abf7ea70fd, wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 3413, function: Function { arguments: [], shim_idx: 3414, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__ha3c8e2c9b0cf79cd, wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("BluetoothDevice")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("BluetoothRemoteGATTCharacteristic")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("BluetoothRemoteGATTServer")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("BluetoothRemoteGATTService")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("DataView")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("Event")], shim_idx: 86, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243);
return ret;
},
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 86, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8);
return ret;
},
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("undefined")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9);
return ret;
},
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
const ret = getArrayU8FromWasm0(arg0, arg1);
return ret;
},
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
},
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./index_bg.js": import0,
};
}
function wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function addToExternrefTable0(obj) {
const idx = wasm.__externref_table_alloc();
wasm.__wbindgen_externrefs.set(idx, obj);
return idx;
}
const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(state => state.dtor(state.a, state.b));
function debugString(val) {
// primitive types
const type = typeof val;
if (type == 'number' || type == 'boolean' || val == null) {
return `${val}`;
}
if (type == 'string') {
return `"${val}"`;
}
if (type == 'symbol') {
const description = val.description;
if (description == null) {
return 'Symbol';
} else {
return `Symbol(${description})`;
}
}
if (type == 'function') {
const name = val.name;
if (typeof name == 'string' && name.length > 0) {
return `Function(${name})`;
} else {
return 'Function';
}
}
// objects
if (Array.isArray(val)) {
const length = val.length;
let debug = '[';
if (length > 0) {
debug += debugString(val[0]);
}
for(let i = 1; i < length; i++) {
debug += ', ' + debugString(val[i]);
}
debug += ']';
return debug;
}
// Test for built-in
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
let className;
if (builtInMatches && builtInMatches.length > 1) {
className = builtInMatches[1];
} else {
// Failed to match the standard '[object ClassName]'
return toString.call(val);
}
if (className == 'Object') {
// we're a user defined class or Object
// JSON.stringify avoids problems with cycles, and is generally much
// easier than looping through ownProperties of `val`.
try {
return 'Object(' + JSON.stringify(val) + ')';
} catch (_) {
return 'Object';
}
}
// errors
if (val instanceof Error) {
return `${val.name}: ${val.message}\n${val.stack}`;
}
// TODO we could test for more things here, like `Set`s and `Map`s.
return className;
}
function getArrayJsValueViewFromWasm0(ptr, len) {
ptr = ptr >>> 0;
const mem = getDataViewMemory0();
const result = [];
for (let i = ptr; i < ptr + 4 * len; i += 4) {
result.push(wasm.__wbindgen_externrefs.get(mem.getUint32(i, true)));
}
return result;
}
function getArrayU8FromWasm0(ptr, len) {
ptr = ptr >>> 0;
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return decodeText(ptr, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function handleError(f, args) {
try {
return f.apply(this, args);
} catch (e) {
const idx = addToExternrefTable0(e);
wasm.__wbindgen_exn_store(idx);
}
}
function isLikeNone(x) {
return x === undefined || x === null;
}
function makeMutClosure(arg0, arg1, dtor, f) {
const state = { a: arg0, b: arg1, cnt: 1, dtor };
const real = (...args) => {
// First up with a closure we increment the internal reference
// count. This ensures that the Rust closure environment won't
// be deallocated while we're invoking it.
state.cnt++;
const a = state.a;
state.a = 0;
try {
return f(a, state.b, ...args);
} finally {
state.a = a;
real._wbg_cb_unref();
}
};
real._wbg_cb_unref = () => {
if (--state.cnt === 0) {
state.dtor(state.a, state.b);
state.a = 0;
CLOSURE_DTORS.unregister(state);
}
};
CLOSURE_DTORS.register(real, state, state);
return real;
}
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasm;
function __wbg_finalize_init(instance, module) {
wasm = instance.exports;
wasmModule = module;
cachedDataViewMemory0 = null;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('index_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };

View File

@@ -1,770 +0,0 @@
let wasm;
export function __wbg_set_wasm(val) {
wasm = val;
}
function isLikeNone(x) {
return x === undefined || x === null;
}
function addToExternrefTable0(obj) {
const idx = wasm.__externref_table_alloc();
wasm.__wbindgen_export_1.set(idx, obj);
return idx;
}
function handleError(f, args) {
try {
return f.apply(this, args);
} catch (e) {
const idx = addToExternrefTable0(e);
wasm.__wbindgen_exn_store(idx);
}
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
const lTextDecoder =
typeof TextDecoder === "undefined" ? (0, module.require)("util").TextDecoder : TextDecoder;
let cachedTextDecoder = new lTextDecoder("utf-8", { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new lTextDecoder("utf-8", { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return decodeText(ptr, len);
}
function getArrayU8FromWasm0(ptr, len) {
ptr = ptr >>> 0;
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
}
let WASM_VECTOR_LEN = 0;
const lTextEncoder =
typeof TextEncoder === "undefined" ? (0, module.require)("util").TextEncoder : TextEncoder;
const cachedTextEncoder = new lTextEncoder("utf-8");
const encodeString =
typeof cachedTextEncoder.encodeInto === "function"
? function (arg, view) {
return cachedTextEncoder.encodeInto(arg, view);
}
: function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length,
};
};
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0()
.subarray(ptr, ptr + buf.length)
.set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7f) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, (len = offset + arg.length * 3), 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = encodeString(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (
cachedDataViewMemory0 === null ||
cachedDataViewMemory0.buffer.detached === true ||
(cachedDataViewMemory0.buffer.detached === undefined &&
cachedDataViewMemory0.buffer !== wasm.memory.buffer)
) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function debugString(val) {
// primitive types
const type = typeof val;
if (type == "number" || type == "boolean" || val == null) {
return `${val}`;
}
if (type == "string") {
return `"${val}"`;
}
if (type == "symbol") {
const description = val.description;
if (description == null) {
return "Symbol";
} else {
return `Symbol(${description})`;
}
}
if (type == "function") {
const name = val.name;
if (typeof name == "string" && name.length > 0) {
return `Function(${name})`;
} else {
return "Function";
}
}
// objects
if (Array.isArray(val)) {
const length = val.length;
let debug = "[";
if (length > 0) {
debug += debugString(val[0]);
}
for (let i = 1; i < length; i++) {
debug += ", " + debugString(val[i]);
}
debug += "]";
return debug;
}
// Test for built-in
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
let className;
if (builtInMatches && builtInMatches.length > 1) {
className = builtInMatches[1];
} else {
// Failed to match the standard '[object ClassName]'
return toString.call(val);
}
if (className == "Object") {
// we're a user defined class or Object
// JSON.stringify avoids problems with cycles, and is generally much
// easier than looping through ownProperties of `val`.
try {
return "Object(" + JSON.stringify(val) + ")";
} catch (_) {
return "Object";
}
}
// errors
if (val instanceof Error) {
return `${val.name}: ${val.message}\n${val.stack}`;
}
// TODO we could test for more things here, like `Set`s and `Map`s.
return className;
}
const CLOSURE_DTORS =
typeof FinalizationRegistry === "undefined"
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry((state) => {
wasm.__wbindgen_export_6.get(state.dtor)(state.a, state.b);
});
function makeMutClosure(arg0, arg1, dtor, f) {
const state = { a: arg0, b: arg1, cnt: 1, dtor };
const real = (...args) => {
// First up with a closure we increment the internal reference
// count. This ensures that the Rust closure environment won't
// be deallocated while we're invoking it.
state.cnt++;
const a = state.a;
state.a = 0;
try {
return f(a, state.b, ...args);
} finally {
if (--state.cnt === 0) {
wasm.__wbindgen_export_6.get(state.dtor)(a, state.b);
CLOSURE_DTORS.unregister(state);
} else {
state.a = a;
}
}
};
real.original = state;
CLOSURE_DTORS.register(real, state, state);
return real;
}
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
/**
* @param {number} server_ptr
* @param {Uint8Array} buf
* @param {Function} callback
*/
export function buttplug_client_send_json_message(server_ptr, buf, callback) {
const ptr0 = passArray8ToWasm0(buf, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
wasm.buttplug_client_send_json_message(server_ptr, ptr0, len0, callback);
}
/**
* @param {number} ptr
*/
export function buttplug_free_embedded_wasm_server(ptr) {
wasm.buttplug_free_embedded_wasm_server(ptr);
}
/**
* @param {string} _max_level
*/
export function buttplug_activate_env_logger(_max_level) {
const ptr0 = passStringToWasm0(_max_level, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
wasm.buttplug_activate_env_logger(ptr0, len0);
}
/**
* @param {Function} callback
* @returns {number}
*/
export function buttplug_create_embedded_wasm_server(callback) {
const ret = wasm.buttplug_create_embedded_wasm_server(callback);
return ret >>> 0;
}
function __wbg_adapter_8(arg0, arg1, arg2) {
wasm.closure3251_externref_shim(arg0, arg1, arg2);
}
function __wbg_adapter_11(arg0, arg1, arg2) {
wasm.closure202_externref_shim(arg0, arg1, arg2);
}
function __wbg_adapter_16(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h239e120689ecd2a1(arg0, arg1);
}
export function __wbg_bluetooth_b95c3b6935c8a51a(arg0) {
const ret = arg0.bluetooth;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_buffer_068eb5b0fbad96d8(arg0) {
const ret = arg0.buffer;
return ret;
}
export function __wbg_byteLength_59ab6482fa6cb38b(arg0) {
const ret = arg0.byteLength;
return ret;
}
export function __wbg_call_2f8d426a20a307fe() {
return handleError(function (arg0, arg1) {
const ret = arg0.call(arg1);
return ret;
}, arguments);
}
export function __wbg_call_f53f0647ceb9c567() {
return handleError(function (arg0, arg1, arg2) {
const ret = arg0.call(arg1, arg2);
return ret;
}, arguments);
}
export function __wbg_connect_b91d2ba90a1ff675(arg0) {
const ret = arg0.connect();
return ret;
}
export function __wbg_crypto_86f2631e91b51511(arg0) {
const ret = arg0.crypto;
return ret;
}
export function __wbg_error_7534b8e9a36f1ab4(arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.error(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
}
export function __wbg_gatt_06b2bdb9e7f8fb84(arg0) {
const ret = arg0.gatt;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_getCharacteristic_1cb1de10f54aa049(arg0, arg1, arg2) {
const ret = arg0.getCharacteristic(getStringFromWasm0(arg1, arg2));
return ret;
}
export function __wbg_getPrimaryService_f8c0b8be4fded7fd(arg0, arg1, arg2) {
const ret = arg0.getPrimaryService(getStringFromWasm0(arg1, arg2));
return ret;
}
export function __wbg_getRandomValues_1c61fac11405ffdc() {
return handleError(function (arg0, arg1) {
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
}, arguments);
}
export function __wbg_getRandomValues_9c5c1b115e142bb8() {
return handleError(function (arg0, arg1) {
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
}, arguments);
}
export function __wbg_getRandomValues_b3f15fcbfabb0f8b() {
return handleError(function (arg0, arg1) {
arg0.getRandomValues(arg1);
}, arguments);
}
export function __wbg_get_27b4bcbec57323ca() {
return handleError(function (arg0, arg1) {
const ret = Reflect.get(arg0, arg1);
return ret;
}, arguments);
}
export function __wbg_id_d769eab9a0939b42(arg0, arg1) {
const ret = arg1.id;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg_instanceof_Window_7f29e5c72acbfd60(arg0) {
let result;
try {
result = arg0 instanceof Window;
} catch (_) {
result = false;
}
const ret = result;
return ret;
}
export function __wbg_length_904c0910ed998bf3(arg0) {
const ret = arg0.length;
return ret;
}
export function __wbg_log_0cc1b7768397bcfe(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.log(
getStringFromWasm0(arg0, arg1),
getStringFromWasm0(arg2, arg3),
getStringFromWasm0(arg4, arg5),
getStringFromWasm0(arg6, arg7),
);
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
}
export function __wbg_log_cb9e190acc5753fb(arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.log(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
}
export function __wbg_mark_7438147ce31e9d4b(arg0, arg1) {
performance.mark(getStringFromWasm0(arg0, arg1));
}
export function __wbg_measure_fb7825c11612c823() {
return handleError(function (arg0, arg1, arg2, arg3) {
let deferred0_0;
let deferred0_1;
let deferred1_0;
let deferred1_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
deferred1_0 = arg2;
deferred1_1 = arg3;
performance.measure(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
wasm.__wbindgen_free(deferred1_0, deferred1_1, 1);
}
}, arguments);
}
export function __wbg_msCrypto_d562bbe83e0d4b91(arg0) {
const ret = arg0.msCrypto;
return ret;
}
export function __wbg_name_cf5d973f5e1d9b1e(arg0, arg1) {
const ret = arg1.name;
var ptr1 = isLikeNone(ret)
? 0
: passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg_navigator_b6d1cae68d750613(arg0) {
const ret = arg0.navigator;
return ret;
}
export function __wbg_new_1930cbb8d9ffc31b() {
const ret = new Object();
return ret;
}
export function __wbg_new_8a6f238a6ece86ea() {
const ret = new Error();
return ret;
}
export function __wbg_new_9190433fb67ed635(arg0) {
const ret = new Uint8Array(arg0);
return ret;
}
export function __wbg_new_e969dc3f68d25093() {
const ret = [];
return ret;
}
export function __wbg_newnoargs_a81330f6e05d8aca(arg0, arg1) {
const ret = new Function(getStringFromWasm0(arg0, arg1));
return ret;
}
export function __wbg_newwithbyteoffset_b204dc995f2352f4(arg0, arg1) {
const ret = new Uint8Array(arg0, arg1 >>> 0);
return ret;
}
export function __wbg_newwithlength_ed0ee6c1edca86fc(arg0) {
const ret = new Uint8Array(arg0 >>> 0);
return ret;
}
export function __wbg_node_e1f24f89a7336c2e(arg0) {
const ret = arg0.node;
return ret;
}
export function __wbg_now_0dc4920a47cf7280(arg0) {
const ret = arg0.now();
return ret;
}
export function __wbg_now_1f875e5cd673bc3c(arg0) {
const ret = arg0.now();
return ret;
}
export function __wbg_performance_6adc3b899e448a23(arg0) {
const ret = arg0.performance;
return ret;
}
export function __wbg_process_3975fd6c72f520aa(arg0) {
const ret = arg0.process;
return ret;
}
export function __wbg_prototypesetcall_c5f74efd31aea86b(arg0, arg1, arg2) {
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
}
export function __wbg_push_cd3ac7d5b094565d(arg0, arg1) {
const ret = arg0.push(arg1);
return ret;
}
export function __wbg_queueMicrotask_bcc6e26d899696db(arg0) {
const ret = arg0.queueMicrotask;
return ret;
}
export function __wbg_queueMicrotask_f24a794d09c42640(arg0) {
queueMicrotask(arg0);
}
export function __wbg_randomFillSync_f8c153b79f285817() {
return handleError(function (arg0, arg1) {
arg0.randomFillSync(arg1);
}, arguments);
}
export function __wbg_readValue_9d41d1a73a07165f(arg0) {
const ret = arg0.readValue();
return ret;
}
export function __wbg_requestDevice_17840c36162ed342(arg0, arg1) {
const ret = arg0.requestDevice(arg1);
return ret;
}
export function __wbg_require_b74f47fc2d022fd6() {
return handleError(function () {
const ret = module.require;
return ret;
}, arguments);
}
export function __wbg_resolve_5775c0ef9222f556(arg0) {
const ret = Promise.resolve(arg0);
return ret;
}
export function __wbg_setTimeout_63008613644b07af() {
return handleError(function (arg0, arg1, arg2) {
const ret = arg0.setTimeout(arg1, arg2);
return ret;
}, arguments);
}
export function __wbg_setfilters_b142ba75a84ace1a(arg0, arg1) {
arg0.filters = arg1;
}
export function __wbg_setname_decc08ea308195a6(arg0, arg1, arg2) {
arg0.name = getStringFromWasm0(arg1, arg2);
}
export function __wbg_setnameprefix_3c1f973506cd8f08(arg0, arg1, arg2) {
arg0.namePrefix = getStringFromWasm0(arg1, arg2);
}
export function __wbg_setoncharacteristicvaluechanged_a126981aa4e69da5(arg0, arg1) {
arg0.oncharacteristicvaluechanged = arg1;
}
export function __wbg_setongattserverdisconnected_d43d7f3a48a58fa4(arg0, arg1) {
arg0.ongattserverdisconnected = arg1;
}
export function __wbg_setoptionalservices_b6c19589e7aa503e(arg0, arg1) {
arg0.optionalServices = arg1;
}
export function __wbg_stack_0ed75d68575b0f3c(arg0, arg1) {
const ret = arg1.stack;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg_startNotifications_e1edb2183f9289f5(arg0) {
const ret = arg0.startNotifications();
return ret;
}
export function __wbg_static_accessor_GLOBAL_1f13249cc3acc96d() {
const ret = typeof global === "undefined" ? null : global;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_static_accessor_GLOBAL_THIS_df7ae94b1e0ed6a3() {
const ret = typeof globalThis === "undefined" ? null : globalThis;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_static_accessor_SELF_6265471db3b3c228() {
const ret = typeof self === "undefined" ? null : self;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_static_accessor_WINDOW_16fb482f8ec52863() {
const ret = typeof window === "undefined" ? null : window;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_subarray_a219824899e59712(arg0, arg1, arg2) {
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
return ret;
}
export function __wbg_target_bfb4281bfa013115(arg0) {
const ret = arg0.target;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_then_8d2fcccde5380a03(arg0, arg1, arg2) {
const ret = arg0.then(arg1, arg2);
return ret;
}
export function __wbg_then_9cc266be2bf537b6(arg0, arg1) {
const ret = arg0.then(arg1);
return ret;
}
export function __wbg_value_5e240e44f81872c5(arg0) {
const ret = arg0.value;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_versions_4e31226f5e8dc909(arg0) {
const ret = arg0.versions;
return ret;
}
export function __wbg_wbindgencbdrop_a85ed476c6a370b9(arg0) {
const obj = arg0.original;
if (obj.cnt-- == 1) {
obj.a = 0;
return true;
}
const ret = false;
return ret;
}
export function __wbg_wbindgendebugstring_bb652b1bc2061b6d(arg0, arg1) {
const ret = debugString(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg_wbindgenisfunction_ea72b9d66a0e1705(arg0) {
const ret = typeof arg0 === "function";
return ret;
}
export function __wbg_wbindgenisobject_dfe064a121d87553(arg0) {
const val = arg0;
const ret = typeof val === "object" && val !== null;
return ret;
}
export function __wbg_wbindgenisstring_4b74e4111ba029e6(arg0) {
const ret = typeof arg0 === "string";
return ret;
}
export function __wbg_wbindgenisundefined_71f08a6ade4354e7(arg0) {
const ret = arg0 === undefined;
return ret;
}
export function __wbg_wbindgenthrow_4c11a24fca429ccf(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
}
export function __wbg_writeValue_586734e0fc6e7c73() {
return handleError(function (arg0, arg1) {
const ret = arg0.writeValue(arg1);
return ret;
}, arguments);
}
export function __wbindgen_cast_0f76fca626397b3d(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 201, function: Function { arguments: [NamedExternref("Event")], shim_idx: 202, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, 201, __wbg_adapter_11);
return ret;
}
export function __wbindgen_cast_2241b6af4c4b2941(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
}
export function __wbindgen_cast_30a893855537e77b(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 201, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 202, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, 201, __wbg_adapter_11);
return ret;
}
export function __wbindgen_cast_76c05e7c8d82d9b7(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 3269, function: Function { arguments: [], shim_idx: 3270, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, 3269, __wbg_adapter_16);
return ret;
}
export function __wbindgen_cast_cb3d47e2c086b274(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 3250, function: Function { arguments: [Externref], shim_idx: 3251, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, 3250, __wbg_adapter_8);
return ret;
}
export function __wbindgen_cast_cb9088102bce6b30(arg0, arg1) {
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
const ret = getArrayU8FromWasm0(arg0, arg1);
return ret;
}
export function __wbindgen_init_externref_table() {
const table = wasm.__wbindgen_export_1;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
}

Binary file not shown.

View File

@@ -1,29 +0,0 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const create_test_dcm: (a: number, b: number) => void;
export const buttplug_activate_env_logger: (a: number, b: number) => void;
export const buttplug_client_send_json_message: (a: number, b: number, c: number, d: any) => void;
export const buttplug_create_embedded_wasm_server: (a: any) => number;
export const buttplug_free_embedded_wasm_server: (a: number) => void;
export const wasm_bindgen__closure__destroy__h72b504abf7ea70fd: (a: number, b: number) => void;
export const wasm_bindgen__closure__destroy__ha3c8e2c9b0cf79cd: (a: number, b: number) => void;
export const wasm_bindgen__closure__destroy__h0f95d90d24796def: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd: (a: number, b: number) => void;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __externref_table_alloc: () => number;
export const __wbindgen_externrefs: WebAssembly.Table;
export const __wbindgen_exn_store: (a: number) => void;
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_start: () => void;

View File

@@ -1,32 +0,0 @@
{
"name": "buttplug_wasm",
"type": "module",
"collaborators": [
"Nonpolynomial Labs, LLC <kyle@nonpolynomial.com>"
],
"description": "WASM Interop for the Buttplug Intimate Hardware Control Library",
"version": "10.0.0",
"license": "BSD-3-Clause",
"repository": {
"type": "git",
"url": "https://github.com/buttplugio/buttplug.git"
},
"files": [
"index_bg.wasm",
"index.js",
"index.d.ts"
],
"main": "index.js",
"homepage": "http://buttplug.io",
"types": "index.d.ts",
"sideEffects": [
"./snippets/*"
],
"keywords": [
"usb",
"serial",
"hardware",
"bluetooth",
"teledildonics"
]
}

18
packages/email/email.css Normal file
View File

@@ -0,0 +1,18 @@
@import "@maizzle/tailwindcss";
@theme {
/* ── Design tokens — exact mirror of frontend app.css :root ── */
--color-background: oklch(0.98 0.01 320);
--color-foreground: oklch(0.08 0.02 280);
--color-card: oklch(0.99 0.005 320);
--color-card-foreground: oklch(0.08 0.02 280);
--color-muted: oklch(0.95 0.01 280);
--color-muted-foreground: oklch(0.4 0.02 280);
--color-border: oklch(0.85 0.02 280);
--color-primary: oklch(56.971% 0.27455 319.257);
--color-primary-foreground: oklch(0.98 0.01 320);
/* ── Font ── */
--font-sans:
"Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

View File

@@ -0,0 +1,25 @@
{
"name": "@sexy/email",
"version": "1.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"dependencies": {
"@maizzle/framework": "6.0.0-15",
"@maizzle/tailwindcss": "^1.2.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,25 @@
import { renderTemplate } from "./render.js";
const BASE_URL = process.env.PUBLIC_URL ?? "https://sexy.pivoine.art";
export async function renderVerification(data: {
token: string;
}): Promise<{ subject: string; html: string }> {
return {
subject: "Verify your email address — sexy.pivoine.art",
html: await renderTemplate("verification", {
url: `${BASE_URL}/signup/verify?token=${data.token}`,
}),
};
}
export async function renderPasswordReset(data: {
token: string;
}): Promise<{ subject: string; html: string }> {
return {
subject: "Reset your password — sexy.pivoine.art",
html: await renderTemplate("password-reset", {
url: `${BASE_URL}/password/reset?token=${data.token}`,
}),
};
}

View File

@@ -0,0 +1,42 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
// At runtime (dist/render.js), __dirname is packages/email/dist/
const PKG_ROOT = path.join(__dirname, "..");
const TEMPLATES_ROOT = path.join(PKG_ROOT, "templates");
const CSS_PATH = path.join(PKG_ROOT, "email.css");
const BASE_URL = process.env.PUBLIC_URL ?? "https://sexy.pivoine.art";
export interface RenderOptions {
url: string;
[key: string]: unknown;
}
export async function renderTemplate(name: string, locals: RenderOptions): Promise<string> {
// Dynamic import: @maizzle/framework v6 is ESM-only
const { render } = await import("@maizzle/framework");
const html = await readFile(path.join(TEMPLATES_ROOT, `${name}.html`), "utf8");
const { html: rendered } = await render(html, {
components: {
root: TEMPLATES_ROOT,
folders: ["layouts"],
},
// Override PostCSS `from` so @import "@maizzle/tailwindcss" resolves
// from this package's node_modules (defu gives our value priority).
postcss: {
options: {
from: CSS_PATH,
},
},
locals: {
cssPath: CSS_PATH, // layout uses {{ cssPath }} in <link href="{{ cssPath }}" inline>
baseUrl: BASE_URL,
...locals,
},
});
return rendered;
}

View File

@@ -0,0 +1,81 @@
<!doctype html>
<html
lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="x-apple-disable-message-reformatting" />
<!--[if mso]>
<noscript
><xml
><o:OfficeDocumentSettings
><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings
></xml
></noscript
>
<![endif]-->
<title>{{ page.title || 'sexy' }}</title>
<!-- Noto Sans — progressive enhancement for clients that support web fonts -->
<style plain>
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;600;700&display=swap");
</style>
<!-- Design tokens + Tailwind preset — path resolved by render.ts -->
<link rel="stylesheet" href="{{ cssPath }}" inline />
</head>
<body class="bg-background m-0 p-0 font-sans">
<!-- Preview text (hidden) -->
<if condition="page.previewText || previewText">
<div class="hidden max-h-0 overflow-hidden">
{{ page.previewText || previewText }}
&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;
</div>
</if>
<div class="py-8 px-4">
<table
class="w-full max-w-[600px] mx-auto"
role="presentation"
cellpadding="0"
cellspacing="0"
border="0"
>
<!-- Brand header — uses --foreground as dark bg -->
<tr>
<td class="bg-foreground rounded-t-2xl px-8 py-6 text-center">
<a href="{{ baseUrl }}" style="text-decoration: none">
<span class="text-sm font-semibold tracking-[0.22em] uppercase text-background">
sexy<span class="text-primary">.</span>pivoine<span class="text-primary">.</span>art
</span>
</a>
</td>
</tr>
<!-- Card body -->
<tr>
<td class="bg-card px-8 py-10 text-[14px] text-card-foreground leading-relaxed">
<yield />
</td>
</tr>
<!-- Footer -->
<tr>
<td class="bg-muted border-t border-border rounded-b-2xl px-8 py-6 text-center">
<p class="text-[11px] text-muted-foreground m-0">
&copy; {{ new Date().getFullYear() }} sexy &mdash; For adults only (18+)
</p>
<p class="text-[11px] text-muted-foreground mt-2 mb-0">
If you did not request this email, you can safely ignore it.
</p>
</td>
</tr>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,41 @@
---
title: "Reset your password — sexy"
previewText: "You requested a password reset. Use the link below to set a new one."
---
<x-main>
<h1 class="text-[22px] font-semibold text-foreground m-0 mb-2">Reset your password</h1>
<p class="text-muted-foreground m-0 mb-6">
We received a request to reset the password for your account. Click the button below to choose a
new one.
</p>
<!-- CTA button — inline style needed for Outlook -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="mb-6">
<tr>
<td class="rounded-lg" style="background: #b700d9">
<a
href="{{ url }}"
class="inline-block px-8 py-[14px] text-[14px] font-semibold text-primary-foreground no-underline rounded-lg"
style="background: #b700d9; color: #faf4fb"
>
Reset my password
</a>
</td>
</tr>
</table>
<p class="text-[13px] text-muted-foreground m-0 mb-6">
This link expires in <strong class="text-foreground">1 hour</strong>. If you did not request a
password reset, no action is needed — your account remains secure.
</p>
<hr class="border-0 border-t border-border my-6" />
<p class="text-[12px] text-muted-foreground m-0">
Button not working? Copy and paste this link into your browser:
</p>
<p class="text-[12px] m-0 mt-1">
<a href="{{ url }}" class="text-primary break-all" style="color: #b700d9"> {{ url }} </a>
</p>
</x-main>

View File

@@ -0,0 +1,40 @@
---
title: "Verify your email — sexy"
previewText: "Almost there — confirm your email address to activate your account."
---
<x-main>
<h1 class="text-[22px] font-semibold text-foreground m-0 mb-2">Verify your email address</h1>
<p class="text-muted-foreground m-0 mb-6">
Thanks for signing up! Click the button below to confirm your email address and activate your
account.
</p>
<!-- CTA button — inline style needed for Outlook -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="mb-6">
<tr>
<td class="rounded-lg" style="background: #b700d9">
<a
href="{{ url }}"
class="inline-block px-8 py-[14px] text-[14px] font-semibold text-primary-foreground no-underline rounded-lg"
style="background: #b700d9; color: #faf4fb"
>
Verify my email
</a>
</td>
</tr>
</table>
<p class="text-[13px] text-muted-foreground m-0 mb-6">
This link expires in <strong class="text-foreground">24 hours</strong>.
</p>
<hr class="border-0 border-t border-border my-6" />
<p class="text-[12px] text-muted-foreground m-0">
Button not working? Copy and paste this link into your browser:
</p>
<p class="text-[12px] m-0 mt-1">
<a href="{{ url }}" class="text-primary break-all" style="color: #b700d9"> {{ url }} </a>
</p>
</x-main>

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "dist",
"declaration": true,
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true
},
"include": ["src"]
}

View File

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

View File

@@ -1,4 +1,5 @@
import { isAuthenticated } from "$lib/services"; import { redirect } from "@sveltejs/kit";
import { isAuthenticated, UnauthorizedError } from "$lib/services";
import { logger, generateRequestId } from "$lib/logger"; import { logger, generateRequestId } from "$lib/logger";
import type { Handle } from "@sveltejs/kit"; import type { Handle } from "@sveltejs/kit";
@@ -65,6 +66,10 @@ export const handle: Handle = async ({ event, resolve }) => {
}, },
}); });
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) {
const loginUrl = `/login?redirect=${encodeURIComponent(url.pathname)}`;
throw redirect(303, loginUrl);
}
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
logger.error("Request handler error", { logger.error("Request handler error", {
requestId, requestId,

View File

@@ -10,23 +10,23 @@
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden" class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
> >
<div <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>
<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>
<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>
<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 <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 <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> </div>
</div> </div>

View File

@@ -38,7 +38,7 @@
isMobileMenuOpen = false; isMobileMenuOpen = false;
} }
function isActiveLink(link: { name: string; href: string }) { function isActiveLink(link: { name?: string; href: string }) {
return ( return (
(page.url.pathname === "/" && link === navLinks[0]) || (page.url.pathname === "/" && link === navLinks[0]) ||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0]) (page.url.pathname.startsWith(link.href) && link !== navLinks[0])
@@ -47,7 +47,7 @@
</script> </script>
<header <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="container mx-auto px-4">
<div class="flex items-center justify-evenly h-16"> <div class="flex items-center justify-evenly h-16">
@@ -76,28 +76,14 @@
{/each} {/each}
</nav> </nav>
<!-- Desktop Auth Actions --> <!-- Auth Actions -->
{#if authStatus.authenticated} {#if authStatus.authenticated}
<div class="w-full hidden lg:flex items-center justify-end"> <div class="w-full flex items-center justify-end">
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1"> <div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
<Button <Button
variant="link" variant="link"
size="icon" size="icon"
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "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="/me"
title={$_("header.dashboard")}
>
<span class="icon-[ri--dashboard-2-line] h-4 w-4"></span>
<span
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/me" }) ? "w-full" : "group-hover:w-full"}`}
></span>
<span class="sr-only">{$_("header.dashboard")}</span>
</Button>
<Button
variant="link"
size="icon"
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
href="/play" href="/play"
title={$_("header.play")} title={$_("header.play")}
> >
@@ -112,7 +98,7 @@
<Button <Button
variant="link" variant="link"
size="icon" size="icon"
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/admin" }) ? "text-foreground" : "hover:text-foreground"}`} class={`flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/admin" }) ? "text-foreground" : "hover:text-foreground"}`}
href="/admin/users" href="/admin/users"
title="Admin" title="Admin"
> >
@@ -124,7 +110,7 @@
</Button> </Button>
{/if} {/if}
<Separator orientation="vertical" class="mx-1 h-6 bg-border/50" /> <Separator orientation="vertical" class="hidden lg:block mx-1 h-6 bg-border/50" />
<a href="/me" class="flex items-center gap-2 px-1 hover:opacity-80 transition-opacity"> <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"> <Avatar class="h-7 w-7 ring-2 ring-primary/20">
@@ -138,35 +124,24 @@
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)} {getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span class="text-sm font-medium text-foreground/90 max-w-24 truncate"> <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]} {authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
</span> </span>
</a> </a>
<Button <Button
variant="ghost" variant="link"
size="icon" size="icon"
class="h-8 w-8 rounded-full text-foreground hover:text-destructive hover:bg-destructive/10" class="hidden lg:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group hover:text-destructive"
onclick={handleLogout} onclick={handleLogout}
title={$_("header.logout")} title={$_("header.logout")}
> >
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span> <span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
</Button> </Button>
</div> </div>
</div> <div class="lg:hidden ml-2">
{:else}
<div class="hidden lg:flex w-full items-center justify-end gap-4">
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button>
<Button
href="/signup"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
>{$_("header.signup")}</Button
>
</div>
{/if}
<!-- Burger button — mobile/tablet only -->
<div class="lg:hidden ml-auto">
<BurgerMenuButton <BurgerMenuButton
label={$_("header.navigation")} label={$_("header.navigation")}
bind:isMobileMenuOpen bind:isMobileMenuOpen
@@ -174,6 +149,27 @@
/> />
</div> </div>
</div> </div>
{:else}
<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}
</div>
</div> </div>
</header> </header>

View File

@@ -9,7 +9,7 @@
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class={className} class={`rounded-full ring-2 ring-primary/20 ${className}`}
width={size} width={size}
height={size} height={size}
viewBox="0 0 10240 10240" viewBox="0 0 10240 10240"

View File

@@ -11,7 +11,6 @@
</script> </script>
<section class="relative py-12 md:py-20 overflow-hidden"> <section class="relative py-12 md:py-20 overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-b from-primary/12 via-accent/6 to-transparent"></div>
<div class="relative container mx-auto px-4 text-center"> <div class="relative container mx-auto px-4 text-center">
<div class="max-w-5xl mx-auto"> <div class="max-w-5xl mx-auto">
<h1 <h1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,6 +91,23 @@ export default {
me: { me: {
title: "Dashboard", title: "Dashboard",
welcome: "Welcome back, {name}", welcome: "Welcome back, {name}",
nav: {
profile: "Profile",
security: "Security",
recordings: "Recordings",
analytics: "Analytics",
back_to_site: "Back to site",
back_mobile: "Back",
},
analytics: {
title: "Analytics",
description: "Track your content performance and audience engagement",
total_videos: "Total Videos",
total_likes: "Total Likes",
total_plays: "Total Plays",
video_performance: "Video Performance",
video_performance_description: "Detailed metrics for each video",
},
view_profile: "View Public Profile", view_profile: "View Public Profile",
settings: { settings: {
title: "Settings", title: "Settings",
@@ -134,6 +151,10 @@ export default {
delete_confirm: "Are you sure you want to delete this recording?", delete_confirm: "Are you sure you want to delete this recording?",
delete_success: "Recording deleted successfully", delete_success: "Recording deleted successfully",
delete_error: "Failed to delete recording", delete_error: "Failed to delete recording",
publish_success: "Recording published successfully",
publish_error: "Failed to publish recording",
unpublish_success: "Recording unpublished",
unpublish_error: "Failed to unpublish recording",
}, },
}, },
recording_card: { recording_card: {
@@ -144,6 +165,8 @@ export default {
status_draft: "Draft", status_draft: "Draft",
status_published: "Published", status_published: "Published",
play: "Play", play: "Play",
publish: "Publish",
unpublish: "Unpublish",
edit: "Edit", edit: "Edit",
delete: "Delete", delete: "Delete",
public: "Public", public: "Public",
@@ -799,11 +822,19 @@ export default {
questions_email: "support@pivoine.art", questions_email: "support@pivoine.art",
}, },
play: { play: {
title: "SexyPlay", title: "Play",
description: "Bring your toys.", description: "Connect and control your Bluetooth toys.",
scan: "Start Scan", scan: "Start Scan",
scanning: "Scanning...", scanning: "Scanning...",
no_results: "No Devices founds", no_results: "No devices found",
no_results_description: "Start a scan to discover nearby Bluetooth devices",
nav: {
play: "Play",
recordings: "Recordings",
leaderboard: "Leaderboard",
back_to_site: "Back to site",
back_mobile: "Site",
},
}, },
error: { error: {
not_found: "Oops! Page Not Found", not_found: "Oops! Page Not Found",
@@ -905,14 +936,15 @@ export default {
}, },
admin: { admin: {
nav: { nav: {
back_to_site: "Back to site", back_to_site: "Back to site",
back_mobile: "Back", back_mobile: "Back",
title: "Admin", title: "Admin",
users: "Users", users: "Users",
videos: "Videos", videos: "Videos",
articles: "Articles", articles: "Articles",
comments: "Comments", comments: "Comments",
recordings: "Recordings", recordings: "Recordings",
queues: "Queues",
}, },
common: { common: {
save_changes: "Save changes", save_changes: "Save changes",
@@ -927,8 +959,8 @@ export default {
cover_image: "Cover image", cover_image: "Cover image",
tags: "Tags", tags: "Tags",
publish_date: "Publish date", publish_date: "Publish date",
title_field: "Title *", title_field: "Title",
slug_field: "Slug *", slug_field: "Slug",
title_slug_required: "Title and slug are required", title_slug_required: "Title and slug are required",
image_uploaded: "Image uploaded", image_uploaded: "Image uploaded",
image_upload_failed: "Image upload failed", image_upload_failed: "Image upload failed",
@@ -1058,6 +1090,36 @@ export default {
delete_success: "Recording deleted", delete_success: "Recording deleted",
delete_error: "Failed to delete recording", delete_error: "Failed to delete recording",
}, },
queues: {
title: "Job Queues",
pause: "Pause",
resume: "Resume",
paused_badge: "Paused",
retry: "Retry",
remove: "Remove",
retry_success: "Job retried",
retry_error: "Failed to retry job",
remove_success: "Job removed",
remove_error: "Failed to remove job",
pause_success: "Queue paused",
pause_error: "Failed to pause queue",
resume_success: "Queue resumed",
resume_error: "Failed to resume queue",
col_id: "ID",
col_name: "Name",
col_status: "Status",
col_attempts: "Attempts",
col_created: "Created",
col_actions: "Actions",
no_jobs: "No jobs found",
status_all: "All",
status_waiting: "Waiting",
status_active: "Active",
status_completed: "Completed",
status_failed: "Failed",
status_delayed: "Delayed",
failed_reason: "Reason: {reason}",
},
article_form: { article_form: {
new_title: "New article", new_title: "New article",
edit_title: "Edit article", edit_title: "Edit article",

View File

@@ -1,5 +1,5 @@
/** /**
* Server-side logging utility for sexy.pivoine.art * Server-side logging utility for sexy
* Provides structured logging with context and request tracing * Provides structured logging with context and request tracing
*/ */
@@ -20,7 +20,7 @@ interface LogContext {
class Logger { class Logger {
private isDev = process.env.NODE_ENV === "development"; private isDev = process.env.NODE_ENV === "development";
private serviceName = "sexy.pivoine.art"; private serviceName = "sexy";
private formatLog(ctx: LogContext): string { private formatLog(ctx: LogContext): string {
const { timestamp, level, message, context, requestId, userId, path, method, duration, error } = const { timestamp, level, message, context, requestId, userId, path, method, duration, error } =
@@ -126,7 +126,7 @@ class Logger {
}; };
console.log("\n" + "=".repeat(60)); console.log("\n" + "=".repeat(60));
console.log("🍑 sexy.pivoine.art - Server Starting 💜"); console.log("🍑 sexy - Server Starting 💜");
console.log("=".repeat(60)); console.log("=".repeat(60));
console.log("\n📋 Environment Configuration:"); console.log("\n📋 Environment Configuration:");
Object.entries(env).forEach(([key, value]) => { Object.entries(env).forEach(([key, value]) => {

View File

@@ -16,6 +16,22 @@ import type {
} from "$lib/types"; } from "$lib/types";
import { logger } from "$lib/logger"; import { logger } from "$lib/logger";
export class UnauthorizedError extends Error {
constructor() {
super("Unauthorized");
this.name = "UnauthorizedError";
}
}
function isUnauthorizedError(error: unknown): boolean {
if (error && typeof error === "object" && "response" in error) {
const resp = (error as { response?: { errors?: { message: string }[] } }).response;
if (resp?.errors?.some((e) => e.message === "Unauthorized")) return true;
}
const msg = error instanceof Error ? error.message : String(error);
return msg.startsWith("Unauthorized");
}
// Helper to log API calls // Helper to log API calls
async function loggedApiCall<T>( async function loggedApiCall<T>(
operationName: string, operationName: string,
@@ -32,6 +48,10 @@ async function loggedApiCall<T>(
return result; return result;
} catch (error) { } catch (error) {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
if (isUnauthorizedError(error)) {
logger.debug(`🔒 API: ${operationName} unauthorized`, { duration, context });
throw new UnauthorizedError();
}
logger.error(`❌ API: ${operationName} failed`, { logger.error(`❌ API: ${operationName} failed`, {
duration, duration,
context, context,
@@ -816,13 +836,12 @@ const RECORDINGS_QUERY = gql`
} }
`; `;
export async function getRecordings(fetchFn?: typeof globalThis.fetch) { export async function getRecordings(fetchFn?: typeof globalThis.fetch, token?: string) {
return loggedApiCall( return loggedApiCall(
"getRecordings", "getRecordings",
async () => { async () => {
const data = await getGraphQLClient(fetchFn).request<{ recordings: Recording[] }>( const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
RECORDINGS_QUERY, const data = await client.request<{ recordings: Recording[] }>(RECORDINGS_QUERY);
);
return data.recordings; return data.recordings;
}, },
{}, {},
@@ -902,6 +921,26 @@ export async function createRecording(
); );
} }
const UPDATE_RECORDING_MUTATION = gql`
mutation UpdateRecording($id: String!, $status: String, $public: Boolean) {
updateRecording(id: $id, status: $status, public: $public) {
id
status
public
}
}
`;
export async function updateRecording(id: string, fields: { status?: string; public?: boolean }) {
return loggedApiCall("updateRecording", async () => {
const data = await getGraphQLClient().request<{ updateRecording: Recording }>(
UPDATE_RECORDING_MUTATION,
{ id, ...fields },
);
return data.updateRecording;
});
}
const DELETE_RECORDING_MUTATION = gql` const DELETE_RECORDING_MUTATION = gql`
mutation DeleteRecording($id: String!) { mutation DeleteRecording($id: String!) {
deleteRecording(id: $id) deleteRecording(id: $id)
@@ -940,14 +979,12 @@ const RECORDING_QUERY = gql`
} }
`; `;
export async function getRecording(id: string, fetchFn?: typeof globalThis.fetch) { export async function getRecording(id: string, fetchFn?: typeof globalThis.fetch, token?: string) {
return loggedApiCall( return loggedApiCall(
"getRecording", "getRecording",
async () => { async () => {
const data = await getGraphQLClient(fetchFn).request<{ recording: Recording | null }>( const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
RECORDING_QUERY, const data = await client.request<{ recording: Recording | null }>(RECORDING_QUERY, { id });
{ id },
);
return data.recording; return data.recording;
}, },
{ id }, { id },
@@ -1779,13 +1816,12 @@ const ANALYTICS_QUERY = gql`
} }
`; `;
export async function getAnalytics(fetchFn?: typeof globalThis.fetch) { export async function getAnalytics(fetchFn?: typeof globalThis.fetch, token?: string) {
return loggedApiCall( return loggedApiCall(
"getAnalytics", "getAnalytics",
async () => { async () => {
const data = await getGraphQLClient(fetchFn).request<{ analytics: Analytics | null }>( const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
ANALYTICS_QUERY, const data = await client.request<{ analytics: Analytics | null }>(ANALYTICS_QUERY);
);
return data.analytics; return data.analytics;
}, },
{}, {},
@@ -1876,3 +1912,150 @@ export async function adminDeleteRecording(id: string): Promise<void> {
await getGraphQLClient().request(ADMIN_DELETE_RECORDING_MUTATION, { id }); await getGraphQLClient().request(ADMIN_DELETE_RECORDING_MUTATION, { id });
}); });
} }
// --- Queues ---
export type JobCounts = {
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
paused: number;
};
export type QueueInfo = {
name: string;
counts: JobCounts;
isPaused: boolean;
};
export type Job = {
id: string;
name: string;
queue: string;
status: string;
data: Record<string, unknown>;
result: unknown;
failedReason: string | null;
attemptsMade: number;
createdAt: string;
processedAt: string | null;
finishedAt: string | null;
progress: number | null;
};
const ADMIN_QUEUES_QUERY = gql`
query AdminQueues {
adminQueues {
name
isPaused
counts {
waiting
active
completed
failed
delayed
paused
}
}
}
`;
export async function getAdminQueues(
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<QueueInfo[]> {
return loggedApiCall("getAdminQueues", async () => {
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminQueues: QueueInfo[] }>(ADMIN_QUEUES_QUERY);
return data.adminQueues;
});
}
const ADMIN_QUEUE_JOBS_QUERY = gql`
query AdminQueueJobs($queue: String!, $status: String, $limit: Int, $offset: Int) {
adminQueueJobs(queue: $queue, status: $status, limit: $limit, offset: $offset) {
id
name
queue
status
data
result
failedReason
attemptsMade
createdAt
processedAt
finishedAt
progress
}
}
`;
export async function getAdminQueueJobs(
queue: string,
status?: string,
limit?: number,
offset?: number,
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<Job[]> {
return loggedApiCall("getAdminQueueJobs", async () => {
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminQueueJobs: Job[] }>(ADMIN_QUEUE_JOBS_QUERY, {
queue,
status,
limit,
offset,
});
return data.adminQueueJobs;
});
}
const ADMIN_RETRY_JOB_MUTATION = gql`
mutation AdminRetryJob($queue: String!, $jobId: String!) {
adminRetryJob(queue: $queue, jobId: $jobId)
}
`;
export async function adminRetryJob(queue: string, jobId: string): Promise<void> {
return loggedApiCall("adminRetryJob", async () => {
await getGraphQLClient().request(ADMIN_RETRY_JOB_MUTATION, { queue, jobId });
});
}
const ADMIN_REMOVE_JOB_MUTATION = gql`
mutation AdminRemoveJob($queue: String!, $jobId: String!) {
adminRemoveJob(queue: $queue, jobId: $jobId)
}
`;
export async function adminRemoveJob(queue: string, jobId: string): Promise<void> {
return loggedApiCall("adminRemoveJob", async () => {
await getGraphQLClient().request(ADMIN_REMOVE_JOB_MUTATION, { queue, jobId });
});
}
const ADMIN_PAUSE_QUEUE_MUTATION = gql`
mutation AdminPauseQueue($queue: String!) {
adminPauseQueue(queue: $queue)
}
`;
const ADMIN_RESUME_QUEUE_MUTATION = gql`
mutation AdminResumeQueue($queue: String!) {
adminResumeQueue(queue: $queue)
}
`;
export async function adminPauseQueue(queue: string): Promise<void> {
return loggedApiCall("adminPauseQueue", async () => {
await getGraphQLClient().request(ADMIN_PAUSE_QUEUE_MUTATION, { queue });
});
}
export async function adminResumeQueue(queue: string): Promise<void> {
return loggedApiCall("adminResumeQueue", async () => {
await getGraphQLClient().request(ADMIN_RESUME_QUEUE_MUTATION, { queue });
});
}

View File

@@ -27,10 +27,10 @@ export type {
RecentPoint, RecentPoint,
UserGamification, UserGamification,
Achievement, Achievement,
} from "@sexy.pivoine.art/types"; } from "@sexy/types";
import type { CurrentUser } from "@sexy.pivoine.art/types"; import type { CurrentUser } from "@sexy/types";
import type { ButtplugClientDevice } from "@sexy.pivoine.art/buttplug"; import type { ButtplugClientDevice } from "@sexy/buttplug";
// ─── Frontend-only types ───────────────────────────────────────────────────── // ─── Frontend-only types ─────────────────────────────────────────────────────

View File

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

View File

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

View File

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

View File

@@ -11,9 +11,11 @@
import { Textarea } from "$lib/components/ui/textarea"; import { Textarea } from "$lib/components/ui/textarea";
import { TagsInput } from "$lib/components/ui/tags-input"; import { TagsInput } from "$lib/components/ui/tags-input";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone"; import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { Card, CardContent } from "$lib/components/ui/card";
import { getAssetUrl } from "$lib/api"; import { getAssetUrl } from "$lib/api";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import { DatePicker } from "$lib/components/ui/date-picker"; import { DatePicker } from "$lib/components/ui/date-picker";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props(); const { data } = $props();
@@ -93,29 +95,48 @@
} }
</script> </script>
<div class="p-3 sm:p-6"> <Meta title={$_("admin.article_form.edit_title")} description={null} />
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/articles" size="sm"> <div class="py-3 sm:py-6 lg:pl-6">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")} <div class="mb-6">
</Button> <h1 class="text-2xl font-bold">{data.article.title}</h1>
<h1 class="text-2xl font-bold">{$_("admin.article_form.edit_title")}</h1> <p class="text-xs text-muted-foreground mt-0.5">
{data.article.slug}{data.article.category ? " · " + data.article.category : ""}{data.article
.author
? " · " + data.article.author.artist_name
: ""}
</p>
</div> </div>
<div class="space-y-5 max-w-4xl"> <Card class="bg-card/50 border-primary/20 max-w-4xl">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label> <Label for="title">{$_("admin.common.title_field")}</Label>
<Input id="title" bind:value={title} /> <Input
id="title"
bind:value={title}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label> <Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input id="slug" bind:value={slug} /> <Input
id="slug"
bind:value={slug}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div> </div>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label> <Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
<Textarea id="excerpt" bind:value={excerpt} rows={2} /> <Textarea
id="excerpt"
bind:value={excerpt}
rows={2}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div> </div>
<!-- Markdown editor with live preview --> <!-- Markdown editor with live preview -->
@@ -123,22 +144,24 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label>{$_("admin.article_form.content")}</Label> <Label>{$_("admin.article_form.content")}</Label>
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden"> <div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
<button <Button
type="button" variant="ghost"
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`} size="sm"
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</button class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</Button
> >
<button <Button
type="button" variant="ghost"
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`} size="sm"
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</button class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</Button
> >
</div> </div>
</div> </div>
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96"> <div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
<Textarea <Textarea
bind:value={content} bind:value={content}
class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`} class={`h-full min-h-96 font-mono text-sm resize-none bg-background/50 border-primary/20 focus:border-primary ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
/> />
<div <div
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`} class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
@@ -166,11 +189,10 @@
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} /> <FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
</div> </div>
<!-- Author -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>{$_("admin.article_form.author")}</Label> <Label>{$_("admin.article_form.author")}</Label>
<Select type="single" bind:value={authorId}> <Select type="single" bind:value={authorId}>
<SelectTrigger class="w-full"> <SelectTrigger class="w-full bg-background/50 border-primary/20">
{#if selectedAuthor} {#if selectedAuthor}
{#if selectedAuthor.avatar} {#if selectedAuthor.avatar}
<img <img
@@ -205,7 +227,11 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="category">{$_("admin.article_form.category")}</Label> <Label for="category">{$_("admin.article_form.category")}</Label>
<Input id="category" bind:value={category} /> <Input
id="category"
bind:value={category}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label> <Label>{$_("admin.common.publish_date")}</Label>
@@ -215,7 +241,10 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label> <Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} /> <TagsInput
bind:value={tags}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div> </div>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
@@ -223,15 +252,13 @@
<span class="text-sm">{$_("admin.common.featured")}</span> <span class="text-sm">{$_("admin.common.featured")}</span>
</label> </label>
<div class="flex gap-3 pt-2">
<Button <Button
onclick={handleSubmit} onclick={handleSubmit}
disabled={saving} disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90" class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
> >
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")} {saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button> </Button>
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button> </CardContent>
</div> </Card>
</div>
</div> </div>

View File

@@ -11,6 +11,8 @@
import { TagsInput } from "$lib/components/ui/tags-input"; import { TagsInput } from "$lib/components/ui/tags-input";
import { DatePicker } from "$lib/components/ui/date-picker"; import { DatePicker } from "$lib/components/ui/date-picker";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone"; import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { Card, CardContent } from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
let title = $state(""); let title = $state("");
let slug = $state(""); let slug = $state("");
@@ -75,15 +77,15 @@
} }
</script> </script>
<div class="p-3 sm:p-6"> <Meta title={$_("admin.article_form.new_title")} description={null} />
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/articles" size="sm"> <div class="py-3 sm:py-6 lg:pl-6">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")} <div class="mb-6">
</Button>
<h1 class="text-2xl font-bold">{$_("admin.article_form.new_title")}</h1> <h1 class="text-2xl font-bold">{$_("admin.article_form.new_title")}</h1>
</div> </div>
<div class="space-y-5 max-w-4xl"> <Card class="bg-card/50 border-primary/20 max-w-4xl">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label> <Label for="title">{$_("admin.common.title_field")}</Label>
@@ -94,6 +96,7 @@
if (!slug) slug = generateSlug(title); if (!slug) slug = generateSlug(title);
}} }}
placeholder={$_("admin.article_form.title_placeholder")} placeholder={$_("admin.article_form.title_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/> />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
@@ -102,6 +105,7 @@
id="slug" id="slug"
bind:value={slug} bind:value={slug}
placeholder={$_("admin.article_form.slug_placeholder")} placeholder={$_("admin.article_form.slug_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/> />
</div> </div>
</div> </div>
@@ -113,6 +117,7 @@
bind:value={excerpt} bind:value={excerpt}
placeholder={$_("admin.article_form.excerpt_placeholder")} placeholder={$_("admin.article_form.excerpt_placeholder")}
rows={2} rows={2}
class="bg-background/50 border-primary/20 focus:border-primary"
/> />
</div> </div>
@@ -121,15 +126,17 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label>{$_("admin.article_form.content")}</Label> <Label>{$_("admin.article_form.content")}</Label>
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden"> <div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
<button <Button
type="button" variant="ghost"
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`} size="sm"
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</button class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</Button
> >
<button <Button
type="button" variant="ghost"
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`} size="sm"
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</button class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</Button
> >
</div> </div>
</div> </div>
@@ -138,7 +145,7 @@
<Textarea <Textarea
bind:value={content} bind:value={content}
placeholder={$_("admin.article_form.content_placeholder")} placeholder={$_("admin.article_form.content_placeholder")}
class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`} class={`h-full min-h-96 font-mono text-sm resize-none bg-background/50 border-primary/20 focus:border-primary ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
/> />
<div <div
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`} class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
@@ -157,9 +164,9 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>{$_("admin.common.cover_image")}</Label> <Label>{$_("admin.common.cover_image")}</Label>
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} /> <FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
{#if imageId}<p class="text-xs text-green-600 mt-1"> {#if imageId}
{$_("admin.common.image_uploaded")} <p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")}</p>
</p>{/if} {/if}
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -169,6 +176,7 @@
id="category" id="category"
bind:value={category} bind:value={category}
placeholder={$_("admin.article_form.category_placeholder")} placeholder={$_("admin.article_form.category_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/> />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
@@ -179,7 +187,10 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label> <Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} /> <TagsInput
bind:value={tags}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div> </div>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
@@ -187,15 +198,13 @@
<span class="text-sm">{$_("admin.common.featured")}</span> <span class="text-sm">{$_("admin.common.featured")}</span>
</label> </label>
<div class="flex gap-3 pt-2">
<Button <Button
onclick={handleSubmit} onclick={handleSubmit}
disabled={saving} disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90" class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
> >
{saving ? $_("admin.common.creating") : $_("admin.article_form.create")} {saving ? $_("admin.common.creating") : $_("admin.article_form.create")}
</Button> </Button>
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button> </CardContent>
</div> </Card>
</div>
</div> </div>

View File

@@ -10,6 +10,8 @@
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog"; import * as Dialog from "$lib/components/ui/dialog";
import TimeAgo from "javascript-time-ago"; import TimeAgo from "javascript-time-ago";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props(); const { data } = $props();
const timeAgo = new TimeAgo("en"); const timeAgo = new TimeAgo("en");
@@ -53,15 +55,17 @@
} }
</script> </script>
<div class="py-3 sm:py-6 sm:pl-6"> <Meta title={$_("admin.comments.title")} description={null} />
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.comments.title")}</h1> <h1 class="text-2xl font-bold">{$_("admin.comments.title")}</h1>
<span class="text-sm text-muted-foreground" <span class="text-sm text-muted-foreground"
>{$_("admin.users.total", { values: { total: data.total } })}</span >{$_("admin.users.total", { values: { total: data.total } })}</span
> >
</div> </div>
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0"> <div class="flex flex-wrap gap-3 mb-4">
<Input <Input
placeholder={$_("admin.comments.search_placeholder")} placeholder={$_("admin.comments.search_placeholder")}
class="max-w-xs" class="max-w-xs"
@@ -150,7 +154,7 @@
</div> </div>
{#if data.total > data.limit} {#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0"> <div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{$_("admin.users.showing", { {$_("admin.users.showing", {
values: { values: {
@@ -160,28 +164,15 @@
}, },
})} })}
</span> </span>
<div class="flex gap-2"> <Pagination
<Button currentPage={Math.floor(data.offset / data.limit) + 1}
size="sm" totalPages={Math.ceil(data.total / data.limit)}
variant="outline" onPageChange={(p) => {
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString()); const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit))); params.set("offset", String((p - 1) * data.limit));
goto(`?${params.toString()}`); goto(`?${params.toString()}`);
}}>{$_("common.previous")}</Button }}
> />
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}>{$_("common.next")}</Button
>
</div>
</div> </div>
{/if} {/if}
</div> </div>

View File

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

View File

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

View File

@@ -11,6 +11,8 @@
import * as Dialog from "$lib/components/ui/dialog"; import * as Dialog from "$lib/components/ui/dialog";
import type { Recording } from "$lib/types"; import type { Recording } from "$lib/types";
import TimeAgo from "javascript-time-ago"; import TimeAgo from "javascript-time-ago";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props(); const { data } = $props();
const timeAgo = new TimeAgo("en"); const timeAgo = new TimeAgo("en");
@@ -63,15 +65,17 @@
} }
</script> </script>
<div class="py-3 sm:py-6 sm:pl-6"> <Meta title={$_("admin.recordings.title")} description={null} />
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.recordings.title")}</h1> <h1 class="text-2xl font-bold">{$_("admin.recordings.title")}</h1>
<span class="text-sm text-muted-foreground" <span class="text-sm text-muted-foreground"
>{$_("admin.users.total", { values: { total: data.total } })}</span >{$_("admin.users.total", { values: { total: data.total } })}</span
> >
</div> </div>
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0"> <div class="flex flex-wrap items-center gap-3 mb-4">
<Input <Input
placeholder={$_("admin.recordings.search_placeholder")} placeholder={$_("admin.recordings.search_placeholder")}
class="max-w-xs" class="max-w-xs"
@@ -128,10 +132,12 @@
<td class="px-4 py-3 hidden sm:table-cell"> <td class="px-4 py-3 hidden sm:table-cell">
<div class="flex gap-1"> <div class="flex gap-1">
<Badge <Badge
variant={recording.status === "published" ? "default" : "outline"} variant="outline"
class={recording.status === "draft" ? "text-muted-foreground" : ""} class={recording.status === "published"
? "text-green-600 border-green-500/40 bg-green-500/10"
: "text-yellow-600 border-yellow-500/40 bg-yellow-500/10"}
> >
{recording.status} {$_(`recording_card.status_${recording.status}`)}
</Badge> </Badge>
{#if recording.public} {#if recording.public}
<Badge variant="outline" class="text-blue-600 border-blue-500/40 bg-blue-500/10" <Badge variant="outline" class="text-blue-600 border-blue-500/40 bg-blue-500/10"
@@ -174,7 +180,7 @@
</div> </div>
{#if data.total > data.limit} {#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0"> <div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{$_("admin.users.showing", { {$_("admin.users.showing", {
values: { values: {
@@ -184,28 +190,15 @@
}, },
})} })}
</span> </span>
<div class="flex gap-2"> <Pagination
<Button currentPage={Math.floor(data.offset / data.limit) + 1}
size="sm" totalPages={Math.ceil(data.total / data.limit)}
variant="outline" onPageChange={(p) => {
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString()); const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit))); params.set("offset", String((p - 1) * data.limit));
goto(`?${params.toString()}`); goto(`?${params.toString()}`);
}}>{$_("common.previous")}</Button }}
> />
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}>{$_("common.next")}</Button
>
</div>
</div> </div>
{/if} {/if}
</div> </div>

View File

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

View File

@@ -13,7 +13,9 @@
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { Card, CardContent } from "$lib/components/ui/card";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone"; import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props(); const { data } = $props();
@@ -125,11 +127,10 @@
} }
</script> </script>
<div class="p-3 sm:p-6 max-w-2xl"> <Meta title={data.user.artist_name || data.user.email} description={null} />
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/users" size="sm"> <div class="py-3 sm:py-6 lg:pl-6">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")} <div class="mb-6">
</Button>
<div> <div>
<h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1> <h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
@@ -140,25 +141,38 @@
</div> </div>
</div> </div>
<div class="space-y-6"> <div class="space-y-6 max-w-2xl">
<!-- Basic info --> <!-- Profile & files card -->
<Card class="bg-card/50 border-primary/20">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="firstName">{$_("admin.user_edit.first_name")}</Label> <Label for="firstName">{$_("admin.user_edit.first_name")}</Label>
<Input id="firstName" bind:value={firstName} /> <Input
id="firstName"
bind:value={firstName}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="lastName">{$_("admin.user_edit.last_name")}</Label> <Label for="lastName">{$_("admin.user_edit.last_name")}</Label>
<Input id="lastName" bind:value={lastName} /> <Input
id="lastName"
bind:value={lastName}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div> </div>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="artistName">{$_("admin.user_edit.artist_name")}</Label> <Label for="artistName">{$_("admin.user_edit.artist_name")}</Label>
<Input id="artistName" bind:value={artistName} /> <Input
id="artistName"
bind:value={artistName}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div> </div>
<!-- Avatar -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>{$_("admin.user_edit.avatar")}</Label> <Label>{$_("admin.user_edit.avatar")}</Label>
{#if avatarId} {#if avatarId}
@@ -168,10 +182,13 @@
class="h-20 w-20 rounded-full object-cover mb-2" class="h-20 w-20 rounded-full object-cover mb-2"
/> />
{/if} {/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleAvatarUpload} /> <FileDropZone
accept="image/*"
maxFileSize={10 * MEGABYTE}
onUpload={handleAvatarUpload}
/>
</div> </div>
<!-- Banner -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>{$_("admin.user_edit.banner")}</Label> <Label>{$_("admin.user_edit.banner")}</Label>
{#if bannerId} {#if bannerId}
@@ -181,10 +198,13 @@
class="w-full h-24 rounded object-cover mb-2" class="w-full h-24 rounded object-cover mb-2"
/> />
{/if} {/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} /> <FileDropZone
accept="image/*"
maxFileSize={10 * MEGABYTE}
onUpload={handleBannerUpload}
/>
</div> </div>
<!-- Model photo (used in cards & model page, not for avatar/comments) -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>{$_("admin.user_edit.model_photo")}</Label> <Label>{$_("admin.user_edit.model_photo")}</Label>
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.model_photo_hint")}</p> <p class="text-xs text-muted-foreground">{$_("admin.user_edit.model_photo_hint")}</p>
@@ -195,10 +215,13 @@
class="w-full h-48 rounded object-cover mb-2" class="w-full h-48 rounded object-cover mb-2"
/> />
{/if} {/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload2} /> <FileDropZone
accept="image/*"
maxFileSize={10 * MEGABYTE}
onUpload={handlePhotoUpload2}
/>
</div> </div>
<!-- Admin flag -->
<label <label
class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors" class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"
> >
@@ -213,18 +236,19 @@
</div> </div>
</label> </label>
<div class="flex gap-3">
<Button <Button
onclick={handleSave} onclick={handleSave}
disabled={saving} disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90" class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
> >
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")} {saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button> </Button>
</div> </CardContent>
</Card>
<!-- Photo gallery --> <!-- Photo gallery card -->
<div class="space-y-3 pt-4 border-t border-border/40"> <Card class="bg-card/50 border-primary/20">
<CardContent class="space-y-4 pt-6">
<Label>{$_("admin.user_edit.photos")}</Label> <Label>{$_("admin.user_edit.photos")}</Label>
{#if data.user.photos && data.user.photos.length > 0} {#if data.user.photos && data.user.photos.length > 0}
@@ -236,13 +260,14 @@
alt="" alt=""
class="w-full aspect-square object-cover rounded" class="w-full aspect-square object-cover rounded"
/> />
<button <Button
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded" variant="ghost"
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded h-auto p-0"
onclick={() => removePhoto(photo.id)} onclick={() => removePhoto(photo.id)}
type="button" aria-label="Remove photo"
> >
<span class="icon-[ri--delete-bin-line] h-5 w-5 text-white"></span> <span class="icon-[ri--delete-bin-line] h-5 w-5 text-white"></span>
</button> </Button>
</div> </div>
{/each} {/each}
</div> </div>
@@ -251,6 +276,7 @@
{/if} {/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} /> <FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
</div> </CardContent>
</Card>
</div> </div>
</div> </div>

View File

@@ -11,6 +11,8 @@
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog"; import * as Dialog from "$lib/components/ui/dialog";
import type { Video } from "$lib/types"; import type { Video } from "$lib/types";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props(); const { data } = $props();
@@ -61,8 +63,10 @@
} }
</script> </script>
<div class="py-3 sm:py-6 sm:pl-6"> <Meta title={$_("admin.videos.title")} description={null} />
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.videos.title")}</h1> <h1 class="text-2xl font-bold">{$_("admin.videos.title")}</h1>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground" <span class="text-sm text-muted-foreground"
@@ -78,7 +82,7 @@
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0"> <div class="flex flex-wrap items-center gap-3 mb-4">
<Input <Input
placeholder={$_("admin.videos.search_placeholder")} placeholder={$_("admin.videos.search_placeholder")}
class="max-w-xs" class="max-w-xs"
@@ -206,7 +210,7 @@
<!-- Pagination --> <!-- Pagination -->
{#if data.total > data.limit} {#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0"> <div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{$_("admin.users.showing", { {$_("admin.users.showing", {
values: { values: {
@@ -216,32 +220,15 @@
}, },
})} })}
</span> </span>
<div class="flex gap-2"> <Pagination
<Button currentPage={Math.floor(data.offset / data.limit) + 1}
size="sm" totalPages={Math.ceil(data.total / data.limit)}
variant="outline" onPageChange={(p) => {
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString()); const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit))); params.set("offset", String((p - 1) * data.limit));
goto(`?${params.toString()}`); goto(`?${params.toString()}`);
}} }}
> />
{$_("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}
>
{$_("common.next")}
</Button>
</div>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -10,9 +10,11 @@
import { Textarea } from "$lib/components/ui/textarea"; import { Textarea } from "$lib/components/ui/textarea";
import { TagsInput } from "$lib/components/ui/tags-input"; import { TagsInput } from "$lib/components/ui/tags-input";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone"; import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { Card, CardContent } from "$lib/components/ui/card";
import { getAssetUrl } from "$lib/api"; import { getAssetUrl } from "$lib/api";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import { DatePicker } from "$lib/components/ui/date-picker"; import { DatePicker } from "$lib/components/ui/date-picker";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props(); const { data } = $props();
@@ -102,15 +104,20 @@
} }
</script> </script>
<div class="p-3 sm:p-6 max-w-2xl"> <Meta title={$_("admin.video_form.edit_title")} description={null} />
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/videos" size="sm"> <div class="py-3 sm:py-6 lg:pl-6">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")} <div class="mb-6">
</Button> <h1 class="text-2xl font-bold">{data.video.title}</h1>
<h1 class="text-2xl font-bold">{$_("admin.video_form.edit_title")}</h1> <p class="text-xs text-muted-foreground mt-0.5">
{data.video.slug}{data.video.premium ? " · premium" : ""}{data.video.featured
? " · featured"
: ""}
</p>
</div> </div>
<div class="space-y-5"> <Card class="bg-card/50 border-primary/20 max-w-2xl">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label> <Label for="title">{$_("admin.common.title_field")}</Label>
@@ -118,11 +125,17 @@
id="title" id="title"
bind:value={title} bind:value={title}
placeholder={$_("admin.video_form.title_placeholder")} placeholder={$_("admin.video_form.title_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/> />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label> <Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} /> <Input
id="slug"
bind:value={slug}
placeholder={$_("admin.video_form.slug_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div> </div>
</div> </div>
@@ -133,6 +146,7 @@
bind:value={description} bind:value={description}
placeholder={$_("admin.video_form.description_placeholder")} placeholder={$_("admin.video_form.description_placeholder")}
rows={3} rows={3}
class="bg-background/50 border-primary/20 focus:border-primary"
/> />
</div> </div>
@@ -165,7 +179,10 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label> <Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} /> <TagsInput
bind:value={tags}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
@@ -192,7 +209,7 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>{$_("admin.video_form.models")}</Label> <Label>{$_("admin.video_form.models")}</Label>
<Select type="multiple" bind:value={selectedModelIds}> <Select type="multiple" bind:value={selectedModelIds}>
<SelectTrigger class="w-full"> <SelectTrigger class="w-full bg-background/50 border-primary/20">
{#if selectedModelIds.length} {#if selectedModelIds.length}
{$_("admin.video_form.models_selected", { {$_("admin.video_form.models_selected", {
values: { count: selectedModelIds.length }, values: { count: selectedModelIds.length },
@@ -219,15 +236,13 @@
</div> </div>
{/if} {/if}
<div class="flex gap-3 pt-2">
<Button <Button
onclick={handleSubmit} onclick={handleSubmit}
disabled={saving} disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90" class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
> >
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")} {saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button> </Button>
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button> </CardContent>
</div> </Card>
</div>
</div> </div>

View File

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

View File

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

View File

@@ -13,6 +13,7 @@
import Meta from "$lib/components/meta/meta.svelte"; import Meta from "$lib/components/meta/meta.svelte";
import SexyBackground from "$lib/components/background/background.svelte"; import SexyBackground from "$lib/components/background/background.svelte";
import PageHero from "$lib/components/page-hero/page-hero.svelte"; import PageHero from "$lib/components/page-hero/page-hero.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const timeAgo = new TimeAgo("en"); const timeAgo = new TimeAgo("en");
const { data } = $props(); const { data } = $props();
@@ -49,23 +50,6 @@
else params.delete("page"); else params.delete("page");
goto(`?${params.toString()}`); goto(`?${params.toString()}`);
} }
const totalPages = $derived(Math.ceil(data.total / data.limit));
const pageNumbers = $derived(() => {
const pages: (number | -1)[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (data.page > 3) pages.push(-1);
for (let i = Math.max(2, data.page - 1); i <= Math.min(totalPages - 1, data.page + 1); i++)
pages.push(i);
if (data.page < totalPages - 2) pages.push(-1);
pages.push(totalPages);
}
return pages;
});
</script> </script>
<Meta title={$_("magazine.title")} description={$_("magazine.description")} /> <Meta title={$_("magazine.title")} description={$_("magazine.description")} />
@@ -308,38 +292,13 @@
{/if} {/if}
<!-- Pagination --> <!-- Pagination -->
{#if totalPages > 1} {#if Math.ceil(data.total / data.limit) > 1}
<div class="flex flex-col items-center gap-3 mt-10"> <div class="flex flex-col items-center gap-3 mt-10">
<div class="flex items-center gap-1"> <Pagination
<Button currentPage={data.page}
variant="outline" totalPages={Math.ceil(data.total / data.limit)}
size="sm" onPageChange={goToPage}
disabled={data.page <= 1} />
onclick={() => goToPage(data.page - 1)}
class="border-primary/20 hover:bg-primary/10">{$_("common.previous")}</Button
>
{#each pageNumbers() as p, i (i)}
{#if p === -1}
<span class="px-2 text-muted-foreground select-none"></span>
{:else}
<Button
variant={p === data.page ? "default" : "outline"}
size="sm"
onclick={() => goToPage(p)}
class={p === data.page
? "bg-gradient-to-r from-primary to-accent min-w-9"
: "border-primary/20 hover:bg-primary/10 min-w-9"}>{p}</Button
>
{/if}
{/each}
<Button
variant="outline"
size="sm"
disabled={data.page >= totalPages}
onclick={() => goToPage(data.page + 1)}
class="border-primary/20 hover:bg-primary/10">{$_("common.next")}</Button
>
</div>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
{$_("common.total_results", { values: { total: data.total } })} {$_("common.total_results", { values: { total: data.total } })}
</p> </p>

View File

@@ -0,0 +1,12 @@
import { redirect } from "@sveltejs/kit";
import { isModel } from "$lib/api";
export async function load({ locals }) {
if (!locals.authStatus.authenticated) {
throw redirect(302, "/login");
}
return {
authStatus: locals.authStatus,
isModel: isModel(locals.authStatus.user!),
};
}

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { page } from "$app/state";
import { _ } from "svelte-i18n";
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import { getAssetUrl } from "$lib/api";
const { children, data } = $props();
const navLinks = $derived([
{ name: $_("me.nav.profile"), href: "/me/profile", icon: "icon-[ri--user-line]" },
{ name: $_("me.nav.security"), href: "/me/security", icon: "icon-[ri--shield-keyhole-line]" },
...(data.isModel
? [
{
name: $_("me.nav.analytics"),
href: "/me/analytics",
icon: "icon-[ri--line-chart-line]",
},
]
: []),
]);
function isActive(href: string) {
return page.url.pathname.startsWith(href);
}
const user = $derived(data.authStatus.user!);
const avatarUrl = $derived(
user.avatar ? (getAssetUrl(user.avatar, "thumbnail") ?? undefined) : undefined,
);
const displayName = $derived(user.artist_name ?? user.email);
</script>
<div class="min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
<div class="container mx-auto px-4">
<!-- Mobile top nav -->
<div class="lg:hidden border-b border-border/40">
<div class="flex items-center gap-1 overflow-x-auto py-2 scrollbar-none">
<a
href="/"
class="shrink-0 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
>
<span class="icon-[ri--arrow-left-line] h-4 w-4"></span>
<span class="hidden sm:inline">{$_("me.nav.back_mobile")}</span>
</a>
{#each navLinks as link (link.href)}
<a
href={link.href}
class={`shrink-0 flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
isActive(link.href)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span class={`${link.icon} h-4 w-4 shrink-0`}></span>
<span class="hidden sm:inline">{link.name}</span>
</a>
{/each}
</div>
</div>
<!-- Desktop layout -->
<div class="flex min-h-screen">
<!-- Sidebar (desktop only) -->
<aside class="hidden lg:flex w-56 shrink-0 flex-col border-r border-border/40">
<div class="px-4 py-5 border-b border-border/40">
<a
href="/"
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span class="icon-[ri--arrow-left-line] h-3.5 w-3.5"></span>
{$_("me.nav.back_to_site")}
</a>
<div class="mt-3 flex items-center gap-3">
<Avatar class="h-9 w-9 shrink-0">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback class="text-xs">
{getUserInitials(displayName)}
</AvatarFallback>
</Avatar>
<div class="min-w-0">
<p class="text-sm font-semibold text-foreground truncate">{displayName}</p>
<p class="text-xs text-muted-foreground">{$_("me.title")}</p>
</div>
</div>
</div>
<nav class="flex-1 p-3 space-y-1">
{#each navLinks as link (link.href)}
<a
href={link.href}
class={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive(link.href)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span class={`${link.icon} h-4 w-4`}></span>
{link.name}
</a>
{/each}
</nav>
</aside>
<!-- Main content -->
<main class="flex-1 min-w-0">
{@render children()}
</main>
</div>
</div>
</div>

View File

@@ -1,25 +1,4 @@
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
import { getAnalytics, getFolders, getRecordings } from "$lib/services"; export function load() {
import { isModel } from "$lib/api"; throw redirect(302, "/me/profile");
export async function load({ locals, fetch }) {
// Redirect to login if not authenticated
if (!locals.authStatus.authenticated) {
throw redirect(302, "/login");
}
const recordings = await getRecordings(fetch).catch(() => []);
const analytics = isModel(locals.authStatus.user!)
? await getAnalytics(fetch).catch(() => null)
: null;
const folders = await getFolders(fetch).catch(() => []);
return {
authStatus: locals.authStatus,
folders,
recordings,
analytics,
};
} }

View File

@@ -1,690 +0,0 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import SexyBackground from "$lib/components/background/background.svelte";
import { onMount, untrack } from "svelte";
import { goto, invalidateAll } from "$app/navigation";
import { getAssetUrl, isModel } from "$lib/api";
import * as Alert from "$lib/components/ui/alert";
import { toast } from "svelte-sonner";
import { deleteRecording, removeFile, updateProfile, uploadFile } from "$lib/services";
import * as Dialog from "$lib/components/ui/dialog";
import { Textarea } from "$lib/components/ui/textarea";
import Meta from "$lib/components/meta/meta.svelte";
import { TagsInput } from "$lib/components/ui/tags-input";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import RecordingCard from "$lib/components/recording-card/recording-card.svelte";
const { data } = $props();
let recordings = $state(untrack(() => data.recordings));
let deleteTarget = $state<string | null>(null);
let deleteOpen = $state(false);
let deleting = $state(false);
let activeTab = $state("settings");
let firstName = $state(untrack(() => data.authStatus.user!.first_name));
let lastName = $state(untrack(() => data.authStatus.user!.last_name));
let artistName = $state(untrack(() => data.authStatus.user!.artist_name));
let description = $state(untrack(() => data.authStatus.user!.description));
let tags = $state(untrack(() => data.authStatus.user!.tags ?? undefined));
$effect(() => {
recordings = data.recordings;
firstName = data.authStatus.user!.first_name;
lastName = data.authStatus.user!.last_name;
artistName = data.authStatus.user!.artist_name;
description = data.authStatus.user!.description;
tags = data.authStatus.user!.tags ?? undefined;
email = data.authStatus.user!.email;
});
let email = $state(untrack(() => data.authStatus.user!.email));
let password = $state("");
let confirmPassword = $state("");
let showPassword = $state(false);
let showConfirmPassword = $state(false);
let isProfileLoading = $state(false);
let isProfileError = $state(false);
let profileError = $state("");
let isSecurityLoading = $state(false);
let isSecurityError = $state(false);
let securityError = $state("");
async function handleProfileSubmit(e: Event) {
e.preventDefault();
try {
isProfileLoading = true;
isProfileError = false;
profileError = "";
let avatarId: string | null | undefined = undefined;
if (!avatar?.id && data.authStatus.user!.avatar) {
// User removed their avatar
await removeFile(data.authStatus.user!.avatar);
avatarId = null;
} else if (avatar?.id) {
// Keep existing avatar
avatarId = avatar.id;
}
if (avatar?.file) {
const formData = new FormData();
formData.append("file", avatar.file);
const result = await uploadFile(formData);
avatarId = result.id;
}
await updateProfile({
first_name: firstName,
last_name: lastName,
artist_name: artistName,
description,
tags,
avatar: avatarId ?? undefined,
});
toast.success($_("me.settings.toast_update"));
invalidateAll();
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
profileError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
isProfileError = true;
} finally {
isProfileLoading = false;
}
}
async function handleSecuritySubmit(e: Event) {
e.preventDefault();
try {
if (password !== confirmPassword) {
throw new Error($_("me.settings.password_error"));
}
isSecurityLoading = true;
isSecurityError = false;
securityError = "";
await updateProfile({
email,
password,
});
toast.success($_("me.settings.toast_update"));
invalidateAll();
password = confirmPassword = "";
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
securityError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
isSecurityError = true;
} finally {
isSecurityLoading = false;
}
}
let avatar = $state<{
id?: string;
url: string;
name: string;
size: number;
file?: File;
}>();
async function handleFilesUpload(files: File[]) {
const file = files[0];
avatar = {
name: file.name,
size: file.size,
url: URL.createObjectURL(file),
file,
};
}
async function handleAvatarRemove() {
if (avatar!.id) {
avatar = undefined;
} else {
setExistingAvatar();
}
}
function setExistingAvatar() {
if (data.authStatus.user!.avatar) {
avatar = {
id: data.authStatus.user!.avatar,
url: getAssetUrl(data.authStatus.user!.avatar, "thumbnail")!,
name: data.authStatus.user!.artist_name ?? "",
size: 0,
};
} else {
avatar = undefined;
}
}
function handleDeleteRecording(id: string) {
deleteTarget = id;
deleteOpen = true;
}
async function confirmDeleteRecording() {
if (!deleteTarget) return;
deleting = true;
try {
await deleteRecording(deleteTarget);
recordings = recordings.filter((r) => r.id !== deleteTarget);
toast.success($_("me.recordings.delete_success"));
deleteOpen = false;
deleteTarget = null;
} catch {
toast.error($_("me.recordings.delete_error"));
} finally {
deleting = false;
}
}
function handlePlayRecording(id: string) {
// Navigate to play page with recording ID
goto(`/play?recording=${id}`);
}
onMount(() => {
if (data.authStatus.authenticated) {
setExistingAvatar();
return;
}
goto("/login");
});
</script>
<Meta
title={$_("me.title")}
description={$_("me.welcome", {
values: { name: data.authStatus.user!.artist_name },
})}
/>
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<SexyBackground />
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">{$_("me.title")}</h1>
<p class="text-sm text-muted-foreground mt-0.5">
{$_("me.welcome", { values: { name: data.authStatus.user!.artist_name } })}
</p>
</div>
{#if isModel(data.authStatus.user!)}
<Button href={`/models/${data.authStatus.user!.slug}`} variant="outline">
{$_("me.view_profile")}
</Button>
{/if}
</div>
<!-- Dashboard Tabs -->
<Tabs bind:value={activeTab} class="w-full">
<TabsList class="grid w-full {data.analytics ? 'grid-cols-3' : 'grid-cols-2'} max-w-2xl mb-8">
<TabsTrigger value="settings" class="flex items-center gap-2">
<span class="icon-[ri--settings-4-line] w-4 h-4"></span>
{$_("me.settings.title")}
</TabsTrigger>
<TabsTrigger value="recordings" class="flex items-center gap-2">
<span class="icon-[ri--play-list-2-line] w-4 h-4"></span>
{$_("me.recordings.title")}
</TabsTrigger>
{#if data.analytics}
<TabsTrigger value="analytics" class="flex items-center gap-2">
<span class="icon-[ri--line-chart-line] w-4 h-4"></span>
Analytics
</TabsTrigger>
{/if}
</TabsList>
<!-- Settings Tab -->
<TabsContent value="settings" class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Profile Settings -->
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle>{$_("me.settings.profile_title")}</CardTitle>
<CardDescription>{$_("me.settings.profile_subtitle")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<form onsubmit={handleProfileSubmit} class="space-y-4">
<div class="space-y-2">
<Label>{$_("me.settings.avatar")}</Label>
<div class="flex items-center gap-5">
<FileDropZone
id="avatar"
fileCount={0}
maxFiles={1}
maxFileSize={2 * MEGABYTE}
onUpload={handleFilesUpload}
accept="image/*"
class="h-auto w-auto shrink-0 border-none p-0 rounded-full hover:bg-transparent"
>
<div class="relative group cursor-pointer w-24 h-24">
{#if avatar}
<img
src={avatar.url}
alt={avatar.name}
class="w-24 h-24 rounded-full object-cover ring-4 ring-primary/20 group-hover:ring-primary/50 transition-all"
/>
<div
class="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<span class="icon-[ri--camera-line] w-7 h-7 text-white"></span>
</div>
{:else}
<div
class="w-24 h-24 rounded-full border-2 border-dashed border-primary/30 group-hover:border-primary/60 bg-primary/5 group-hover:bg-primary/10 transition-all flex flex-col items-center justify-center gap-1"
>
<span
class="icon-[ri--camera-line] w-7 h-7 text-primary/50 group-hover:text-primary/80 transition-colors"
></span>
<span class="text-xs text-muted-foreground">Upload</span>
</div>
{/if}
</div>
</FileDropZone>
<div class="flex flex-col gap-1">
<p class="text-sm text-muted-foreground">JPG, PNG · max 2 MB</p>
<p class="text-xs text-muted-foreground/70">
Click or drop to {avatar ? "change" : "upload"}
</p>
{#if avatar}
<Button
variant="ghost"
size="sm"
onclick={handleAvatarRemove}
class="cursor-pointer w-fit mt-1 px-2 h-7 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<span class="icon-[ri--delete-bin-line] w-3.5 h-3.5 mr-1"></span>
Remove
</Button>
{/if}
</div>
</div>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="firstName">{$_("me.settings.first_name")}</Label>
<Input
id="firstName"
placeholder={$_("me.settings.first_name_placeholder")}
bind:value={firstName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-2">
<Label for="lastName">{$_("me.settings.last_name")}</Label>
<Input
id="lastName"
placeholder={$_("me.settings.last_name_placeholder")}
bind:value={lastName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
<div class="space-y-2">
<Label for="artistName">{$_("me.settings.artist_name")}</Label>
<Input
id="artistName"
placeholder={$_("me.settings.artist_name_placeholder")}
bind:value={artistName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-2">
<Label for="description">{$_("me.settings.description")}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={$_("me.settings.description_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
rows={3}
/>
</div>
<div class="space-y-2">
<Label for="tags">{$_("me.settings.tags")}</Label>
<TagsInput
id="tags"
bind:value={tags}
placeholder={$_("me.settings.tags_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
{#if isProfileError}
<div class="grid w-full items-start gap-4">
<Alert.Root variant="destructive">
<Alert.Title class="items-center flex"
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
"me.settings.error",
)}</Alert.Title
>
<Alert.Description>{profileError}</Alert.Description>
</Alert.Root>
</div>
{/if}
<Button
type="submit"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
disabled={isProfileLoading}
>
{#if isProfileLoading}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("me.settings.updating_profile")}
{:else}
{$_("me.settings.update_profile")}
{/if}
</Button>
</form>
</CardContent>
</Card>
<!-- Privacy Settings -->
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle>{$_("me.settings.privacy_title")}</CardTitle>
<CardDescription>{$_("me.settings.privacy_subtitle")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<form onsubmit={handleSecuritySubmit} class="space-y-4">
<!-- Email -->
<div class="space-y-2">
<Label for="email">{$_("me.settings.email")}</Label>
<Input
id="email"
type="email"
placeholder={$_("me.settings.email_placeholder")}
bind:value={email}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<!-- Password -->
<div class="space-y-2">
<Label for="password">{$_("me.settings.password")}</Label>
<div class="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder={$_("me.settings.password_placeholder")}
bind:value={password}
required
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{#if showPassword}
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
{:else}
<span class="icon-[ri--eye-line] w-4 h-4"></span>
{/if}
</button>
</div>
</div>
<!-- Confirm Password -->
<div class="space-y-2">
<Label for="confirmPassword">{$_("me.settings.confirm_password")}</Label>
<div class="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder={$_("me.settings.confirm_password_placeholder")}
bind:value={confirmPassword}
required
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
/>
<button
type="button"
onclick={() => (showConfirmPassword = !showConfirmPassword)}
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{#if showConfirmPassword}
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
{:else}
<span class="icon-[ri--eye-line] w-4 h-4"></span>
{/if}
</button>
</div>
</div>
{#if isSecurityError}
<div class="grid w-full items-start gap-4">
<Alert.Root variant="destructive">
<Alert.Title class="items-center flex"
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
"me.settings.error",
)}</Alert.Title
>
<Alert.Description>{securityError}</Alert.Description>
</Alert.Root>
</div>
{/if}
<Button
variant="outline"
type="submit"
class="cursor-pointer w-full border-primary/20 hover:bg-primary/10"
disabled={isSecurityLoading}
>
{#if isSecurityLoading}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("me.settings.updating_security")}
{:else}
{$_("me.settings.update_security")}
{/if}
</Button>
</form>
</CardContent>
</Card>
</div>
</TabsContent>
<!-- Recordings Tab -->
<TabsContent value="recordings" class="space-y-6">
<div class="mb-6 flex justify-between items-center">
<div>
<h2 class="text-2xl font-bold text-card-foreground">
{$_("me.recordings.title")}
</h2>
<p class="text-muted-foreground">
{$_("me.recordings.description")}
</p>
</div>
<Button
href="/play"
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
{$_("me.recordings.go_to_play")}
</Button>
</div>
{#if recordings.length === 0}
<Card class="bg-card/50 border-primary/20">
<CardContent class="py-12">
<div class="flex flex-col items-center justify-center text-center">
<div class="mb-4 p-4 rounded-full bg-muted/30 border border-border/30">
<span class="icon-[ri--play-list-2-line] w-12 h-12 text-muted-foreground"></span>
</div>
<h3 class="text-xl font-semibold mb-2">
{$_("me.recordings.no_recordings")}
</h3>
<p class="text-muted-foreground mb-6 max-w-md">
{$_("me.recordings.no_recordings_description")}
</p>
<Button
href="/play"
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
{$_("me.recordings.go_to_play")}
</Button>
</div>
</CardContent>
</Card>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each recordings as recording (recording.id)}
<RecordingCard
{recording}
onPlay={handlePlayRecording}
onDelete={handleDeleteRecording}
/>
{/each}
</div>
{/if}
</TabsContent>
<!-- Analytics Tab -->
{#if data.analytics}
<TabsContent value="analytics" class="space-y-6">
<div class="mb-6">
<h2 class="text-2xl font-bold text-card-foreground">Analytics Dashboard</h2>
<p class="text-muted-foreground">
Track your content performance and audience engagement
</p>
</div>
<!-- Overview Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--video-line] w-5 h-5 text-primary"></span>
Total Videos
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{data.analytics.total_videos}</p>
</CardContent>
</Card>
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--heart-fill] w-5 h-5 text-primary"></span>
Total Likes
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{data.analytics.total_likes.toLocaleString()}</p>
</CardContent>
</Card>
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--play-fill] w-5 h-5 text-primary"></span>
Total Plays
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{data.analytics.total_plays.toLocaleString()}</p>
</CardContent>
</Card>
</div>
<!-- Video Performance Table -->
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle>Video Performance</CardTitle>
<CardDescription>Detailed metrics for each video</CardDescription>
</CardHeader>
<CardContent>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-border">
<th class="text-left p-3">Title</th>
<th class="text-right p-3">Likes</th>
<th class="text-right p-3">Plays</th>
<th class="text-right p-3">Completion Rate</th>
<th class="text-right p-3">Avg Watch Time</th>
</tr>
</thead>
<tbody>
{#each data.analytics.videos as video (video.slug)}
<tr class="border-b border-border/50 hover:bg-primary/5 transition-colors">
<td class="p-3">
<a
href="/videos/{video.slug}"
class="hover:text-primary transition-colors"
>
{video.title}
</a>
</td>
<td class="text-right p-3 font-medium">
{video.likes}
</td>
<td class="text-right p-3 font-medium">
{video.plays}
</td>
<td class="text-right p-3">
<span
class="inline-flex items-center px-2 py-1 rounded-full text-xs {video.completion_rate >=
70
? 'bg-green-500/20 text-green-500'
: video.completion_rate >= 40
? 'bg-yellow-500/20 text-yellow-500'
: 'bg-red-500/20 text-red-500'}"
>
{video.completion_rate.toFixed(1)}%
</span>
</td>
<td class="text-right p-3 text-muted-foreground">
{Math.floor(video.avg_watch_time / 60)}:{(video.avg_watch_time % 60)
.toString()
.padStart(2, "0")}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
{/if}
</Tabs>
</div>
</div>
<Dialog.Root bind:open={deleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$_("me.recordings.delete_confirm")}</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
<Button variant="destructive" disabled={deleting} onclick={confirmDeleteRecording}>
{deleting ? "Deleting…" : "Delete"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,13 @@
import { redirect } from "@sveltejs/kit";
import { isModel } from "$lib/api";
import { getAnalytics } from "$lib/services";
export async function load({ locals, fetch, cookies }) {
if (!isModel(locals.authStatus.user!)) {
throw redirect(302, "/me/profile");
}
const token = cookies.get("session_token") || "";
return {
analytics: await getAnalytics(fetch, token).catch(() => null),
};
}

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
</script>
<Meta title={$_("me.analytics.title")} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">{$_("me.analytics.title")}</h1>
<p class="text-sm text-muted-foreground mt-0.5">{$_("me.analytics.description")}</p>
</div>
</div>
<div class="space-y-6">
{#if data.analytics}
<!-- Overview Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--video-line] w-5 h-5 text-primary"></span>
{$_("me.analytics.total_videos")}
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{data.analytics.total_videos}</p>
</CardContent>
</Card>
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--heart-fill] w-5 h-5 text-primary"></span>
{$_("me.analytics.total_likes")}
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{data.analytics.total_likes.toLocaleString()}</p>
</CardContent>
</Card>
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--play-fill] w-5 h-5 text-primary"></span>
{$_("me.analytics.total_plays")}
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{data.analytics.total_plays.toLocaleString()}</p>
</CardContent>
</Card>
</div>
<!-- Video Performance Table -->
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle>{$_("me.analytics.video_performance")}</CardTitle>
<CardDescription>{$_("me.analytics.video_performance_description")}</CardDescription>
</CardHeader>
<CardContent>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-border">
<th class="text-left p-3">Title</th>
<th class="text-right p-3">Likes</th>
<th class="text-right p-3">Plays</th>
<th class="text-right p-3">Completion Rate</th>
<th class="text-right p-3">Avg Watch Time</th>
</tr>
</thead>
<tbody>
{#each data.analytics.videos as video (video.slug)}
<tr class="border-b border-border/50 hover:bg-primary/5 transition-colors">
<td class="p-3">
<a href="/videos/{video.slug}" class="hover:text-primary transition-colors">
{video.title}
</a>
</td>
<td class="text-right p-3 font-medium">
{video.likes}
</td>
<td class="text-right p-3 font-medium">
{video.plays}
</td>
<td class="text-right p-3">
<span
class="inline-flex items-center px-2 py-1 rounded-full text-xs {video.completion_rate >=
70
? 'bg-green-500/20 text-green-500'
: video.completion_rate >= 40
? 'bg-yellow-500/20 text-yellow-500'
: 'bg-red-500/20 text-red-500'}"
>
{video.completion_rate.toFixed(1)}%
</span>
</td>
<td class="text-right p-3 text-muted-foreground">
{Math.floor(video.avg_watch_time / 60)}:{(video.avg_watch_time % 60)
.toString()
.padStart(2, "0")}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</CardContent>
</Card>
{:else}
<Card class="bg-card/50 border-primary/20">
<CardContent class="py-12">
<div class="flex flex-col items-center justify-center text-center">
<div class="mb-4 p-4 rounded-full bg-muted/30 border border-border/30">
<span class="icon-[ri--line-chart-line] w-12 h-12 text-muted-foreground"></span>
</div>
<h3 class="text-xl font-semibold mb-2">No analytics available</h3>
<p class="text-muted-foreground max-w-md">
Analytics data will appear here once your content starts getting views.
</p>
</div>
</CardContent>
</Card>
{/if}
</div>
</div>

View File

@@ -0,0 +1,275 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { invalidateAll } from "$app/navigation";
import { untrack } from "svelte";
import { getAssetUrl } from "$lib/api";
import { toast } from "svelte-sonner";
import { updateProfile, uploadFile, removeFile } from "$lib/services";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Textarea } from "$lib/components/ui/textarea";
import { TagsInput } from "$lib/components/ui/tags-input";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import * as Alert from "$lib/components/ui/alert";
import { Card, CardContent } from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
let firstName = $state(untrack(() => data.authStatus.user!.first_name));
let lastName = $state(untrack(() => data.authStatus.user!.last_name));
let artistName = $state(untrack(() => data.authStatus.user!.artist_name));
let description = $state(untrack(() => data.authStatus.user!.description));
let tags = $state(untrack(() => data.authStatus.user!.tags ?? undefined));
$effect(() => {
firstName = data.authStatus.user!.first_name;
lastName = data.authStatus.user!.last_name;
artistName = data.authStatus.user!.artist_name;
description = data.authStatus.user!.description;
tags = data.authStatus.user!.tags ?? undefined;
});
let isProfileLoading = $state(false);
let isProfileError = $state(false);
let profileError = $state("");
let avatar = $state<{
id?: string;
url: string;
name: string;
size: number;
file?: File;
}>();
function setExistingAvatar() {
if (data.authStatus.user!.avatar) {
avatar = {
id: data.authStatus.user!.avatar,
url: getAssetUrl(data.authStatus.user!.avatar, "thumbnail")!,
name: data.authStatus.user!.artist_name ?? "",
size: 0,
};
} else {
avatar = undefined;
}
}
$effect(() => {
setExistingAvatar();
});
async function handleFilesUpload(files: File[]) {
const file = files[0];
avatar = {
name: file.name,
size: file.size,
url: URL.createObjectURL(file),
file,
};
}
async function handleAvatarRemove() {
if (avatar!.id) {
avatar = undefined;
} else {
setExistingAvatar();
}
}
async function handleProfileSubmit(e: Event) {
e.preventDefault();
try {
isProfileLoading = true;
isProfileError = false;
profileError = "";
let avatarId: string | null | undefined = undefined;
if (!avatar?.id && data.authStatus.user!.avatar) {
await removeFile(data.authStatus.user!.avatar);
avatarId = null;
} else if (avatar?.id) {
avatarId = avatar.id;
}
if (avatar?.file) {
const formData = new FormData();
formData.append("file", avatar.file);
const result = await uploadFile(formData);
avatarId = result.id;
}
await updateProfile({
first_name: firstName,
last_name: lastName,
artist_name: artistName,
description,
tags,
avatar: avatarId ?? undefined,
});
toast.success($_("me.settings.toast_update"));
invalidateAll();
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
profileError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
isProfileError = true;
} finally {
isProfileLoading = false;
}
}
</script>
<Meta title={$_("me.settings.profile_title")} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("me.settings.profile_title")}</h1>
<p class="text-sm text-muted-foreground mt-1">{$_("me.settings.profile_subtitle")}</p>
</div>
<Card class="bg-card/50 border-primary/20 max-w-2xl">
<CardContent class="space-y-4 pt-6">
<form onsubmit={handleProfileSubmit} class="space-y-4">
<div class="space-y-2">
<Label>{$_("me.settings.avatar")}</Label>
<div class="flex items-center gap-5">
<FileDropZone
id="avatar"
fileCount={0}
maxFiles={1}
maxFileSize={2 * MEGABYTE}
onUpload={handleFilesUpload}
accept="image/*"
class="h-auto w-auto shrink-0 border-none p-0 rounded-full hover:bg-transparent"
>
<div class="relative group cursor-pointer w-24 h-24">
{#if avatar}
<img
src={avatar.url}
alt={avatar.name}
class="w-24 h-24 rounded-full object-cover ring-4 ring-primary/20 group-hover:ring-primary/50 transition-all"
/>
<div
class="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<span class="icon-[ri--camera-line] w-7 h-7 text-white"></span>
</div>
{:else}
<div
class="w-24 h-24 rounded-full border-2 border-dashed border-primary/30 group-hover:border-primary/60 bg-primary/5 group-hover:bg-primary/10 transition-all flex flex-col items-center justify-center gap-1"
>
<span
class="icon-[ri--camera-line] w-7 h-7 text-primary/50 group-hover:text-primary/80 transition-colors"
></span>
<span class="text-xs text-muted-foreground">Upload</span>
</div>
{/if}
</div>
</FileDropZone>
<div class="flex flex-col gap-1">
<p class="text-sm text-muted-foreground">JPG, PNG · max 2 MB</p>
<p class="text-xs text-muted-foreground/70">
Click or drop to {avatar ? "change" : "upload"}
</p>
{#if avatar}
<Button
variant="ghost"
size="sm"
onclick={handleAvatarRemove}
class="cursor-pointer w-fit mt-1 px-2 h-7 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<span class="icon-[ri--delete-bin-line] w-3.5 h-3.5 mr-1"></span>
Remove
</Button>
{/if}
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="firstName">{$_("me.settings.first_name")}</Label>
<Input
id="firstName"
placeholder={$_("me.settings.first_name_placeholder")}
bind:value={firstName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-2">
<Label for="lastName">{$_("me.settings.last_name")}</Label>
<Input
id="lastName"
placeholder={$_("me.settings.last_name_placeholder")}
bind:value={lastName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
<div class="space-y-2">
<Label for="artistName">{$_("me.settings.artist_name")}</Label>
<Input
id="artistName"
placeholder={$_("me.settings.artist_name_placeholder")}
bind:value={artistName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-2">
<Label for="description">{$_("me.settings.description")}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={$_("me.settings.description_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
rows={3}
/>
</div>
<div class="space-y-2">
<Label for="tags">{$_("me.settings.tags")}</Label>
<TagsInput
id="tags"
bind:value={tags}
placeholder={$_("me.settings.tags_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
{#if isProfileError}
<div class="grid w-full items-start gap-4">
<Alert.Root variant="destructive">
<Alert.Title class="items-center flex">
<span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>
{$_("me.settings.error")}
</Alert.Title>
<Alert.Description>{profileError}</Alert.Description>
</Alert.Root>
</div>
{/if}
<Button
type="submit"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
disabled={isProfileLoading}
>
{#if isProfileLoading}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("me.settings.updating_profile")}
{:else}
{$_("me.settings.update_profile")}
{/if}
</Button>
</form>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load() {
throw redirect(301, "/play/recordings");
}

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { untrack } from "svelte";
import { _ } from "svelte-i18n";
import { goto } from "$app/navigation";
import { toast } from "svelte-sonner";
import { deleteRecording, updateRecording } from "$lib/services";
import { Button } from "$lib/components/ui/button";
import * as Empty from "$lib/components/ui/empty";
import * as Dialog from "$lib/components/ui/dialog";
import RecordingCard from "$lib/components/recording-card/recording-card.svelte";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
let recordings = $state(untrack(() => data.recordings));
let deleteTarget = $state<string | null>(null);
let deleteOpen = $state(false);
let deleting = $state(false);
function handleDeleteRecording(id: string) {
deleteTarget = id;
deleteOpen = true;
}
async function confirmDeleteRecording() {
if (!deleteTarget) return;
deleting = true;
try {
await deleteRecording(deleteTarget);
recordings = recordings.filter((r) => r.id !== deleteTarget);
toast.success($_("me.recordings.delete_success"));
deleteOpen = false;
deleteTarget = null;
} catch {
toast.error($_("me.recordings.delete_error"));
} finally {
deleting = false;
}
}
async function handlePublishRecording(id: string) {
try {
await updateRecording(id, { status: "published" });
recordings = recordings.map((r) => (r.id === id ? { ...r, status: "published" } : r));
toast.success($_("me.recordings.publish_success"));
} catch {
toast.error($_("me.recordings.publish_error"));
}
}
async function handleUnpublishRecording(id: string) {
try {
await updateRecording(id, { status: "draft" });
recordings = recordings.map((r) => (r.id === id ? { ...r, status: "draft" } : r));
toast.success($_("me.recordings.unpublish_success"));
} catch {
toast.error($_("me.recordings.unpublish_error"));
}
}
function handlePlayRecording(id: string) {
goto(`/play?recording=${id}`);
}
</script>
<Meta title={$_("me.recordings.title")} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("me.recordings.title")}</h1>
</div>
{#if recordings.length === 0}
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon">
<span class="icon-[ri--play-list-2-line] w-8 h-8"></span>
</Empty.Media>
<Empty.Title>{$_("me.recordings.no_recordings")}</Empty.Title>
<Empty.Description>{$_("me.recordings.no_recordings_description")}</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button
href="/play"
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
{$_("me.recordings.go_to_play")}
</Button>
</Empty.Content>
</Empty.Root>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each recordings as recording (recording.id)}
<RecordingCard
{recording}
onPlay={handlePlayRecording}
onPublish={handlePublishRecording}
onUnpublish={handleUnpublishRecording}
onDelete={handleDeleteRecording}
/>
{/each}
</div>
{/if}
</div>
<Dialog.Root bind:open={deleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$_("me.recordings.delete_confirm")}</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (deleteOpen = false)}>
{$_("common.cancel")}
</Button>
<Button variant="destructive" disabled={deleting} onclick={confirmDeleteRecording}>
{deleting ? "Deleting…" : $_("common.delete")}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,154 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { invalidateAll } from "$app/navigation";
import { untrack } from "svelte";
import { toast } from "svelte-sonner";
import { updateProfile } from "$lib/services";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Alert from "$lib/components/ui/alert";
import { Card, CardContent } from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
let email = $state(untrack(() => data.authStatus.user!.email));
let password = $state("");
let confirmPassword = $state("");
let showPassword = $state(false);
let showConfirmPassword = $state(false);
let isSecurityLoading = $state(false);
let isSecurityError = $state(false);
let securityError = $state("");
async function handleSecuritySubmit(e: Event) {
e.preventDefault();
try {
if (password !== confirmPassword) {
throw new Error($_("me.settings.password_error"));
}
isSecurityLoading = true;
isSecurityError = false;
securityError = "";
await updateProfile({
email,
password,
});
toast.success($_("me.settings.toast_update"));
invalidateAll();
password = confirmPassword = "";
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
securityError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
isSecurityError = true;
} finally {
isSecurityLoading = false;
}
}
</script>
<Meta title={$_("me.settings.privacy_title")} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("me.settings.privacy_title")}</h1>
<p class="text-sm text-muted-foreground mt-1">{$_("me.settings.privacy_subtitle")}</p>
</div>
<Card class="bg-card/50 border-primary/20 max-w-2xl">
<CardContent class="space-y-4 pt-6">
<form onsubmit={handleSecuritySubmit} class="space-y-4">
<div class="space-y-2">
<Label for="email">{$_("me.settings.email")}</Label>
<Input
id="email"
type="email"
placeholder={$_("me.settings.email_placeholder")}
bind:value={email}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-2">
<Label for="password">{$_("me.settings.password")}</Label>
<div class="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder={$_("me.settings.password_placeholder")}
bind:value={password}
required
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{#if showPassword}
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
{:else}
<span class="icon-[ri--eye-line] w-4 h-4"></span>
{/if}
</button>
</div>
</div>
<div class="space-y-2">
<Label for="confirmPassword">{$_("me.settings.confirm_password")}</Label>
<div class="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder={$_("me.settings.confirm_password_placeholder")}
bind:value={confirmPassword}
required
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
/>
<button
type="button"
onclick={() => (showConfirmPassword = !showConfirmPassword)}
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{#if showConfirmPassword}
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
{:else}
<span class="icon-[ri--eye-line] w-4 h-4"></span>
{/if}
</button>
</div>
</div>
{#if isSecurityError}
<div class="grid w-full items-start gap-4">
<Alert.Root variant="destructive">
<Alert.Title class="items-center flex">
<span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>
{$_("me.settings.error")}
</Alert.Title>
<Alert.Description>{securityError}</Alert.Description>
</Alert.Root>
</div>
{/if}
<Button
type="submit"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
disabled={isSecurityLoading}
>
{#if isSecurityLoading}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("me.settings.updating_security")}
{:else}
{$_("me.settings.update_security")}
{/if}
</Button>
</form>
</CardContent>
</Card>
</div>

View File

@@ -11,6 +11,7 @@
import Meta from "$lib/components/meta/meta.svelte"; import Meta from "$lib/components/meta/meta.svelte";
import SexyBackground from "$lib/components/background/background.svelte"; import SexyBackground from "$lib/components/background/background.svelte";
import PageHero from "$lib/components/page-hero/page-hero.svelte"; import PageHero from "$lib/components/page-hero/page-hero.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props(); const { data } = $props();
@@ -42,23 +43,6 @@
else params.delete("page"); else params.delete("page");
goto(`?${params.toString()}`); goto(`?${params.toString()}`);
} }
const totalPages = $derived(Math.ceil(data.total / data.limit));
const pageNumbers = $derived(() => {
const pages: (number | -1)[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (data.page > 3) pages.push(-1);
for (let i = Math.max(2, data.page - 1); i <= Math.min(totalPages - 1, data.page + 1); i++)
pages.push(i);
if (data.page < totalPages - 2) pages.push(-1);
pages.push(totalPages);
}
return pages;
});
</script> </script>
<Meta title={$_("models.title")} description={$_("models.description")} /> <Meta title={$_("models.title")} description={$_("models.description")} />
@@ -196,38 +180,13 @@
{/if} {/if}
<!-- Pagination --> <!-- Pagination -->
{#if totalPages > 1} {#if Math.ceil(data.total / data.limit) > 1}
<div class="flex flex-col items-center gap-3 mt-10"> <div class="flex flex-col items-center gap-3 mt-10">
<div class="flex items-center gap-1"> <Pagination
<Button currentPage={data.page}
variant="outline" totalPages={Math.ceil(data.total / data.limit)}
size="sm" onPageChange={goToPage}
disabled={data.page <= 1} />
onclick={() => goToPage(data.page - 1)}
class="border-primary/20 hover:bg-primary/10">{$_("common.previous")}</Button
>
{#each pageNumbers() as p, i (i)}
{#if p === -1}
<span class="px-2 text-muted-foreground select-none"></span>
{:else}
<Button
variant={p === data.page ? "default" : "outline"}
size="sm"
onclick={() => goToPage(p)}
class={p === data.page
? "bg-gradient-to-r from-primary to-accent min-w-9"
: "border-primary/20 hover:bg-primary/10 min-w-9"}>{p}</Button
>
{/if}
{/each}
<Button
variant="outline"
size="sm"
disabled={data.page >= totalPages}
onclick={() => goToPage(data.page + 1)}
class="border-primary/20 hover:bg-primary/10">{$_("common.next")}</Button
>
</div>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
{$_("common.total_results", { values: { total: data.total } })} {$_("common.total_results", { values: { total: data.total } })}
</p> </p>

View File

@@ -0,0 +1,8 @@
import { redirect } from "@sveltejs/kit";
export async function load({ locals }) {
if (!locals.authStatus.authenticated) {
throw redirect(302, "/login");
}
return { authStatus: locals.authStatus };
}

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { page } from "$app/state";
import { _ } from "svelte-i18n";
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import { getAssetUrl } from "$lib/api";
import SexyBackground from "$lib/components/background/background.svelte";
const { children, data } = $props();
const navLinks = $derived([
{
name: $_("play.nav.play"),
href: "/play/buttplug",
icon: "icon-[ri--rocket-line]",
exact: false,
},
{
name: $_("play.nav.recordings"),
href: "/play/recordings",
icon: "icon-[ri--play-list-2-line]",
exact: false,
},
{
name: $_("play.nav.leaderboard"),
href: "/play/leaderboard",
icon: "icon-[ri--trophy-line]",
exact: false,
},
]);
function isActive(link: { href: string; exact: boolean }) {
if (link.exact) return page.url.pathname === link.href;
return page.url.pathname.startsWith(link.href);
}
const user = $derived(data.authStatus.user!);
const avatarUrl = $derived(
user.avatar ? (getAssetUrl(user.avatar, "thumbnail") ?? undefined) : undefined,
);
const displayName = $derived(user.artist_name ?? user.email);
</script>
<div
class="min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 relative overflow-hidden"
>
<SexyBackground />
<div class="container mx-auto px-4 relative z-10">
<!-- Mobile top nav -->
<div class="lg:hidden border-b border-border/40">
<div class="flex items-center gap-1 overflow-x-auto py-2 scrollbar-none">
<a
href="/"
class="shrink-0 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
>
<span class="icon-[ri--arrow-left-line] h-4 w-4"></span>
<span class="hidden sm:inline">{$_("play.nav.back_mobile")}</span>
</a>
{#each navLinks as link (link.href)}
<a
href={link.href}
class={`shrink-0 flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
isActive(link)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span class={`${link.icon} h-4 w-4 shrink-0`}></span>
<span class="hidden sm:inline">{link.name}</span>
</a>
{/each}
</div>
</div>
<!-- Desktop layout -->
<div class="flex min-h-screen">
<!-- Sidebar (desktop only) -->
<aside class="hidden lg:flex w-56 shrink-0 flex-col border-r border-border/40">
<div class="px-4 py-5 border-b border-border/40">
<a
href="/"
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span class="icon-[ri--arrow-left-line] h-3.5 w-3.5"></span>
{$_("play.nav.back_to_site")}
</a>
<div class="mt-3 flex items-center gap-3">
<Avatar class="h-9 w-9 shrink-0">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback class="text-xs">
{getUserInitials(displayName)}
</AvatarFallback>
</Avatar>
<div class="min-w-0">
<p class="text-sm font-semibold text-foreground truncate">{displayName}</p>
<p class="text-xs text-primary">{$_("play.title")}</p>
</div>
</div>
</div>
<nav class="flex-1 p-3 space-y-1">
{#each navLinks as link (link.href)}
<a
href={link.href}
class={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive(link)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span class={`${link.icon} h-4 w-4`}></span>
{link.name}
</a>
{/each}
</nav>
</aside>
<!-- Main content -->
<main class="flex-1 min-w-0">
{@render children()}
</main>
</div>
</div>
</div>

View File

@@ -1,20 +1,5 @@
import { getRecording } from "$lib/services"; import { redirect } from "@sveltejs/kit";
import type { Recording } from "$lib/types";
export async function load({ locals, url, fetch }) { export function load() {
const recordingId = url.searchParams.get("recording"); throw redirect(302, "/play/buttplug");
let recording: Recording | null = null;
if (recordingId && locals.authStatus.authenticated) {
try {
recording = await getRecording(recordingId, fetch);
} catch (error) {
console.error("Failed to load recording:", error);
}
}
return {
authStatus: locals.authStatus,
recording,
};
} }

View File

@@ -0,0 +1,14 @@
import { getRecording } from "$lib/services";
import type { Recording } from "$lib/types";
export async function load({ url, fetch, cookies }) {
const recordingId = url.searchParams.get("recording");
const token = cookies.get("session_token") || "";
let recording: Recording | null = null;
if (recordingId) {
recording = await getRecording(recordingId, fetch, token).catch(() => null);
}
return { recording };
}

View File

@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import Meta from "$lib/components/meta/meta.svelte"; import Meta from "$lib/components/meta/meta.svelte";
import type * as ButtplugTypes from "@sexy.pivoine.art/buttplug"; import type * as ButtplugTypes from "@sexy/buttplug";
import Button from "$lib/components/ui/button/button.svelte"; import Button from "$lib/components/ui/button/button.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation";
import DeviceCard from "$lib/components/device-card/device-card.svelte"; import DeviceCard from "$lib/components/device-card/device-card.svelte";
import RecordingSaveDialog from "./components/recording-save-dialog.svelte"; import RecordingSaveDialog from "../components/recording-save-dialog.svelte";
import DeviceMappingDialog from "./components/device-mapping-dialog.svelte"; import DeviceMappingDialog from "../components/device-mapping-dialog.svelte";
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types"; import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import SexyBackground from "$lib/components/background/background.svelte"; import { createRecording } from "$lib/services";
import * as Empty from "$lib/components/ui/empty";
// Runtime buttplug values — loaded dynamically from the buttplug nginx container // Runtime buttplug values — loaded dynamically from the buttplug nginx container
let client: ButtplugTypes.ButtplugClient; let client: ButtplugTypes.ButtplugClient;
@@ -40,7 +40,6 @@
async function init() { async function init() {
const connector = new ButtplugWasmClientConnector(); const connector = new ButtplugWasmClientConnector();
// await ButtplugWasmClientConnector.activateLogging("info");
await client.connect(connector); await client.connect(connector);
client.on("deviceadded", onDeviceAdded); client.on("deviceadded", onDeviceAdded);
client.on("deviceremoved", (dev: ButtplugTypes.ButtplugClientDevice) => { client.on("deviceremoved", (dev: ButtplugTypes.ButtplugClientDevice) => {
@@ -61,7 +60,6 @@
const device = convertDevice(dev); const device = convertDevice(dev);
devices.push(device); devices.push(device);
// Try to read battery level — access through the reactive array so Svelte detects the mutation
const idx = devices.length - 1; const idx = devices.length - 1;
if (device.hasBattery) { if (device.hasBattery) {
try { try {
@@ -94,16 +92,13 @@
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType; const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value)); await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
// Capture event if recording
if (isRecording && recordingStartTime) { if (isRecording && recordingStartTime) {
captureEvent(device, actuatorIdx, value); captureEvent(device, actuatorIdx, value);
} }
} }
function startRecording() { function startRecording() {
if (devices.length === 0) { if (devices.length === 0) return;
return;
}
isRecording = true; isRecording = true;
recordingStartTime = performance.now(); recordingStartTime = performance.now();
recordedEvents = []; recordedEvents = [];
@@ -130,7 +125,7 @@
device_name: device.name, device_name: device.name,
actuator_index: actuatorIdx, actuator_index: actuatorIdx,
actuator_type: actuator.outputType, actuator_type: actuator.outputType,
value: (value / actuator.maxSteps) * 100, // Normalize to 0-100 value: (value / actuator.maxSteps) * 100,
}); });
} }
@@ -165,7 +160,11 @@
}; };
} }
async function handleSaveRecording(data: { title: string; description: string; tags: string[] }) { async function handleSaveRecording(saveData: {
title: string;
description: string;
tags: string[];
}) {
const deviceInfo: DeviceInfo[] = devices.map((d) => ({ const deviceInfo: DeviceInfo[] = devices.map((d) => ({
name: d.name, name: d.name,
index: d.info.index, index: d.info.index,
@@ -173,33 +172,20 @@
})); }));
try { try {
const response = await fetch("/api/sexy/recordings", { await createRecording({
method: "POST", title: saveData.title,
headers: { description: saveData.description,
"Content-Type": "application/json", duration: Math.round(recordingDuration),
},
body: JSON.stringify({
title: data.title,
description: data.description,
duration: recordingDuration,
events: recordedEvents, events: recordedEvents,
device_info: deviceInfo, device_info: deviceInfo,
tags: data.tags, tags: saveData.tags,
status: "draft", status: "draft",
}),
}); });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
toast.success("Recording saved successfully!"); toast.success("Recording saved successfully!");
showSaveDialog = false; showSaveDialog = false;
recordedEvents = []; recordedEvents = [];
recordingDuration = 0; recordingDuration = 0;
// Optionally navigate to dashboard
// goto("/me?tab=recordings");
} catch (error) { } catch (error) {
console.error("Failed to save recording:", error); console.error("Failed to save recording:", error);
toast.error("Failed to save recording. Please try again."); toast.error("Failed to save recording. Please try again.");
@@ -212,24 +198,19 @@
recordingDuration = 0; recordingDuration = 0;
} }
// Playback functions
function startPlayback() { function startPlayback() {
if (!data.recording) { if (!data.recording) return;
return;
}
if (devices.length === 0) { if (devices.length === 0) {
toast.error("Please connect devices before playing recording"); toast.error("Please connect devices before playing recording");
return; return;
} }
// Check if we need to map devices
if (deviceMappings.size === 0 && (data.recording.device_info?.length ?? 0) > 0) { if (deviceMappings.size === 0 && (data.recording.device_info?.length ?? 0) > 0) {
showMappingDialog = true; showMappingDialog = true;
return; return;
} }
// Start playback with existing mappings
beginPlayback(); beginPlayback();
} }
@@ -259,8 +240,6 @@
} }
playbackProgress = 0; playbackProgress = 0;
currentEventIndex = 0; currentEventIndex = 0;
// Stop all devices
devices.forEach((device) => handleStop(device)); devices.forEach((device) => handleStop(device));
} }
@@ -274,7 +253,6 @@
function resumePlayback() { function resumePlayback() {
if (!data.recording) return; if (!data.recording) return;
isPlaying = true; isPlaying = true;
playbackStartTime = performance.now() - playbackProgress; playbackStartTime = performance.now() - playbackProgress;
scheduleNextEvent(); scheduleNextEvent();
@@ -295,12 +273,10 @@
const delay = event.timestamp - currentTime; const delay = event.timestamp - currentTime;
if (delay <= 0) { if (delay <= 0) {
// Execute event immediately
executeEvent(event); executeEvent(event);
currentEventIndex++; currentEventIndex++;
scheduleNextEvent(); scheduleNextEvent();
} else { } else {
// Schedule event
playbackTimeoutId = setTimeout(() => { playbackTimeoutId = setTimeout(() => {
executeEvent(event); executeEvent(event);
currentEventIndex++; currentEventIndex++;
@@ -311,31 +287,25 @@
} }
function executeEvent(event: RecordedEvent) { function executeEvent(event: RecordedEvent) {
// Get mapped device
const device = deviceMappings.get(event.device_name); const device = deviceMappings.get(event.device_name);
if (!device) { if (!device) {
console.warn(`No device mapping for: ${event.device_name}`); console.warn(`No device mapping for: ${event.device_name}`);
return; return;
} }
// Find matching actuator by type
const actuator = device.actuators.find((a) => a.outputType === event.actuator_type); const actuator = device.actuators.find((a) => a.outputType === event.actuator_type);
if (!actuator) { if (!actuator) {
console.warn(`Actuator type ${event.actuator_type} not found on ${device.name}`); console.warn(`Actuator type ${event.actuator_type} not found on ${device.name}`);
return; return;
} }
// Convert normalized value (0-100) back to device scale
const deviceValue = Math.round((event.value / 100) * actuator.maxSteps); const deviceValue = Math.round((event.value / 100) * actuator.maxSteps);
// Send command to device via feature
const feature = device.info.features.get(actuator.featureIndex); const feature = device.info.features.get(actuator.featureIndex);
if (feature) { if (feature) {
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType; const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue)); feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
} }
// Update UI
actuator.value = deviceValue; actuator.value = deviceValue;
} }
@@ -345,7 +315,6 @@
const targetTime = (percentage / 100) * data.recording.duration; const targetTime = (percentage / 100) * data.recording.duration;
playbackProgress = targetTime; playbackProgress = targetTime;
// Find the event index at this time
const seekEvents = (data.recording.events ?? []) as RecordedEvent[]; const seekEvents = (data.recording.events ?? []) as RecordedEvent[];
currentEventIndex = seekEvents.findIndex((e) => e.timestamp >= targetTime); currentEventIndex = seekEvents.findIndex((e) => e.timestamp >= targetTime);
if (currentEventIndex === -1) { if (currentEventIndex === -1) {
@@ -364,10 +333,6 @@
const { data } = $props(); const { data } = $props();
onMount(async () => { onMount(async () => {
if (!data.authStatus.authenticated) {
goto("/login");
return;
}
// Concatenation prevents Rollup from statically resolving this URL at build time // Concatenation prevents Rollup from statically resolving this URL at build time
const buttplugUrl = "/buttplug/" + "dist/index.js"; const buttplugUrl = "/buttplug/" + "dist/index.js";
const bp = await import(/* @vite-ignore */ buttplugUrl); const bp = await import(/* @vite-ignore */ buttplugUrl);
@@ -382,90 +347,42 @@
<Meta title={$_("play.title")} description={$_("play.description")} /> <Meta title={$_("play.title")} description={$_("play.description")} />
<div <div class="py-3 sm:py-6 lg:pl-6">
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<SexyBackground />
<div class="container mx-auto py-20 relative px-4">
<div class="max-w-4xl mx-auto">
<!-- Header --> <!-- Header -->
<div class="text-center mb-12"> <div class="mb-6">
<h1 <h1 class="text-2xl font-bold">{$_("play.title")}</h1>
class="text-4xl md:text-5xl font-bold mb-4 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent" <p class="text-sm text-muted-foreground mt-1">{$_("play.description")}</p>
>
{$_("play.title")}
</h1>
<p class="text-lg text-muted-foreground mb-6">
{$_("play.description")}
</p>
<div class="flex justify-center gap-3 mb-10">
<Button
variant="outline"
size="sm"
href="/leaderboard"
class="border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--trophy-line] w-4 h-4 mr-2"></span>
{$_("gamification.leaderboard")}
</Button>
<Button
variant="outline"
size="sm"
href="/me"
class="border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--user-line] w-4 h-4 mr-2"></span>
{$_("common.my_profile")}
</Button>
</div> </div>
<div class="flex justify-center gap-4 items-center">
<Button
size="lg"
disabled={!connected || scanning}
onclick={startScanning}
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{#if scanning}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("play.scanning")}
{:else}
{$_("play.scan")}
{/if}
</Button>
<!-- Recording controls (only when devices are connected) -->
{#if devices.length > 0 && !data.recording} {#if devices.length > 0 && !data.recording}
<div class="flex flex-wrap items-center gap-3 mb-6">
{#if !isRecording} {#if !isRecording}
<Button <Button
size="lg"
variant="outline" variant="outline"
onclick={startRecording} onclick={startRecording}
class="cursor-pointer border-primary/30 hover:bg-primary/10" class="cursor-pointer border-primary/30 hover:bg-primary/10"
> >
<span class="icon-[ri--record-circle-line] w-5 h-5 mr-2"></span> <span class="icon-[ri--record-circle-line] w-4 h-4 mr-2"></span>
Start Recording Start Recording
</Button> </Button>
{:else} {:else}
<Button <Button
size="lg"
onclick={stopRecording} onclick={stopRecording}
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white" class="cursor-pointer bg-red-500 hover:bg-red-600 text-white"
> >
<span class="icon-[ri--stop-circle-fill] w-5 h-5 mr-2 animate-pulse"></span> <span class="icon-[ri--stop-circle-fill] w-4 h-4 mr-2 animate-pulse"></span>
Stop Recording ({recordedEvents.length} events) Stop Recording ({recordedEvents.length} events)
</Button> </Button>
{/if} {/if}
</div>
{/if} {/if}
</div>
</div>
<!-- Playback Controls (only shown when recording is loaded) --> <!-- Playback Controls (only shown when recording is loaded) -->
{#if data.recording} {#if data.recording}
<div class="bg-card/50 border border-primary/20 rounded-lg p-6 backdrop-blur-sm"> <div class="bg-card/50 border border-primary/20 rounded-lg p-6 mb-6">
<div class="mb-4"> <div class="mb-4">
<h2 class="text-xl font-semibold text-card-foreground mb-2"> <h2 class="text-xl font-semibold text-card-foreground mb-1">
{data.recording.title} {data.recording.title}
</h2> </h2>
{#if data.recording.description} {#if data.recording.description}
@@ -479,9 +396,7 @@
<div class="mb-4"> <div class="mb-4">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
<span class="text-sm text-muted-foreground min-w-[50px]"> <span class="text-sm text-muted-foreground min-w-[50px]">
{Math.floor(playbackProgress / 1000 / 60)}:{( {Math.floor(playbackProgress / 1000 / 60)}:{(Math.floor(playbackProgress / 1000) % 60)
Math.floor(playbackProgress / 1000) % 60
)
.toString() .toString()
.padStart(2, "0")} .padStart(2, "0")}
</span> </span>
@@ -523,7 +438,6 @@
<!-- Playback Buttons --> <!-- Playback Buttons -->
<div class="flex gap-2 justify-center"> <div class="flex gap-2 justify-center">
<Button <Button
size="lg"
variant="outline" variant="outline"
onclick={stopPlayback} onclick={stopPlayback}
disabled={!isPlaying && playbackProgress === 0} disabled={!isPlaying && playbackProgress === 0}
@@ -533,7 +447,6 @@
</Button> </Button>
{#if !isPlaying} {#if !isPlaying}
<Button <Button
size="lg"
onclick={playbackProgress > 0 ? resumePlayback : startPlayback} onclick={playbackProgress > 0 ? resumePlayback : startPlayback}
disabled={devices.length === 0} disabled={devices.length === 0}
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]" class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
@@ -543,7 +456,6 @@
</Button> </Button>
{:else} {:else}
<Button <Button
size="lg"
onclick={pausePlayback} onclick={pausePlayback}
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]" class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
> >
@@ -570,11 +482,10 @@
</div> </div>
</div> </div>
{/if} {/if}
</div>
</div> <!-- Devices grid or empty state -->
<div class="container mx-auto px-4 py-12"> {#if devices.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{#if devices}
{#each devices as device (device.name)} {#each devices as device (device.name)}
<DeviceCard <DeviceCard
{device} {device}
@@ -582,15 +493,34 @@
onStop={() => handleStop(device)} onStop={() => handleStop(device)}
/> />
{/each} {/each}
</div>
{:else}
<Empty.Root>
<Empty.Header>
<Empty.Media>
<span class="icon-[ri--rocket-line] w-8 h-8"></span>
</Empty.Media>
<Empty.Title>{$_("play.no_results")}</Empty.Title>
<Empty.Description>{$_("play.no_results_description")}</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button
disabled={!connected || scanning}
onclick={startScanning}
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{#if scanning}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("play.scanning")}
{:else}
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
{$_("play.scan")}
{/if} {/if}
</div> </Button>
</Empty.Content>
{#if devices?.length === 0} </Empty.Root>
<div class="text-center py-12">
<p class="text-muted-foreground text-lg mb-4">
{$_("play.no_results")}
</p>
</div>
{/if} {/if}
</div> </div>
@@ -618,4 +548,3 @@
onCancel={handleMappingCancel} onCancel={handleMappingCancel}
/> />
{/if} {/if}
</div>

View File

@@ -0,0 +1,60 @@
import type { PageServerLoad } from "./$types";
import { gql } from "graphql-request";
import { getGraphQLClient } from "$lib/api";
const LEADERBOARD_QUERY = gql`
query Leaderboard($limit: Int, $offset: Int) {
leaderboard(limit: $limit, offset: $offset) {
user_id
display_name
avatar
total_weighted_points
total_raw_points
recordings_count
playbacks_count
achievements_count
rank
}
}
`;
export const load: PageServerLoad = async ({ fetch, url }) => {
try {
const limit = parseInt(url.searchParams.get("limit") || "100");
const offset = parseInt(url.searchParams.get("offset") || "0");
const client = getGraphQLClient(fetch);
const data = await client.request<{
leaderboard: {
user_id: string;
display_name: string | null;
avatar: string | null;
total_weighted_points: number | null;
total_raw_points: number | null;
recordings_count: number | null;
playbacks_count: number | null;
achievements_count: number | null;
rank: number;
}[];
}>(LEADERBOARD_QUERY, { limit, offset });
return {
leaderboard: data.leaderboard || [],
pagination: {
limit,
offset,
hasMore: data.leaderboard?.length === limit,
},
};
} catch (error) {
console.error("Leaderboard load error:", error);
return {
leaderboard: [],
pagination: {
limit: 100,
offset: 0,
hasMore: false,
},
};
}
};

View File

@@ -0,0 +1,197 @@
<script lang="ts">
import { _, locale } from "svelte-i18n";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
function formatPoints(points: number | null | undefined): string {
return Math.round(points ?? 0).toLocaleString($locale || "en");
}
function getMedalEmoji(rank: number): string {
switch (rank) {
case 1:
return "🥇";
case 2:
return "🥈";
case 3:
return "🥉";
default:
return "";
}
}
function getUserInitials(name: string | null | undefined): string {
if (!name) return "?";
const parts = name.split(" ");
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
</script>
<Meta
title={$_("gamification.leaderboard")}
description={$_("gamification.leaderboard_description")}
/>
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">{$_("gamification.leaderboard")}</h1>
<p class="text-sm text-muted-foreground mt-0.5">{$_("gamification.leaderboard_subtitle")}</p>
</div>
</div>
<Card class="bg-card/50 border-border/50">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--trophy-line] w-5 h-5 text-primary"></span>
{$_("gamification.top_players")}
</CardTitle>
</CardHeader>
<CardContent>
{#if data.leaderboard.length === 0}
<div class="text-center py-12 text-muted-foreground">
<span class="icon-[ri--trophy-line] w-12 h-12 mx-auto mb-4 opacity-50 block"></span>
<p>{$_("gamification.no_rankings_yet")}</p>
</div>
{:else}
<div class="space-y-2">
{#each data.leaderboard as entry (entry.user_id)}
<a
href="/users/{entry.user_id}"
class="flex items-center gap-2 sm:gap-4 px-2 py-2 sm:p-4 rounded-lg hover:bg-accent/10 transition-colors group"
>
<!-- Rank Badge -->
<div class="flex-shrink-0 w-8 sm:w-14 text-center">
{#if entry.rank <= 3}
<span class="text-2xl sm:text-3xl">{getMedalEmoji(entry.rank)}</span>
{:else}
<span
class="text-base sm:text-xl font-bold text-muted-foreground group-hover:text-foreground transition-colors"
>
#{entry.rank}
</span>
{/if}
</div>
<!-- Avatar -->
<Avatar
class="h-9 w-9 sm:h-12 sm:w-12 shrink-0 ring-2 ring-accent/20 group-hover:ring-primary/40 transition-all"
>
{#if entry.avatar}
<AvatarImage src={getAssetUrl(entry.avatar, "mini")} alt={entry.display_name} />
{/if}
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground font-semibold"
>
{getUserInitials(entry.display_name)}
</AvatarFallback>
</Avatar>
<!-- User Info -->
<div class="flex-1 min-w-0">
<div class="font-semibold truncate group-hover:text-primary transition-colors">
{entry.display_name || $_("common.anonymous")}
</div>
<div
class="text-xs sm:text-sm text-muted-foreground flex items-center gap-2 sm:gap-3"
>
<span title={$_("gamification.recordings")}>
<span class="icon-[ri--video-line] w-3 h-3 sm:w-3.5 sm:h-3.5 inline mr-0.5"
></span>
{entry.recordings_count}
</span>
<span title={$_("gamification.plays")}>
<span class="icon-[ri--play-line] w-3 h-3 sm:w-3.5 sm:h-3.5 inline mr-0.5"
></span>
{entry.playbacks_count}
</span>
<span title={$_("gamification.achievements")}>
<span class="icon-[ri--trophy-line] w-3 h-3 sm:w-3.5 sm:h-3.5 inline mr-0.5"
></span>
{entry.achievements_count}
</span>
</div>
</div>
<!-- Score -->
<div class="text-right flex-shrink-0">
<div class="text-lg sm:text-2xl font-bold text-primary">
{formatPoints(entry.total_weighted_points)}
</div>
<div class="text-xs text-muted-foreground hidden sm:block">
{$_("gamification.points")}
</div>
</div>
<!-- Arrow indicator -->
<div
class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity hidden sm:block"
>
<span class="icon-[ri--arrow-right-s-line] w-5 h-5 text-muted-foreground"></span>
</div>
</a>
{/each}
</div>
{#if data.pagination.hasMore}
<div class="mt-6 text-center">
<Button
variant="outline"
href="/play/leaderboard?offset={data.pagination.offset +
data.pagination.limit}&limit={data.pagination.limit}"
>
{$_("common.load_more")}
</Button>
</div>
{/if}
{/if}
</CardContent>
</Card>
<!-- Info Card -->
<Card class="mt-6 bg-card/50 border-border/50">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--information-line] w-5 h-5 text-primary"></span>
{$_("gamification.how_it_works")}
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground mb-4">
{$_("gamification.how_it_works_description")}
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div class="flex items-start gap-2">
<span class="icon-[ri--video-add-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"></span>
<div>
<div class="font-medium">{$_("gamification.earn_by_creating")}</div>
<div class="text-muted-foreground">{$_("gamification.earn_by_creating_desc")}</div>
</div>
</div>
<div class="flex items-start gap-2">
<span class="icon-[ri--play-circle-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"
></span>
<div>
<div class="font-medium">{$_("gamification.earn_by_playing")}</div>
<div class="text-muted-foreground">{$_("gamification.earn_by_playing_desc")}</div>
</div>
</div>
<div class="flex items-start gap-2">
<span class="icon-[ri--time-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"></span>
<div>
<div class="font-medium">{$_("gamification.stay_active")}</div>
<div class="text-muted-foreground">{$_("gamification.stay_active_desc")}</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>

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