Compare commits

...

53 Commits

Author SHA1 Message Date
79932157bf fix: revoke points when a comment is deleted
All checks were successful
Build and Push Backend Image / build (push) Successful in 43s
- revokePoints now accepts optional recordingId; when absent it deletes
  one matching row (for actions like COMMENT_CREATE that have no recording)
- deleteComment queues revokePoints + checkAchievements so leaderboard
  and social achievements stay in sync

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 13:49:38 +01:00
86 changed files with 4454 additions and 2344 deletions

View File

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

View File

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

View File

@@ -7,9 +7,17 @@ on:
- develop - 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:

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

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
@@ -34,7 +34,7 @@ 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 \

65
Dockerfile.buttplug Normal file
View File

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

View File

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

23
nginx.buttplug.conf Normal file
View File

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

View File

@@ -5,11 +5,12 @@
"type": "module", "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.pivoine.art/frontend build",
"build:backend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/backend build", "build:backend": "pnpm --filter @sexy.pivoine.art/backend build",
"dev:buttplug": "pnpm --filter @sexy.pivoine.art/buttplug serve",
"dev:data": "docker compose up -d postgres redis", "dev:data": "docker compose up -d postgres redis",
"dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev", "dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev",
"dev": "pnpm dev:data && pnpm dev:backend & pnpm --filter @sexy.pivoine.art/frontend dev", "dev": "pnpm dev:data && pnpm dev:backend & pnpm dev:buttplug & pnpm --filter @sexy.pivoine.art/frontend dev",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
@@ -22,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

@@ -14,14 +14,15 @@
"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.pivoine.art/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

@@ -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,5 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
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",
@@ -32,3 +33,13 @@ export async function sendPasswordReset(email: string, token: string): Promise<v
html: `<p>Click <a href="${BASE_URL}/password/reset?token=${token}">here</a> to reset your password.</p>`, 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/

View File

@@ -2,6 +2,7 @@
"name": "@sexy.pivoine.art/buttplug", "name": "@sexy.pivoine.art/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",
@@ -10,7 +11,8 @@
], ],
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target bundler --release" "build:wasm": "wasm-pack build --out-dir wasm --out-name index --target web --release",
"serve": "node serve.mjs"
}, },
"dependencies": { "dependencies": {
"eventemitter3": "^5.0.4", "eventemitter3": "^5.0.4",

View File

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

View File

@@ -40,7 +40,9 @@ export class ButtplugWasmClientConnector extends EventEmitter implements IButtpl
private static maybeLoadWasm = async () => { private static maybeLoadWasm = async () => {
if (ButtplugWasmClientConnector.wasmInstance == undefined) { if (ButtplugWasmClientConnector.wasmInstance == undefined) {
ButtplugWasmClientConnector.wasmInstance = await import("../wasm/index.js"); const wasmModule = await import("../wasm/index.js");
await wasmModule.default(); // --target web requires calling init() before using exports
ButtplugWasmClientConnector.wasmInstance = wasmModule;
} }
}; };

View File

@@ -23,6 +23,7 @@ type FFICallback = js_sys::Function;
type FFICallbackContext = u32; 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,7 +1,6 @@
{ {
"name": "@sexy.pivoine.art/frontend", "name": "@sexy.pivoine.art/frontend",
"version": "1.0.0", "version": "1.0.0",
"author": "valknarogg",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -12,9 +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:*",
"@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",
@@ -29,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",
@@ -38,11 +37,9 @@
"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/buttplug": "workspace:*",
"@sexy.pivoine.art/types": "workspace:*", "@sexy.pivoine.art/types": "workspace:*",
"graphql": "^16.11.0", "graphql": "^16.11.0",
"graphql-request": "^7.1.2", "graphql-request": "^7.1.2",

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

@@ -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: "Bring your 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

@@ -902,6 +902,26 @@ export async function createRecording(
); );
} }
const UPDATE_RECORDING_MUTATION = gql`
mutation UpdateRecording($id: String!, $status: String, $public: Boolean) {
updateRecording(id: $id, status: $status, public: $public) {
id
status
public
}
}
`;
export async function updateRecording(id: string, fields: { status?: string; public?: boolean }) {
return loggedApiCall("updateRecording", async () => {
const data = await getGraphQLClient().request<{ updateRecording: Recording }>(
UPDATE_RECORDING_MUTATION,
{ id, ...fields },
);
return data.updateRecording;
});
}
const DELETE_RECORDING_MUTATION = gql` const DELETE_RECORDING_MUTATION = gql`
mutation DeleteRecording($id: String!) { mutation DeleteRecording($id: String!) {
deleteRecording(id: $id) deleteRecording(id: $id)
@@ -1876,3 +1896,145 @@ 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,
): Promise<Job[]> {
return loggedApiCall("getAdminQueueJobs", async () => {
const data = await getGraphQLClient().request<{ adminQueueJobs: Job[] }>(
ADMIN_QUEUE_JOBS_QUERY,
{ queue, status, limit, offset },
);
return data.adminQueueJobs;
});
}
const ADMIN_RETRY_JOB_MUTATION = gql`
mutation AdminRetryJob($queue: String!, $jobId: String!) {
adminRetryJob(queue: $queue, jobId: $jobId)
}
`;
export async function adminRetryJob(queue: string, jobId: string): Promise<void> {
return loggedApiCall("adminRetryJob", async () => {
await getGraphQLClient().request(ADMIN_RETRY_JOB_MUTATION, { queue, jobId });
});
}
const ADMIN_REMOVE_JOB_MUTATION = gql`
mutation AdminRemoveJob($queue: String!, $jobId: String!) {
adminRemoveJob(queue: $queue, jobId: $jobId)
}
`;
export async function adminRemoveJob(queue: string, jobId: string): Promise<void> {
return loggedApiCall("adminRemoveJob", async () => {
await getGraphQLClient().request(ADMIN_REMOVE_JOB_MUTATION, { queue, jobId });
});
}
const ADMIN_PAUSE_QUEUE_MUTATION = gql`
mutation AdminPauseQueue($queue: String!) {
adminPauseQueue(queue: $queue)
}
`;
const ADMIN_RESUME_QUEUE_MUTATION = gql`
mutation AdminResumeQueue($queue: String!) {
adminResumeQueue(queue: $queue)
}
`;
export async function adminPauseQueue(queue: string): Promise<void> {
return loggedApiCall("adminPauseQueue", async () => {
await getGraphQLClient().request(ADMIN_PAUSE_QUEUE_MUTATION, { queue });
});
}
export async function adminResumeQueue(queue: string): Promise<void> {
return loggedApiCall("adminResumeQueue", async () => {
await getGraphQLClient().request(ADMIN_RESUME_QUEUE_MUTATION, { queue });
});
}

View File

@@ -28,7 +28,9 @@
<div class="bg-background text-foreground min-h-screen"> <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,7 @@
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";
const { data } = $props(); const { data } = $props();
@@ -64,8 +65,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 +84,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 +204,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">
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{$_("admin.users.showing", { {$_("admin.users.showing", {
values: { values: {

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,7 @@
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";
const { data } = $props(); const { data } = $props();
const timeAgo = new TimeAgo("en"); const timeAgo = new TimeAgo("en");
@@ -53,15 +54,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 +153,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">
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{$_("admin.users.showing", { {$_("admin.users.showing", {
values: { values: {

View File

@@ -0,0 +1,7 @@
import { getAdminQueues } from "$lib/services";
export async function load({ fetch, cookies }) {
const token = cookies.get("session_token") || "";
const queues = await getAdminQueues(fetch, token).catch(() => []);
return { queues };
}

View File

@@ -0,0 +1,302 @@
<script lang="ts">
import { invalidateAll } from "$app/navigation";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import {
getAdminQueueJobs,
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";
const { data } = $props();
const queues = $derived(data.queues);
// null means "user hasn't picked yet" — fall back to first queue
let selectedQueueOverride = $state<string | null>(null);
const selectedQueue = $derived(selectedQueueOverride ?? queues[0]?.name ?? null);
let selectedStatus = $state<string | null>(null);
let jobs = $state<Job[]>([]);
let loadingJobs = $state(false);
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") },
];
async function loadJobs() {
if (!selectedQueue) return;
loadingJobs = true;
try {
jobs = await getAdminQueueJobs(selectedQueue, selectedStatus ?? undefined, 50, 0);
} finally {
loadingJobs = false;
}
}
async function selectQueue(name: string) {
selectedQueueOverride = name;
selectedStatus = null;
await loadJobs();
}
async function selectStatus(status: string | null) {
selectedStatus = status;
await loadJobs();
}
async function retryJob(job: Job) {
try {
await adminRetryJob(job.queue, job.id);
toast.success($_("admin.queues.retry_success"));
await loadJobs();
await refreshCounts();
} 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"));
jobs = jobs.filter((j) => j.id !== job.id);
await refreshCounts();
} 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 refreshCounts();
} catch {
toast.error(isPaused ? $_("admin.queues.resume_error") : $_("admin.queues.pause_error"));
} finally {
togglingQueue = null;
}
}
async function refreshCounts() {
await invalidateAll();
}
$effect(() => {
if (selectedQueue) loadJobs();
});
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>
</div>
<!-- Queue cards -->
<div class="flex flex-wrap gap-3 mb-6">
{#each queues as queue (queue.name)}
{@const isSelected = selectedQueue === 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 selectedQueue}
<!-- Status filter tabs -->
<div class="flex gap-1 mb-4 flex-wrap">
{#each STATUS_FILTERS as f (f.value ?? "all")}
<Button
variant={selectedStatus === 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">
{#if loadingJobs}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
>{$_("common.loading")}</td
>
</tr>
{:else}
{#each 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 jobs.length === 0}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
>{$_("admin.queues.no_jobs")}</td
>
</tr>
{/if}
{/if}
</tbody>
</table>
</div>
{/if}
</div>

View File

@@ -11,6 +11,7 @@
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";
const { data } = $props(); const { data } = $props();
const timeAgo = new TimeAgo("en"); const timeAgo = new TimeAgo("en");
@@ -63,15 +64,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 +131,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 +179,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">
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{$_("admin.users.showing", { {$_("admin.users.showing", {
values: { values: {

View File

@@ -12,6 +12,7 @@
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";
const { data } = $props(); const { data } = $props();
@@ -84,8 +85,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 +96,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 +228,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">
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{$_("admin.users.showing", { {$_("admin.users.showing", {
values: { values: {

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,7 @@
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";
const { data } = $props(); const { data } = $props();
@@ -61,8 +62,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 +81,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 +209,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">
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{$_("admin.users.showing", { {$_("admin.users.showing", {
values: { values: {

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

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

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

@@ -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,12 @@
import { redirect } from "@sveltejs/kit";
import { isModel } from "$lib/api";
import { getAnalytics } from "$lib/services";
export async function load({ locals, fetch }) {
if (!isModel(locals.authStatus.user!)) {
throw redirect(302, "/me/profile");
}
return {
analytics: await getAnalytics(fetch).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,284 @@
<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,
CardDescription,
CardHeader,
CardTitle,
} 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>
</div>
<Card class="bg-card/50 border-primary/20 max-w-2xl">
<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>
<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,163 @@
<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,
CardDescription,
CardHeader,
CardTitle,
} 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="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("me.settings.privacy_title")}</h1>
</div>
<Card class="bg-card/50 border-primary/20 max-w-2xl">
<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">
<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

@@ -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,123 @@
<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">
<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

@@ -1,615 +0,0 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import Meta from "$lib/components/meta/meta.svelte";
import {
ButtplugClient,
ButtplugWasmClientConnector,
type ButtplugClientDevice,
type OutputType,
InputType,
DeviceOutputValueConstructor,
} from "@sexy.pivoine.art/buttplug";
import Button from "$lib/components/ui/button/button.svelte";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import DeviceCard from "$lib/components/device-card/device-card.svelte";
import RecordingSaveDialog from "./components/recording-save-dialog.svelte";
import DeviceMappingDialog from "./components/device-mapping-dialog.svelte";
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
import { toast } from "svelte-sonner";
import SexyBackground from "$lib/components/background/background.svelte";
const client = new ButtplugClient("Sexy.Art");
let connected = $state(client.connected);
let scanning = $state(false);
let devices = $state<BluetoothDevice[]>([]);
// Recording state
let isRecording = $state(false);
let recordingStartTime = $state<number | null>(null);
let recordedEvents = $state<RecordedEvent[]>([]);
let showSaveDialog = $state(false);
let recordingDuration = $state(0);
// Playback state
let isPlaying = $state(false);
let playbackProgress = $state(0);
let playbackStartTime = $state<number | null>(null);
let playbackTimeoutId = $state<number | null>(null);
let currentEventIndex = $state(0);
let showMappingDialog = $state(false);
let deviceMappings = $state<Map<string, BluetoothDevice>>(new Map());
async function init() {
const connector = new ButtplugWasmClientConnector();
// await ButtplugWasmClientConnector.activateLogging("info");
await client.connect(connector);
client.on("deviceadded", onDeviceAdded);
client.on("deviceremoved", (dev: ButtplugClientDevice) => {
const idx = devices.findIndex((d) => d.info.index === dev.index);
if (idx !== -1) devices.splice(idx, 1);
});
client.on("scanningfinished", () => (scanning = false));
client.on("inputreading", handleInputReading);
connected = client.connected;
}
async function startScanning() {
await client.startScanning();
scanning = true;
}
async function onDeviceAdded(dev: ButtplugClientDevice) {
const device = convertDevice(dev);
devices.push(device);
// Try to read battery level — access through the reactive array so Svelte detects the mutation
const idx = devices.length - 1;
if (device.hasBattery) {
try {
devices[idx].batteryLevel = await dev.battery();
} catch (e) {
console.warn(`Failed to read battery for ${dev.name}:`, e);
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleInputReading(msg: any) {
if (msg.InputReading === undefined) return;
const reading = msg.InputReading;
const device = devices.find((d) => d.info.index === reading.DeviceIndex);
if (!device) return;
if (reading.Reading[InputType.Battery] !== undefined) {
device.batteryLevel = reading.Reading[InputType.Battery].Value;
}
device.lastSeen = new Date();
}
async function handleChange(device: BluetoothDevice, actuatorIdx: number, value: number) {
const actuator = device.actuators[actuatorIdx];
const feature = device.info.features.get(actuator.featureIndex);
if (!feature) return;
actuator.value = value;
const outputType = actuator.outputType as typeof OutputType;
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
// Capture event if recording
if (isRecording && recordingStartTime) {
captureEvent(device, actuatorIdx, value);
}
}
function startRecording() {
if (devices.length === 0) {
return;
}
isRecording = true;
recordingStartTime = performance.now();
recordedEvents = [];
recordingDuration = 0;
}
function stopRecording() {
isRecording = false;
if (recordedEvents.length > 0) {
recordingDuration = recordedEvents[recordedEvents.length - 1].timestamp;
showSaveDialog = true;
}
}
function captureEvent(device: BluetoothDevice, actuatorIdx: number, value: number) {
if (!recordingStartTime) return;
const timestamp = performance.now() - recordingStartTime;
const actuator = device.actuators[actuatorIdx];
recordedEvents.push({
timestamp,
device_index: device.info.index,
device_name: device.name,
actuator_index: actuatorIdx,
actuator_type: actuator.outputType,
value: (value / actuator.maxSteps) * 100, // Normalize to 0-100
});
}
async function handleStop(device: BluetoothDevice) {
await device.info.stop();
device.actuators.forEach((a) => (a.value = 0));
}
function convertDevice(device: ButtplugClientDevice): BluetoothDevice {
const actuators: import("$lib/types").DeviceActuator[] = []; // eslint-disable-line @typescript-eslint/consistent-type-imports
for (const [, feature] of device.features) {
for (const outputType of feature.outputTypes) {
actuators.push({
featureIndex: feature.featureIndex,
outputType,
maxSteps: feature.outputMaxValue(outputType),
descriptor: feature.featureDescriptor,
value: 0,
});
}
}
return {
id: String(device.index),
name: device.name,
actuators,
batteryLevel: 0,
hasBattery: device.hasInput(InputType.Battery),
isConnected: true,
lastSeen: new Date(),
info: device,
};
}
async function handleSaveRecording(data: { title: string; description: string; tags: string[] }) {
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
name: d.name,
index: d.info.index,
capabilities: d.actuators.map((a) => a.outputType),
}));
try {
const response = await fetch("/api/sexy/recordings", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: data.title,
description: data.description,
duration: recordingDuration,
events: recordedEvents,
device_info: deviceInfo,
tags: data.tags,
status: "draft",
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
toast.success("Recording saved successfully!");
showSaveDialog = false;
recordedEvents = [];
recordingDuration = 0;
// Optionally navigate to dashboard
// goto("/me?tab=recordings");
} catch (error) {
console.error("Failed to save recording:", error);
toast.error("Failed to save recording. Please try again.");
}
}
function handleCancelSave() {
showSaveDialog = false;
recordedEvents = [];
recordingDuration = 0;
}
// Playback functions
function startPlayback() {
if (!data.recording) {
return;
}
if (devices.length === 0) {
toast.error("Please connect devices before playing recording");
return;
}
// Check if we need to map devices
if (deviceMappings.size === 0 && (data.recording.device_info?.length ?? 0) > 0) {
showMappingDialog = true;
return;
}
// Start playback with existing mappings
beginPlayback();
}
function beginPlayback() {
isPlaying = true;
playbackStartTime = performance.now();
playbackProgress = 0;
currentEventIndex = 0;
scheduleNextEvent();
}
function handleMappingConfirm(mappings: Map<string, BluetoothDevice>) {
deviceMappings = mappings;
showMappingDialog = false;
beginPlayback();
}
function handleMappingCancel() {
showMappingDialog = false;
}
function stopPlayback() {
isPlaying = false;
if (playbackTimeoutId !== null) {
clearTimeout(playbackTimeoutId);
playbackTimeoutId = null;
}
playbackProgress = 0;
currentEventIndex = 0;
// Stop all devices
devices.forEach((device) => handleStop(device));
}
function pausePlayback() {
isPlaying = false;
if (playbackTimeoutId !== null) {
clearTimeout(playbackTimeoutId);
playbackTimeoutId = null;
}
}
function resumePlayback() {
if (!data.recording) return;
isPlaying = true;
playbackStartTime = performance.now() - playbackProgress;
scheduleNextEvent();
}
function scheduleNextEvent() {
if (!data.recording || !isPlaying || !playbackStartTime) return;
const events = (data.recording.events ?? []) as RecordedEvent[];
if (currentEventIndex >= events.length) {
stopPlayback();
toast.success("Playback finished");
return;
}
const event = events[currentEventIndex];
const currentTime = performance.now() - playbackStartTime;
const delay = event.timestamp - currentTime;
if (delay <= 0) {
// Execute event immediately
executeEvent(event);
currentEventIndex++;
scheduleNextEvent();
} else {
// Schedule event
playbackTimeoutId = setTimeout(() => {
executeEvent(event);
currentEventIndex++;
playbackProgress = event.timestamp;
scheduleNextEvent();
}, delay) as unknown as number;
}
}
function executeEvent(event: RecordedEvent) {
// Get mapped device
const device = deviceMappings.get(event.device_name);
if (!device) {
console.warn(`No device mapping for: ${event.device_name}`);
return;
}
// Find matching actuator by type
const actuator = device.actuators.find((a) => a.outputType === event.actuator_type);
if (!actuator) {
console.warn(`Actuator type ${event.actuator_type} not found on ${device.name}`);
return;
}
// Convert normalized value (0-100) back to device scale
const deviceValue = Math.round((event.value / 100) * actuator.maxSteps);
// Send command to device via feature
const feature = device.info.features.get(actuator.featureIndex);
if (feature) {
const outputType = actuator.outputType as typeof OutputType;
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
}
// Update UI
actuator.value = deviceValue;
}
function seek(percentage: number) {
if (!data.recording) return;
const targetTime = (percentage / 100) * data.recording.duration;
playbackProgress = targetTime;
// Find the event index at this time
const seekEvents = (data.recording.events ?? []) as RecordedEvent[];
currentEventIndex = seekEvents.findIndex((e) => e.timestamp >= targetTime);
if (currentEventIndex === -1) {
currentEventIndex = seekEvents.length;
}
if (isPlaying) {
if (playbackTimeoutId !== null) {
clearTimeout(playbackTimeoutId);
}
playbackStartTime = performance.now() - targetTime;
scheduleNextEvent();
}
}
const { data } = $props();
onMount(() => {
if (data.authStatus.authenticated) {
init();
return;
}
goto("/login");
});
</script>
<Meta title={$_("play.title")} description={$_("play.description")} />
<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 py-20 relative px-4">
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="text-center mb-12">
<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"
>
{$_("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 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>
{#if devices.length > 0 && !data.recording}
{#if !isRecording}
<Button
size="lg"
variant="outline"
onclick={startRecording}
class="cursor-pointer border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--record-circle-line] w-5 h-5 mr-2"></span>
Start Recording
</Button>
{:else}
<Button
size="lg"
onclick={stopRecording}
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>
Stop Recording ({recordedEvents.length} events)
</Button>
{/if}
{/if}
</div>
</div>
<!-- Playback Controls (only shown when recording is loaded) -->
{#if data.recording}
<div class="bg-card/50 border border-primary/20 rounded-lg p-6 backdrop-blur-sm">
<div class="mb-4">
<h2 class="text-xl font-semibold text-card-foreground mb-2">
{data.recording.title}
</h2>
{#if data.recording.description}
<p class="text-sm text-muted-foreground">
{data.recording.description}
</p>
{/if}
</div>
<!-- Progress Bar -->
<div class="mb-4">
<div class="flex items-center gap-3 mb-2">
<span class="text-sm text-muted-foreground min-w-[50px]">
{Math.floor(playbackProgress / 1000 / 60)}:{(
Math.floor(playbackProgress / 1000) % 60
)
.toString()
.padStart(2, "0")}
</span>
<div
role="slider"
tabindex="0"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={data.recording.duration}
aria-valuenow={playbackProgress}
class="flex-1 h-2 bg-muted rounded-full overflow-hidden cursor-pointer relative"
onclick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const percentage = ((e.clientX - rect.left) / rect.width) * 100;
seek(percentage);
}}
onkeydown={(e) => {
if (e.key === "ArrowRight")
seek(((playbackProgress + 1) / data.recording.duration) * 100);
else if (e.key === "ArrowLeft")
seek(((playbackProgress - 1) / data.recording.duration) * 100);
}}
>
<div
class="absolute inset-0 bg-gradient-to-r from-primary to-accent transition-all duration-150"
style="width: {(playbackProgress / data.recording.duration) * 100}%"
></div>
</div>
<span class="text-sm text-muted-foreground min-w-[50px] text-right">
{Math.floor(data.recording.duration / 1000 / 60)}:{(
Math.floor(data.recording.duration / 1000) % 60
)
.toString()
.padStart(2, "0")}
</span>
</div>
</div>
<!-- Playback Buttons -->
<div class="flex gap-2 justify-center">
<Button
size="lg"
variant="outline"
onclick={stopPlayback}
disabled={!isPlaying && playbackProgress === 0}
class="cursor-pointer border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--stop-fill] w-5 h-5"></span>
</Button>
{#if !isPlaying}
<Button
size="lg"
onclick={playbackProgress > 0 ? resumePlayback : startPlayback}
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]"
>
<span class="icon-[ri--play-fill] w-5 h-5 mr-2"></span>
{playbackProgress > 0 ? "Resume" : "Play"}
</Button>
{:else}
<Button
size="lg"
onclick={pausePlayback}
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
>
<span class="icon-[ri--pause-fill] w-5 h-5 mr-2"></span>
Pause
</Button>
{/if}
</div>
<!-- Recording Info -->
<div class="mt-4 pt-4 border-t border-border/50 grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-xs text-muted-foreground">Events</p>
<p class="text-sm font-medium">{data.recording.events?.length ?? 0}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Devices</p>
<p class="text-sm font-medium">{data.recording.device_info?.length ?? 0}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Status</p>
<p class="text-sm font-medium capitalize">{data.recording.status}</p>
</div>
</div>
</div>
{/if}
</div>
</div>
<div class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#if devices}
{#each devices as device (device.name)}
<DeviceCard
{device}
onChange={(scalarIndex, val) => handleChange(device, scalarIndex, val)}
onStop={() => handleStop(device)}
/>
{/each}
{/if}
</div>
{#if devices?.length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground text-lg mb-4">
{$_("play.no_results")}
</p>
</div>
{/if}
</div>
<!-- Recording Save Dialog -->
<RecordingSaveDialog
open={showSaveDialog}
events={recordedEvents}
deviceInfo={devices.map((d) => ({
name: d.name,
index: d.info.index,
capabilities: d.actuators.map((a) => a.outputType),
}))}
duration={recordingDuration}
onSave={handleSaveRecording}
onCancel={handleCancelSave}
/>
<!-- Device Mapping Dialog -->
{#if data.recording}
<DeviceMappingDialog
open={showMappingDialog}
recordedDevices={(data.recording.device_info ?? []) as DeviceInfo[]}
connectedDevices={devices}
onConfirm={handleMappingConfirm}
onCancel={handleMappingCancel}
/>
{/if}
</div>

View File

@@ -0,0 +1,19 @@
import { getRecording } from "$lib/services";
import type { Recording } from "$lib/types";
export async function load({ url, fetch }) {
const recordingId = url.searchParams.get("recording");
let recording: Recording | null = null;
if (recordingId) {
try {
recording = await getRecording(recordingId, fetch);
} catch (error) {
console.error("Failed to load recording:", error);
}
}
return {
recording,
};
}

View File

@@ -0,0 +1,549 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import Meta from "$lib/components/meta/meta.svelte";
import type * as ButtplugTypes from "@sexy.pivoine.art/buttplug";
import Button from "$lib/components/ui/button/button.svelte";
import { onMount } from "svelte";
import DeviceCard from "$lib/components/device-card/device-card.svelte";
import RecordingSaveDialog from "../components/recording-save-dialog.svelte";
import DeviceMappingDialog from "../components/device-mapping-dialog.svelte";
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
import { toast } from "svelte-sonner";
import { createRecording } from "$lib/services";
import * as Empty from "$lib/components/ui/empty";
// Runtime buttplug values — loaded dynamically from the buttplug nginx container
let client: ButtplugTypes.ButtplugClient;
let InputType: typeof ButtplugTypes.InputType;
let DeviceOutputValueConstructor: typeof ButtplugTypes.DeviceOutputValueConstructor;
let ButtplugWasmClientConnector: typeof ButtplugTypes.ButtplugWasmClientConnector;
let connected = $state(false);
let scanning = $state(false);
let devices = $state<BluetoothDevice[]>([]);
// Recording state
let isRecording = $state(false);
let recordingStartTime = $state<number | null>(null);
let recordedEvents = $state<RecordedEvent[]>([]);
let showSaveDialog = $state(false);
let recordingDuration = $state(0);
// Playback state
let isPlaying = $state(false);
let playbackProgress = $state(0);
let playbackStartTime = $state<number | null>(null);
let playbackTimeoutId = $state<number | null>(null);
let currentEventIndex = $state(0);
let showMappingDialog = $state(false);
let deviceMappings = $state<Map<string, BluetoothDevice>>(new Map());
async function init() {
const connector = new ButtplugWasmClientConnector();
await client.connect(connector);
client.on("deviceadded", onDeviceAdded);
client.on("deviceremoved", (dev: ButtplugTypes.ButtplugClientDevice) => {
const idx = devices.findIndex((d) => d.info.index === dev.index);
if (idx !== -1) devices.splice(idx, 1);
});
client.on("scanningfinished", () => (scanning = false));
client.on("inputreading", handleInputReading);
connected = client.connected;
}
async function startScanning() {
await client.startScanning();
scanning = true;
}
async function onDeviceAdded(dev: ButtplugTypes.ButtplugClientDevice) {
const device = convertDevice(dev);
devices.push(device);
const idx = devices.length - 1;
if (device.hasBattery) {
try {
devices[idx].batteryLevel = await dev.battery();
} catch (e) {
console.warn(`Failed to read battery for ${dev.name}:`, e);
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleInputReading(msg: any) {
if (msg.InputReading === undefined) return;
const reading = msg.InputReading;
const device = devices.find((d) => d.info.index === reading.DeviceIndex);
if (!device) return;
if (reading.Reading[InputType.Battery] !== undefined) {
device.batteryLevel = reading.Reading[InputType.Battery].Value;
}
device.lastSeen = new Date();
}
async function handleChange(device: BluetoothDevice, actuatorIdx: number, value: number) {
const actuator = device.actuators[actuatorIdx];
const feature = device.info.features.get(actuator.featureIndex);
if (!feature) return;
actuator.value = value;
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
if (isRecording && recordingStartTime) {
captureEvent(device, actuatorIdx, value);
}
}
function startRecording() {
if (devices.length === 0) return;
isRecording = true;
recordingStartTime = performance.now();
recordedEvents = [];
recordingDuration = 0;
}
function stopRecording() {
isRecording = false;
if (recordedEvents.length > 0) {
recordingDuration = recordedEvents[recordedEvents.length - 1].timestamp;
showSaveDialog = true;
}
}
function captureEvent(device: BluetoothDevice, actuatorIdx: number, value: number) {
if (!recordingStartTime) return;
const timestamp = performance.now() - recordingStartTime;
const actuator = device.actuators[actuatorIdx];
recordedEvents.push({
timestamp,
device_index: device.info.index,
device_name: device.name,
actuator_index: actuatorIdx,
actuator_type: actuator.outputType,
value: (value / actuator.maxSteps) * 100,
});
}
async function handleStop(device: BluetoothDevice) {
await device.info.stop();
device.actuators.forEach((a) => (a.value = 0));
}
function convertDevice(device: ButtplugTypes.ButtplugClientDevice): BluetoothDevice {
const actuators: import("$lib/types").DeviceActuator[] = []; // eslint-disable-line @typescript-eslint/consistent-type-imports
for (const [, feature] of device.features) {
for (const outputType of feature.outputTypes) {
actuators.push({
featureIndex: feature.featureIndex,
outputType,
maxSteps: feature.outputMaxValue(outputType),
descriptor: feature.featureDescriptor,
value: 0,
});
}
}
return {
id: String(device.index),
name: device.name,
actuators,
batteryLevel: 0,
hasBattery: device.hasInput(InputType.Battery),
isConnected: true,
lastSeen: new Date(),
info: device,
};
}
async function handleSaveRecording(saveData: {
title: string;
description: string;
tags: string[];
}) {
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
name: d.name,
index: d.info.index,
capabilities: d.actuators.map((a) => a.outputType),
}));
try {
await createRecording({
title: saveData.title,
description: saveData.description,
duration: Math.round(recordingDuration),
events: recordedEvents,
device_info: deviceInfo,
tags: saveData.tags,
status: "draft",
});
toast.success("Recording saved successfully!");
showSaveDialog = false;
recordedEvents = [];
recordingDuration = 0;
} catch (error) {
console.error("Failed to save recording:", error);
toast.error("Failed to save recording. Please try again.");
}
}
function handleCancelSave() {
showSaveDialog = false;
recordedEvents = [];
recordingDuration = 0;
}
function startPlayback() {
if (!data.recording) return;
if (devices.length === 0) {
toast.error("Please connect devices before playing recording");
return;
}
if (deviceMappings.size === 0 && (data.recording.device_info?.length ?? 0) > 0) {
showMappingDialog = true;
return;
}
beginPlayback();
}
function beginPlayback() {
isPlaying = true;
playbackStartTime = performance.now();
playbackProgress = 0;
currentEventIndex = 0;
scheduleNextEvent();
}
function handleMappingConfirm(mappings: Map<string, BluetoothDevice>) {
deviceMappings = mappings;
showMappingDialog = false;
beginPlayback();
}
function handleMappingCancel() {
showMappingDialog = false;
}
function stopPlayback() {
isPlaying = false;
if (playbackTimeoutId !== null) {
clearTimeout(playbackTimeoutId);
playbackTimeoutId = null;
}
playbackProgress = 0;
currentEventIndex = 0;
devices.forEach((device) => handleStop(device));
}
function pausePlayback() {
isPlaying = false;
if (playbackTimeoutId !== null) {
clearTimeout(playbackTimeoutId);
playbackTimeoutId = null;
}
}
function resumePlayback() {
if (!data.recording) return;
isPlaying = true;
playbackStartTime = performance.now() - playbackProgress;
scheduleNextEvent();
}
function scheduleNextEvent() {
if (!data.recording || !isPlaying || !playbackStartTime) return;
const events = (data.recording.events ?? []) as RecordedEvent[];
if (currentEventIndex >= events.length) {
stopPlayback();
toast.success("Playback finished");
return;
}
const event = events[currentEventIndex];
const currentTime = performance.now() - playbackStartTime;
const delay = event.timestamp - currentTime;
if (delay <= 0) {
executeEvent(event);
currentEventIndex++;
scheduleNextEvent();
} else {
playbackTimeoutId = setTimeout(() => {
executeEvent(event);
currentEventIndex++;
playbackProgress = event.timestamp;
scheduleNextEvent();
}, delay) as unknown as number;
}
}
function executeEvent(event: RecordedEvent) {
const device = deviceMappings.get(event.device_name);
if (!device) {
console.warn(`No device mapping for: ${event.device_name}`);
return;
}
const actuator = device.actuators.find((a) => a.outputType === event.actuator_type);
if (!actuator) {
console.warn(`Actuator type ${event.actuator_type} not found on ${device.name}`);
return;
}
const deviceValue = Math.round((event.value / 100) * actuator.maxSteps);
const feature = device.info.features.get(actuator.featureIndex);
if (feature) {
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
}
actuator.value = deviceValue;
}
function seek(percentage: number) {
if (!data.recording) return;
const targetTime = (percentage / 100) * data.recording.duration;
playbackProgress = targetTime;
const seekEvents = (data.recording.events ?? []) as RecordedEvent[];
currentEventIndex = seekEvents.findIndex((e) => e.timestamp >= targetTime);
if (currentEventIndex === -1) {
currentEventIndex = seekEvents.length;
}
if (isPlaying) {
if (playbackTimeoutId !== null) {
clearTimeout(playbackTimeoutId);
}
playbackStartTime = performance.now() - targetTime;
scheduleNextEvent();
}
}
const { data } = $props();
onMount(async () => {
// Concatenation prevents Rollup from statically resolving this URL at build time
const buttplugUrl = "/buttplug/" + "dist/index.js";
const bp = await import(/* @vite-ignore */ buttplugUrl);
InputType = bp.InputType;
DeviceOutputValueConstructor = bp.DeviceOutputValueConstructor;
ButtplugWasmClientConnector = bp.ButtplugWasmClientConnector;
client = new bp.ButtplugClient("Sexy.Art");
connected = client.connected;
await init();
});
</script>
<Meta title={$_("play.title")} description={$_("play.description")} />
<div class="py-3 sm:py-6 lg:pl-6">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("play.title")}</h1>
</div>
<!-- Recording controls (only when devices are connected) -->
{#if devices.length > 0 && !data.recording}
<div class="flex flex-wrap items-center gap-3 mb-6">
{#if !isRecording}
<Button
variant="outline"
onclick={startRecording}
class="cursor-pointer border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--record-circle-line] w-4 h-4 mr-2"></span>
Start Recording
</Button>
{:else}
<Button
onclick={stopRecording}
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white"
>
<span class="icon-[ri--stop-circle-fill] w-4 h-4 mr-2 animate-pulse"></span>
Stop Recording ({recordedEvents.length} events)
</Button>
{/if}
</div>
{/if}
<!-- Playback Controls (only shown when recording is loaded) -->
{#if data.recording}
<div class="bg-card/50 border border-primary/20 rounded-lg p-6 mb-6">
<div class="mb-4">
<h2 class="text-xl font-semibold text-card-foreground mb-1">
{data.recording.title}
</h2>
{#if data.recording.description}
<p class="text-sm text-muted-foreground">
{data.recording.description}
</p>
{/if}
</div>
<!-- Progress Bar -->
<div class="mb-4">
<div class="flex items-center gap-3 mb-2">
<span class="text-sm text-muted-foreground min-w-[50px]">
{Math.floor(playbackProgress / 1000 / 60)}:{(Math.floor(playbackProgress / 1000) % 60)
.toString()
.padStart(2, "0")}
</span>
<div
role="slider"
tabindex="0"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={data.recording.duration}
aria-valuenow={playbackProgress}
class="flex-1 h-2 bg-muted rounded-full overflow-hidden cursor-pointer relative"
onclick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const percentage = ((e.clientX - rect.left) / rect.width) * 100;
seek(percentage);
}}
onkeydown={(e) => {
if (e.key === "ArrowRight")
seek(((playbackProgress + 1) / data.recording.duration) * 100);
else if (e.key === "ArrowLeft")
seek(((playbackProgress - 1) / data.recording.duration) * 100);
}}
>
<div
class="absolute inset-0 bg-gradient-to-r from-primary to-accent transition-all duration-150"
style="width: {(playbackProgress / data.recording.duration) * 100}%"
></div>
</div>
<span class="text-sm text-muted-foreground min-w-[50px] text-right">
{Math.floor(data.recording.duration / 1000 / 60)}:{(
Math.floor(data.recording.duration / 1000) % 60
)
.toString()
.padStart(2, "0")}
</span>
</div>
</div>
<!-- Playback Buttons -->
<div class="flex gap-2 justify-center">
<Button
variant="outline"
onclick={stopPlayback}
disabled={!isPlaying && playbackProgress === 0}
class="cursor-pointer border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--stop-fill] w-5 h-5"></span>
</Button>
{#if !isPlaying}
<Button
onclick={playbackProgress > 0 ? resumePlayback : startPlayback}
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]"
>
<span class="icon-[ri--play-fill] w-5 h-5 mr-2"></span>
{playbackProgress > 0 ? "Resume" : "Play"}
</Button>
{:else}
<Button
onclick={pausePlayback}
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
>
<span class="icon-[ri--pause-fill] w-5 h-5 mr-2"></span>
Pause
</Button>
{/if}
</div>
<!-- Recording Info -->
<div class="mt-4 pt-4 border-t border-border/50 grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-xs text-muted-foreground">Events</p>
<p class="text-sm font-medium">{data.recording.events?.length ?? 0}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Devices</p>
<p class="text-sm font-medium">{data.recording.device_info?.length ?? 0}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Status</p>
<p class="text-sm font-medium capitalize">{data.recording.status}</p>
</div>
</div>
</div>
{/if}
<!-- Devices grid or empty state -->
{#if devices.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{#each devices as device (device.name)}
<DeviceCard
{device}
onChange={(scalarIndex, val) => handleChange(device, scalarIndex, val)}
onStop={() => handleStop(device)}
/>
{/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}
</Button>
</Empty.Content>
</Empty.Root>
{/if}
</div>
<!-- Recording Save Dialog -->
<RecordingSaveDialog
open={showSaveDialog}
events={recordedEvents}
deviceInfo={devices.map((d) => ({
name: d.name,
index: d.info.index,
capabilities: d.actuators.map((a) => a.outputType),
}))}
duration={recordingDuration}
onSave={handleSaveRecording}
onCancel={handleCancelSave}
/>
<!-- Device Mapping Dialog -->
{#if data.recording}
<DeviceMappingDialog
open={showMappingDialog}
recordedDevices={(data.recording.device_info ?? []) as DeviceInfo[]}
connectedDevices={devices}
onConfirm={handleMappingConfirm}
onCancel={handleMappingCancel}
/>
{/if}

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,188 @@
<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-4 p-4 rounded-lg hover:bg-accent/10 transition-colors group"
>
<!-- Rank Badge -->
<div class="flex-shrink-0 w-14 text-center">
{#if entry.rank <= 3}
<span class="text-3xl">{getMedalEmoji(entry.rank)}</span>
{:else}
<span
class="text-xl font-bold text-muted-foreground group-hover:text-foreground transition-colors"
>
#{entry.rank}
</span>
{/if}
</div>
<!-- Avatar -->
<Avatar
class="h-12 w-12 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-sm text-muted-foreground flex items-center gap-3">
<span title={$_("gamification.recordings")}>
<span class="icon-[ri--video-line] w-3.5 h-3.5 inline mr-1"></span>
{entry.recordings_count}
</span>
<span title={$_("gamification.plays")}>
<span class="icon-[ri--play-line] w-3.5 h-3.5 inline mr-1"></span>
{entry.playbacks_count}
</span>
<span title={$_("gamification.achievements")}>
<span class="icon-[ri--trophy-line] w-3.5 h-3.5 inline mr-1"></span>
{entry.achievements_count}
</span>
</div>
</div>
<!-- Score -->
<div class="text-right flex-shrink-0">
<div class="text-2xl font-bold text-primary">
{formatPoints(entry.total_weighted_points)}
</div>
<div class="text-xs text-muted-foreground">
{$_("gamification.points")}
</div>
</div>
<!-- Arrow indicator -->
<div class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<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">
<CardContent class="p-6">
<h3 class="font-semibold mb-2 flex items-center gap-2">
<span class="icon-[ri--information-line] w-4 h-4 text-primary"></span>
{$_("gamification.how_it_works")}
</h3>
<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>

View File

@@ -0,0 +1,7 @@
import { getRecordings } from "$lib/services";
export async function load({ fetch }) {
return {
recordings: await getRecordings(fetch).catch(() => []),
};
}

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/buttplug?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>
<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/buttplug"
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

@@ -131,7 +131,7 @@
<span class="icon-[ri--trophy-line] w-6 h-6 text-primary"></span> <span class="icon-[ri--trophy-line] w-6 h-6 text-primary"></span>
{$_("gamification.stats")} {$_("gamification.stats")}
</h2> </h2>
<Button variant="outline" size="sm" href="/leaderboard"> <Button variant="outline" size="sm" href="/play/leaderboard">
<span class="icon-[ri--bar-chart-line] w-4 h-4 mr-2"></span> <span class="icon-[ri--bar-chart-line] w-4 h-4 mr-2"></span>
{$_("gamification.leaderboard")} {$_("gamification.leaderboard")}
</Button> </Button>

View File

@@ -2,13 +2,17 @@ import path from "path";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from "@sveltejs/kit/vite";
import wasm from "vite-plugin-wasm";
export default defineConfig({ export default defineConfig({
plugins: [sveltekit(), tailwindcss(), wasm()], plugins: [sveltekit(), tailwindcss()],
resolve: { resolve: {
alias: { $lib: path.resolve("./src/lib"), "@": path.resolve("./src/lib") }, alias: { $lib: path.resolve("./src/lib"), "@": path.resolve("./src/lib") },
}, },
build: {
rollupOptions: {
external: ["@sexy.pivoine.art/buttplug"],
},
},
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {
@@ -19,6 +23,11 @@ export default defineConfig({
secure: false, secure: false,
ws: true, ws: true,
}, },
"/buttplug": {
rewrite: (path) => path.replace(/^\/buttplug/, ""),
target: "http://localhost:8080",
changeOrigin: true,
},
}, },
}, },
}); });

View File

@@ -1,6 +1,7 @@
{ {
"name": "@sexy.pivoine.art/types", "name": "@sexy.pivoine.art/types",
"version": "1.0.0", "version": "1.0.0",
"private": true,
"types": "./src/index.ts", "types": "./src/index.ts",
"exports": { "exports": {
".": { ".": {

384
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
argon2: argon2:
specifier: ^0.43.0 specifier: ^0.43.0
version: 0.43.1 version: 0.43.1
bullmq:
specifier: ^5.70.4
version: 5.70.4
drizzle-orm: drizzle-orm:
specifier: ^0.44.1 specifier: ^0.44.1
version: 0.44.7(@types/pg@8.18.0)(knex@3.1.0(pg@8.19.0))(pg@8.19.0) version: 0.44.7(@types/pg@8.18.0)(knex@3.1.0(pg@8.19.0))(pg@8.19.0)
@@ -151,9 +154,6 @@ importers:
packages/frontend: packages/frontend:
dependencies: dependencies:
'@sexy.pivoine.art/buttplug':
specifier: workspace:*
version: link:../buttplug
'@sexy.pivoine.art/types': '@sexy.pivoine.art/types':
specifier: workspace:* specifier: workspace:*
version: link:../types version: link:../types
@@ -183,11 +183,14 @@ importers:
specifier: ^1.2.1 specifier: ^1.2.1
version: 1.2.1(tailwindcss@4.2.1) version: 1.2.1(tailwindcss@4.2.1)
'@internationalized/date': '@internationalized/date':
specifier: ^3.11.0 specifier: ^3.12.0
version: 3.11.0 version: 3.12.0
'@lucide/svelte': '@lucide/svelte':
specifier: ^0.561.0 specifier: ^0.561.0
version: 0.561.0(svelte@5.53.7) version: 0.561.0(svelte@5.53.7)
'@sexy.pivoine.art/buttplug':
specifier: workspace:*
version: link:../buttplug
'@sveltejs/adapter-node': '@sveltejs/adapter-node':
specifier: ^5.5.4 specifier: ^5.5.4
version: 5.5.4(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0))) version: 5.5.4(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))
@@ -214,7 +217,7 @@ importers:
version: 5.0.8 version: 5.0.8
bits-ui: bits-ui:
specifier: 2.16.2 specifier: 2.16.2
version: 2.16.2(@internationalized/date@3.11.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7) version: 2.16.2(@internationalized/date@3.12.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
@@ -227,9 +230,6 @@ importers:
prettier-plugin-svelte: prettier-plugin-svelte:
specifier: ^3.5.1 specifier: ^3.5.1
version: 3.5.1(prettier@3.8.1)(svelte@5.53.7) version: 3.5.1(prettier@3.8.1)(svelte@5.53.7)
super-sitemap:
specifier: ^1.0.7
version: 1.0.7(svelte@5.53.7)
svelte: svelte:
specifier: ^5.53.7 specifier: ^5.53.7
version: 5.53.7 version: 5.53.7
@@ -257,9 +257,6 @@ importers:
vite: vite:
specifier: ^7.3.1 specifier: ^7.3.1
version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0) version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)
vite-plugin-wasm:
specifier: 3.5.0
version: 3.5.0(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0))
packages/types: packages/types:
devDependencies: devDependencies:
@@ -1094,67 +1091,79 @@ packages:
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5': '@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4': '@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4': '@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4': '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4': '@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5': '@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5': '@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5': '@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5': '@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5': '@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5': '@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5': '@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
@@ -1173,8 +1182,11 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@internationalized/date@3.11.0': '@internationalized/date@3.12.0':
resolution: {integrity: sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==} resolution: {integrity: sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==}
'@ioredis/commands@1.5.0':
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
'@ioredis/commands@1.5.1': '@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
@@ -1211,6 +1223,36 @@ packages:
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'} engines: {node: '>=8'}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
cpu: [arm64]
os: [darwin]
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==}
cpu: [x64]
os: [darwin]
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==}
cpu: [arm64]
os: [linux]
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==}
cpu: [arm]
os: [linux]
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==}
cpu: [x64]
os: [linux]
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==}
cpu: [x64]
os: [win32]
'@phc/format@1.0.0': '@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1305,66 +1347,79 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0': '@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0': '@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0': '@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0': '@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0': '@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0': '@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0': '@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0': '@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0': '@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0': '@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0': '@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0': '@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0': '@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -1491,24 +1546,28 @@ packages:
engines: {node: '>= 20'} engines: {node: '>= 20'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.1': '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.1': '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.1': '@tailwindcss/oxide-linux-x64-musl@4.2.1':
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.1': '@tailwindcss/oxide-wasm32-wasi@4.2.1':
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
@@ -1698,10 +1757,6 @@ packages:
ajv@8.18.0: ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
ansi-styles@3.2.1:
resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
engines: {node: '>=4'}
argon2@0.43.1: argon2@0.43.1:
resolution: {integrity: sha512-TfOzvDWUaQPurCT1hOwIeFNkgrAJDpbBGBGWDgzDsm11nNhImc13WhdGdCU6K7brkp8VpeY07oGtSex0Wmhg8w==} resolution: {integrity: sha512-TfOzvDWUaQPurCT1hOwIeFNkgrAJDpbBGBGWDgzDsm11nNhImc13WhdGdCU6K7brkp8VpeY07oGtSex0Wmhg8w==}
engines: {node: '>=16.17.0'} engines: {node: '>=16.17.0'}
@@ -1710,14 +1765,6 @@ packages:
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
array-back@3.1.0:
resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==}
engines: {node: '>=6'}
array-back@4.0.2:
resolution: {integrity: sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==}
engines: {node: '>=8'}
async@0.2.10: async@0.2.10:
resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==}
@@ -1767,15 +1814,14 @@ packages:
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
bullmq@5.70.4:
resolution: {integrity: sha512-S58YT/tGdhc4pEPcIahtZRBR1TcTLpss1UKiXimF+Vy4yZwF38pW2IvhHqs4j4dEbZqDt8oi0jGGN/WYQHbPDg==}
ce-la-react@0.3.2: ce-la-react@0.3.2:
resolution: {integrity: sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==} resolution: {integrity: sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==}
peerDependencies: peerDependencies:
react: '>=17.0.0' react: '>=17.0.0'
chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
chokidar@4.0.3: chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'} engines: {node: '>= 14.16.0'}
@@ -1796,9 +1842,6 @@ packages:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -1819,14 +1862,6 @@ packages:
colorette@2.0.19: colorette@2.0.19:
resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
command-line-args@5.2.1:
resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==}
engines: {node: '>=4.0.0'}
command-line-usage@6.1.3:
resolution: {integrity: sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==}
engines: {node: '>=8.0.0'}
commander@10.0.1: commander@10.0.1:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -1859,6 +1894,10 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
cron-parser@4.9.0:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'}
cross-inspect@1.0.1: cross-inspect@1.0.1:
resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==} resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -1916,10 +1955,6 @@ packages:
decimal.js@10.6.0: decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
deep-is@0.1.4: deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1946,11 +1981,6 @@ packages:
devalue@5.6.3: devalue@5.6.3:
resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==}
directory-tree@3.6.0:
resolution: {integrity: sha512-rTMWs+zxr0QEbzQKRfwV6SeEy+zIHFkorskI4bhG2o7ayr82c+FC7yWg3yLpurgp6Hs2NGy1NWrKIaDodr2r8A==}
engines: {node: '>=10.0'}
hasBin: true
dom-serializer@2.0.0: dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -2114,10 +2144,6 @@ packages:
escape-html@1.0.3: escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
escape-string-regexp@4.0.0: escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2239,13 +2265,6 @@ packages:
fast-uri@3.1.0: fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fast-xml-builder@1.0.0:
resolution: {integrity: sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==}
fast-xml-parser@5.4.2:
resolution: {integrity: sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==}
hasBin: true
fastify-plugin@5.1.0: fastify-plugin@5.1.0:
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
@@ -2275,10 +2294,6 @@ packages:
resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
find-replace@3.0.0:
resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==}
engines: {node: '>=4.0.0'}
find-up@5.0.0: find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2406,10 +2421,6 @@ packages:
resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==} resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
hasown@2.0.2: hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2451,6 +2462,10 @@ packages:
resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==} resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==}
engines: {node: '>=12.22.0'} engines: {node: '>=12.22.0'}
ioredis@5.9.3:
resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==}
engines: {node: '>=12.22.0'}
ipaddr.js@2.3.0: ipaddr.js@2.3.0:
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
@@ -2591,24 +2606,28 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1: lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1: lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1: lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1: lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -2637,9 +2656,6 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.defaults@4.2.0: lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
@@ -2659,6 +2675,10 @@ packages:
lru-queue@0.1.0: lru-queue@0.1.0:
resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==} resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
lz-string@1.5.0: lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true hasBin: true
@@ -2750,6 +2770,13 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
msgpackr-extract@3.0.3:
resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==}
hasBin: true
msgpackr@1.11.5:
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
nanoid@3.3.11: nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -2761,10 +2788,17 @@ packages:
next-tick@1.1.0: next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-addon-api@8.6.0: node-addon-api@8.6.0:
resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==} resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
engines: {node: ^18 || ^20 || >= 21} engines: {node: ^18 || ^20 || >= 21}
node-gyp-build-optional-packages@5.2.2:
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
hasBin: true
node-gyp-build@4.8.4: node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true hasBin: true
@@ -2990,10 +3024,6 @@ packages:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'} engines: {node: '>=4'}
reduce-flatten@2.0.0:
resolution: {integrity: sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==}
engines: {node: '>=6'}
relative-time-format@1.1.12: relative-time-format@1.1.12:
resolution: {integrity: sha512-qaZBjmRIuXLfuLnzgqpFdBPa5W0euSX1tMnoMUHGPphLwJmrt8xbNiOIHrlvYOD6oNJ0M5owPCZyPibI8de5pQ==} resolution: {integrity: sha512-qaZBjmRIuXLfuLnzgqpFdBPa5W0euSX1tMnoMUHGPphLwJmrt8xbNiOIHrlvYOD6oNJ0M5owPCZyPibI8de5pQ==}
@@ -3084,6 +3114,11 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
set-cookie-parser@2.7.2: set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
@@ -3145,21 +3180,9 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
strnum@2.2.0:
resolution: {integrity: sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==}
style-to-object@1.0.14: style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
super-sitemap@1.0.7:
resolution: {integrity: sha512-fXUphLw0Tss29/SFP2irhWLNUCRbBNMf/XSsncvKpEM++CThfaPT8Bis3973RIb7KatiJAVfJtFH7xPyCp1AYw==}
peerDependencies:
svelte: '>=4.0.0 <6.0.0'
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
supports-preserve-symlinks-flag@1.0.0: supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3217,10 +3240,6 @@ packages:
tabbable@6.4.0: tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
table-layout@1.0.2:
resolution: {integrity: sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==}
engines: {node: '>=8.0.0'}
tailwind-merge@3.5.0: tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
@@ -3326,14 +3345,6 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
typical@4.0.0:
resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==}
engines: {node: '>=8'}
typical@5.2.0:
resolution: {integrity: sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==}
engines: {node: '>=8'}
ufo@1.6.3: ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
@@ -3423,10 +3434,6 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
wordwrapjs@4.0.1:
resolution: {integrity: sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==}
engines: {node: '>=8.0.0'}
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -4105,10 +4112,12 @@ snapshots:
'@img/sharp-win32-x64@0.33.5': '@img/sharp-win32-x64@0.33.5':
optional: true optional: true
'@internationalized/date@3.11.0': '@internationalized/date@3.12.0':
dependencies: dependencies:
'@swc/helpers': 0.5.19 '@swc/helpers': 0.5.19
'@ioredis/commands@1.5.0': {}
'@ioredis/commands@1.5.1': {} '@ioredis/commands@1.5.1': {}
'@isaacs/cliui@9.0.0': {} '@isaacs/cliui@9.0.0': {}
@@ -4144,6 +4153,24 @@ snapshots:
'@lukeed/ms@2.0.2': {} '@lukeed/ms@2.0.2': {}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
optional: true
'@phc/format@1.0.0': {} '@phc/format@1.0.0': {}
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
@@ -4597,10 +4624,6 @@ snapshots:
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 require-from-string: 2.0.2
ansi-styles@3.2.1:
dependencies:
color-convert: 1.9.3
argon2@0.43.1: argon2@0.43.1:
dependencies: dependencies:
'@phc/format': 1.0.0 '@phc/format': 1.0.0
@@ -4609,10 +4632,6 @@ snapshots:
aria-query@5.3.1: {} aria-query@5.3.1: {}
array-back@3.1.0: {}
array-back@4.0.2: {}
async@0.2.10: {} async@0.2.10: {}
atomic-sleep@1.0.0: {} atomic-sleep@1.0.0: {}
@@ -4642,11 +4661,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
bits-ui@2.16.2(@internationalized/date@3.11.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7): bits-ui@2.16.2(@internationalized/date@3.12.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7):
dependencies: dependencies:
'@floating-ui/core': 1.7.5 '@floating-ui/core': 1.7.5
'@floating-ui/dom': 1.7.6 '@floating-ui/dom': 1.7.6
'@internationalized/date': 3.11.0 '@internationalized/date': 3.12.0
esm-env: 1.2.2 esm-env: 1.2.2
runed: 0.35.1(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7) runed: 0.35.1(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)
svelte: 5.53.7 svelte: 5.53.7
@@ -4668,16 +4687,22 @@ snapshots:
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
bullmq@5.70.4:
dependencies:
cron-parser: 4.9.0
ioredis: 5.9.3
msgpackr: 1.11.5
node-abort-controller: 3.1.1
semver: 7.7.4
tslib: 2.8.1
uuid: 11.1.0
transitivePeerDependencies:
- supports-color
ce-la-react@0.3.2(react@19.2.0): ce-la-react@0.3.2(react@19.2.0):
dependencies: dependencies:
react: 19.2.0 react: 19.2.0
chalk@2.4.2:
dependencies:
ansi-styles: 3.2.1
escape-string-regexp: 1.0.5
supports-color: 5.5.0
chokidar@4.0.3: chokidar@4.0.3:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
@@ -4696,10 +4721,6 @@ snapshots:
cluster-key-slot@1.1.2: {} cluster-key-slot@1.1.2: {}
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -4721,20 +4742,6 @@ snapshots:
colorette@2.0.19: colorette@2.0.19:
optional: true optional: true
command-line-args@5.2.1:
dependencies:
array-back: 3.1.0
find-replace: 3.0.0
lodash.camelcase: 4.3.0
typical: 4.0.0
command-line-usage@6.1.3:
dependencies:
array-back: 4.0.2
chalk: 2.4.2
table-layout: 1.0.2
typical: 5.2.0
commander@10.0.1: commander@10.0.1:
optional: true optional: true
@@ -4757,6 +4764,10 @@ snapshots:
cookie@1.1.1: {} cookie@1.1.1: {}
cron-parser@4.9.0:
dependencies:
luxon: 3.7.2
cross-inspect@1.0.1: cross-inspect@1.0.1:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -4809,8 +4820,6 @@ snapshots:
decimal.js@10.6.0: {} decimal.js@10.6.0: {}
deep-extend@0.6.0: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
deepmerge@4.3.1: {} deepmerge@4.3.1: {}
@@ -4825,11 +4834,6 @@ snapshots:
devalue@5.6.3: {} devalue@5.6.3: {}
directory-tree@3.6.0:
dependencies:
command-line-args: 5.2.1
command-line-usage: 6.1.3
dom-serializer@2.0.0: dom-serializer@2.0.0:
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0
@@ -5016,8 +5020,6 @@ snapshots:
escape-html@1.0.3: {} escape-html@1.0.3: {}
escape-string-regexp@1.0.5: {}
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)): eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)):
@@ -5173,13 +5175,6 @@ snapshots:
fast-uri@3.1.0: {} fast-uri@3.1.0: {}
fast-xml-builder@1.0.0: {}
fast-xml-parser@5.4.2:
dependencies:
fast-xml-builder: 1.0.0
strnum: 2.2.0
fastify-plugin@5.1.0: {} fastify-plugin@5.1.0: {}
fastify@5.7.4: fastify@5.7.4:
@@ -5220,10 +5215,6 @@ snapshots:
fast-querystring: 1.1.2 fast-querystring: 1.1.2
safe-regex2: 5.0.0 safe-regex2: 5.0.0
find-replace@3.0.0:
dependencies:
array-back: 3.1.0
find-up@5.0.0: find-up@5.0.0:
dependencies: dependencies:
locate-path: 6.0.0 locate-path: 6.0.0
@@ -5341,8 +5332,6 @@ snapshots:
graphql@16.13.1: {} graphql@16.13.1: {}
has-flag@3.0.0: {}
hasown@2.0.2: hasown@2.0.2:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
@@ -5394,6 +5383,20 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
ioredis@5.9.3:
dependencies:
'@ioredis/commands': 1.5.0
cluster-key-slot: 1.1.2
debug: 4.4.3
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ipaddr.js@2.3.0: {} ipaddr.js@2.3.0: {}
is-arrayish@0.3.4: {} is-arrayish@0.3.4: {}
@@ -5542,8 +5545,6 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.camelcase@4.3.0: {}
lodash.defaults@4.2.0: {} lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {} lodash.isarguments@3.1.0: {}
@@ -5559,6 +5560,8 @@ snapshots:
dependencies: dependencies:
es5-ext: 0.10.64 es5-ext: 0.10.64
luxon@3.7.2: {}
lz-string@1.5.0: {} lz-string@1.5.0: {}
magic-string@0.30.21: magic-string@0.30.21:
@@ -5643,14 +5646,37 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
msgpackr-extract@3.0.3:
dependencies:
node-gyp-build-optional-packages: 5.2.2
optionalDependencies:
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3
optional: true
msgpackr@1.11.5:
optionalDependencies:
msgpackr-extract: 3.0.3
nanoid@3.3.11: {} nanoid@3.3.11: {}
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
next-tick@1.1.0: {} next-tick@1.1.0: {}
node-abort-controller@3.1.1: {}
node-addon-api@8.6.0: {} node-addon-api@8.6.0: {}
node-gyp-build-optional-packages@5.2.2:
dependencies:
detect-libc: 2.1.2
optional: true
node-gyp-build@4.8.4: {} node-gyp-build@4.8.4: {}
nodemailer@7.0.13: {} nodemailer@7.0.13: {}
@@ -5848,8 +5874,6 @@ snapshots:
dependencies: dependencies:
redis-errors: 1.2.0 redis-errors: 1.2.0
reduce-flatten@2.0.0: {}
relative-time-format@1.1.12: {} relative-time-format@1.1.12: {}
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
@@ -5948,6 +5972,8 @@ snapshots:
semver@7.7.3: {} semver@7.7.3: {}
semver@7.7.4: {}
set-cookie-parser@2.7.2: {} set-cookie-parser@2.7.2: {}
set-cookie-parser@3.0.1: {} set-cookie-parser@3.0.1: {}
@@ -6019,22 +6045,10 @@ snapshots:
statuses@2.0.2: {} statuses@2.0.2: {}
strnum@2.2.0: {}
style-to-object@1.0.14: style-to-object@1.0.14:
dependencies: dependencies:
inline-style-parser: 0.2.7 inline-style-parser: 0.2.7
super-sitemap@1.0.7(svelte@5.53.7):
dependencies:
directory-tree: 3.6.0
fast-xml-parser: 5.4.2
svelte: 5.53.7
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3): svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3):
@@ -6124,13 +6138,6 @@ snapshots:
tabbable@6.4.0: {} tabbable@6.4.0: {}
table-layout@1.0.2:
dependencies:
array-back: 4.0.2
deep-extend: 0.6.0
typical: 5.2.0
wordwrapjs: 4.0.1
tailwind-merge@3.5.0: {} tailwind-merge@3.5.0: {}
tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1): tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1):
@@ -6227,10 +6234,6 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
typical@4.0.0: {}
typical@5.2.0: {}
ufo@1.6.3: {} ufo@1.6.3: {}
undici-types@7.18.2: {} undici-types@7.18.2: {}
@@ -6285,11 +6288,6 @@ snapshots:
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
wordwrapjs@4.0.1:
dependencies:
reduce-flatten: 2.0.0
typical: 5.2.0
wrappy@1.0.2: {} wrappy@1.0.2: {}
ws@8.19.0: {} ws@8.19.0: {}