Compare commits

..

27 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 20:04:20 +01:00
56 changed files with 3177 additions and 532 deletions

View File

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

View File

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

View File

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

View File

@@ -33,10 +33,10 @@ COPY packages ./packages
RUN pnpm install --frozen-lockfile
# Generate SvelteKit type definitions (creates .svelte-kit/tsconfig.json)
RUN pnpm --filter @sexy.pivoine.art/frontend exec svelte-kit sync
RUN pnpm --filter @sexy/frontend exec svelte-kit sync
# Build frontend
RUN pnpm --filter @sexy.pivoine.art/frontend build
RUN pnpm --filter @sexy/frontend build
# Prune dev dependencies for production
RUN CI=true pnpm install -rP

View File

@@ -16,18 +16,22 @@ COPY packages/backend/package.json ./packages/backend/package.json
COPY packages/frontend/package.json ./packages/frontend/package.json
COPY packages/buttplug/package.json ./packages/buttplug/package.json
COPY packages/types/package.json ./packages/types/package.json
COPY packages/email/package.json ./packages/email/package.json
RUN pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend --ignore-scripts
RUN pnpm install --frozen-lockfile --filter @sexy/backend --filter @sexy/email --ignore-scripts
# Rebuild native bindings (argon2, sharp)
RUN pnpm rebuild argon2 sharp
COPY packages/types ./packages/types
COPY packages/email ./packages/email
COPY packages/backend ./packages/backend
RUN pnpm --filter @sexy.pivoine.art/backend build
RUN pnpm --filter @sexy/email build
RUN CI=true pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend --prod --ignore-scripts
RUN pnpm --filter @sexy/backend build
RUN CI=true pnpm install --frozen-lockfile --filter @sexy/backend --prod --ignore-scripts
RUN pnpm rebuild argon2 sharp
@@ -48,7 +52,7 @@ RUN userdel -r node && \
WORKDIR /home/node/app
RUN mkdir -p packages/backend
RUN mkdir -p packages/backend packages/email
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/package.json ./package.json
@@ -56,6 +60,11 @@ COPY --from=builder --chown=node:node /app/packages/backend/dist ./packages/back
COPY --from=builder --chown=node:node /app/packages/backend/node_modules ./packages/backend/node_modules
COPY --from=builder --chown=node:node /app/packages/backend/package.json ./packages/backend/package.json
COPY --from=builder --chown=node:node /app/packages/backend/src/migrations ./packages/backend/dist/migrations
COPY --from=builder --chown=node:node /app/packages/email/dist ./packages/email/dist
COPY --from=builder --chown=node:node /app/packages/email/node_modules ./packages/email/node_modules
COPY --from=builder --chown=node:node /app/packages/email/email.css ./packages/email/email.css
COPY --from=builder --chown=node:node /app/packages/email/templates ./packages/email/templates
COPY --from=builder --chown=node:node /app/packages/email/package.json ./packages/email/package.json
RUN mkdir -p /data/uploads && chown node:node /data/uploads

View File

@@ -35,14 +35,14 @@ 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
RUN pnpm install --frozen-lockfile --filter @sexy/buttplug
# Build WASM
RUN RUSTFLAGS='--cfg getrandom_backend="wasm_js" --cfg=web_sys_unstable_apis' \
pnpm --filter @sexy.pivoine.art/buttplug build:wasm
pnpm --filter @sexy/buttplug build:wasm
# Build TypeScript
RUN pnpm --filter @sexy.pivoine.art/buttplug build
RUN pnpm --filter @sexy/buttplug build
# ============================================================================
# Runner stage - nginx serving dist/ and wasm/

21
LICENSE Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
{
"name": "@sexy.pivoine.art/backend",
"name": "@sexy/backend",
"version": "1.0.0",
"private": true,
"scripts": {
@@ -20,7 +20,8 @@
"@fastify/static": "^8.1.1",
"@pothos/core": "^4.4.0",
"@pothos/plugin-errors": "^4.2.0",
"@sexy.pivoine.art/types": "workspace:*",
"@sexy/email": "workspace:*",
"@sexy/types": "workspace:*",
"argon2": "^0.43.0",
"bullmq": "^5.70.4",
"drizzle-orm": "^0.44.1",

View File

@@ -98,6 +98,18 @@ builder.mutationField("deleteComment", (t) =>
if (!comment[0]) throw new GraphQLError("Comment not found");
requireOwnerOrAdmin(ctx, comment[0].user_id);
await ctx.db.delete(comments).where(eq(comments.id, args.id));
await gamificationQueue.add("revokePoints", {
job: "revokePoints",
userId: comment[0].user_id,
action: "COMMENT_CREATE",
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: comment[0].user_id,
category: "social",
});
return true;
},
}),

View File

@@ -251,6 +251,28 @@ builder.mutationField("deleteRecording", (t) =>
if (!existing[0]) throw new GraphQLError("Recording not found");
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
if (existing[0].status === "published") {
await gamificationQueue.add("revokePoints", {
job: "revokePoints",
userId: ctx.currentUser.id,
action: "RECORDING_CREATE",
recordingId: args.id,
});
if (existing[0].featured) {
await gamificationQueue.add("revokePoints", {
job: "revokePoints",
userId: ctx.currentUser.id,
action: "RECORDING_FEATURED",
recordingId: args.id,
});
}
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "content",
});
}
await ctx.db.delete(recordings).where(eq(recordings.id, args.id));
return true;

View File

@@ -22,7 +22,7 @@ import type {
RecentPoint,
UserGamification,
Achievement,
} from "@sexy.pivoine.art/types";
} from "@sexy/types";
type AdminUserDetail = User & { photos: ModelPhoto[] };
import { builder } from "../builder";

View File

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

View File

@@ -1,4 +1,4 @@
import { eq, sql, and, gt, isNotNull, count, sum } from "drizzle-orm";
import { eq, sql, and, gt, isNull, isNotNull, count, sum } from "drizzle-orm";
import type { DB } from "../db/connection";
import {
user_points,
@@ -45,17 +45,33 @@ export async function revokePoints(
db: DB,
userId: string,
action: keyof typeof POINT_VALUES,
recordingId: string,
recordingId?: string,
): Promise<void> {
await db
.delete(user_points)
.where(
and(
eq(user_points.user_id, userId),
eq(user_points.action, action),
eq(user_points.recording_id, recordingId),
),
);
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);
}
@@ -116,7 +132,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
const commentsResult = await db
.select({ count: count() })
.from(comments)
.where(and(eq(comments.user_id, userId), eq(comments.collection, "recordings")));
.where(and(eq(comments.user_id, userId), eq(comments.collection, "videos")));
const commentsCount = commentsResult[0]?.count || 0;
const achievementsResult = await db
@@ -195,7 +211,9 @@ export async function checkAchievements(db: DB, userId: string, category?: strin
.update(user_achievements)
.set({
progress,
date_unlocked: isUnlocked ? existing[0].date_unlocked || new Date() : null,
date_unlocked: isUnlocked
? (existing[0].date_unlocked ?? new Date())
: existing[0].date_unlocked,
})
.where(
and(
@@ -277,7 +295,7 @@ async function getAchievementProgress(
const result = await db
.select({ count: count() })
.from(comments)
.where(and(eq(comments.user_id, userId), eq(comments.collection, "recordings")));
.where(and(eq(comments.user_id, userId), eq(comments.collection, "videos")));
return result[0]?.count || 0;
}

View File

@@ -9,7 +9,7 @@ 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: "revokePoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId?: string }
| { job: "checkAchievements"; userId: string; category?: string };
export function startGamificationWorker(): Worker {

View File

@@ -1,5 +1,5 @@
{
"name": "@sexy.pivoine.art/buttplug",
"name": "@sexy/buttplug",
"version": "1.0.0",
"type": "module",
"private": true,

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
{
"name": "@sexy.pivoine.art/frontend",
"name": "@sexy/frontend",
"version": "1.0.0",
"type": "module",
"private": true,
@@ -11,7 +11,7 @@
"check": "svelte-check --tsconfig ./tsconfig.json --threshold warning"
},
"devDependencies": {
"@sexy.pivoine.art/buttplug": "workspace:*",
"@sexy/buttplug": "workspace:*",
"@iconify-json/ri": "^1.2.10",
"@iconify/tailwind4": "^1.2.1",
"@internationalized/date": "^3.12.0",
@@ -40,7 +40,7 @@
"vite": "^7.3.1"
},
"dependencies": {
"@sexy.pivoine.art/types": "workspace:*",
"@sexy/types": "workspace:*",
"graphql": "^16.11.0",
"graphql-request": "^7.1.2",
"javascript-time-ago": "^2.6.4",

View File

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

View File

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

View File

@@ -823,7 +823,7 @@ export default {
},
play: {
title: "Play",
description: "Bring your toys.",
description: "Connect and control your Bluetooth toys.",
scan: "Start Scan",
scanning: "Scanning...",
no_results: "No devices found",

View File

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

View File

@@ -16,6 +16,22 @@ import type {
} from "$lib/types";
import { logger } from "$lib/logger";
export class UnauthorizedError extends Error {
constructor() {
super("Unauthorized");
this.name = "UnauthorizedError";
}
}
function isUnauthorizedError(error: unknown): boolean {
if (error && typeof error === "object" && "response" in error) {
const resp = (error as { response?: { errors?: { message: string }[] } }).response;
if (resp?.errors?.some((e) => e.message === "Unauthorized")) return true;
}
const msg = error instanceof Error ? error.message : String(error);
return msg.startsWith("Unauthorized");
}
// Helper to log API calls
async function loggedApiCall<T>(
operationName: string,
@@ -32,6 +48,10 @@ async function loggedApiCall<T>(
return result;
} catch (error) {
const duration = Date.now() - startTime;
if (isUnauthorizedError(error)) {
logger.debug(`🔒 API: ${operationName} unauthorized`, { duration, context });
throw new UnauthorizedError();
}
logger.error(`❌ API: ${operationName} failed`, {
duration,
context,
@@ -816,13 +836,12 @@ const RECORDINGS_QUERY = gql`
}
`;
export async function getRecordings(fetchFn?: typeof globalThis.fetch) {
export async function getRecordings(fetchFn?: typeof globalThis.fetch, token?: string) {
return loggedApiCall(
"getRecordings",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ recordings: Recording[] }>(
RECORDINGS_QUERY,
);
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
const data = await client.request<{ recordings: Recording[] }>(RECORDINGS_QUERY);
return data.recordings;
},
{},
@@ -960,14 +979,12 @@ const RECORDING_QUERY = gql`
}
`;
export async function getRecording(id: string, fetchFn?: typeof globalThis.fetch) {
export async function getRecording(id: string, fetchFn?: typeof globalThis.fetch, token?: string) {
return loggedApiCall(
"getRecording",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ recording: Recording | null }>(
RECORDING_QUERY,
{ id },
);
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
const data = await client.request<{ recording: Recording | null }>(RECORDING_QUERY, { id });
return data.recording;
},
{ id },
@@ -1799,13 +1816,12 @@ const ANALYTICS_QUERY = gql`
}
`;
export async function getAnalytics(fetchFn?: typeof globalThis.fetch) {
export async function getAnalytics(fetchFn?: typeof globalThis.fetch, token?: string) {
return loggedApiCall(
"getAnalytics",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ analytics: Analytics | null }>(
ANALYTICS_QUERY,
);
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
const data = await client.request<{ analytics: Analytics | null }>(ANALYTICS_QUERY);
return data.analytics;
},
{},
@@ -1981,12 +1997,17 @@ export async function getAdminQueueJobs(
status?: string,
limit?: number,
offset?: number,
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<Job[]> {
return loggedApiCall("getAdminQueueJobs", async () => {
const data = await getGraphQLClient().request<{ adminQueueJobs: Job[] }>(
ADMIN_QUEUE_JOBS_QUERY,
{ queue, status, limit, offset },
);
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminQueueJobs: Job[] }>(ADMIN_QUEUE_JOBS_QUERY, {
queue,
status,
limit,
offset,
});
return data.adminQueueJobs;
});
}

View File

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

View File

@@ -13,6 +13,7 @@
import type { Article } from "$lib/types";
import TimeAgo from "javascript-time-ago";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props();
@@ -204,7 +205,7 @@
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4">
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
@@ -214,32 +215,15 @@
},
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}
>
{$_("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}
>
{$_("common.next")}
</Button>
</div>
<Pagination
currentPage={Math.floor(data.offset / data.limit) + 1}
totalPages={Math.ceil(data.total / data.limit)}
onPageChange={(p) => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String((p - 1) * data.limit));
goto(`?${params.toString()}`);
}}
/>
</div>
{/if}
</div>

View File

@@ -11,6 +11,7 @@
import * as Dialog from "$lib/components/ui/dialog";
import TimeAgo from "javascript-time-ago";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props();
const timeAgo = new TimeAgo("en");
@@ -153,7 +154,7 @@
</div>
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4">
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
@@ -163,28 +164,15 @@
},
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}>{$_("common.previous")}</Button
>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}>{$_("common.next")}</Button
>
</div>
<Pagination
currentPage={Math.floor(data.offset / data.limit) + 1}
totalPages={Math.ceil(data.total / data.limit)}
onPageChange={(p) => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String((p - 1) * data.limit));
goto(`?${params.toString()}`);
}}
/>
</div>
{/if}
</div>

View File

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

View File

@@ -1,29 +1,18 @@
<script lang="ts">
import { invalidateAll } from "$app/navigation";
import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import {
getAdminQueueJobs,
adminRetryJob,
adminRemoveJob,
adminPauseQueue,
adminResumeQueue,
} from "$lib/services";
import { adminRetryJob, adminRemoveJob, adminPauseQueue, adminResumeQueue } from "$lib/services";
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import type { Job } from "$lib/services";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props();
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 = [
@@ -35,33 +24,28 @@
{ 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;
function navigate(overrides: Record<string, string | null>) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
for (const [k, v] of Object.entries(overrides)) {
if (v === null) params.delete(k);
else params.set(k, v);
}
goto(`?${params.toString()}`);
}
async function selectQueue(name: string) {
selectedQueueOverride = name;
selectedStatus = null;
await loadJobs();
function selectQueue(name: string) {
navigate({ queue: name, status: null, offset: null });
}
async function selectStatus(status: string | null) {
selectedStatus = status;
await loadJobs();
function selectStatus(status: string | null) {
navigate({ status, offset: null });
}
async function retryJob(job: Job) {
try {
await adminRetryJob(job.queue, job.id);
toast.success($_("admin.queues.retry_success"));
await loadJobs();
await refreshCounts();
await invalidateAll();
} catch {
toast.error($_("admin.queues.retry_error"));
}
@@ -71,8 +55,7 @@
try {
await adminRemoveJob(job.queue, job.id);
toast.success($_("admin.queues.remove_success"));
jobs = jobs.filter((j) => j.id !== job.id);
await refreshCounts();
await invalidateAll();
} catch {
toast.error($_("admin.queues.remove_error"));
}
@@ -88,7 +71,7 @@
await adminPauseQueue(queueName);
toast.success($_("admin.queues.pause_success"));
}
await refreshCounts();
await invalidateAll();
} catch {
toast.error(isPaused ? $_("admin.queues.resume_error") : $_("admin.queues.pause_error"));
} finally {
@@ -96,14 +79,6 @@
}
}
async function refreshCounts() {
await invalidateAll();
}
$effect(() => {
if (selectedQueue) loadJobs();
});
function statusColor(status: string): string {
switch (status) {
case "active":
@@ -130,12 +105,17 @@
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.queues.title")}</h1>
{#if data.queue && data.total > 0}
<span class="text-sm text-muted-foreground">
{$_("admin.users.total", { values: { total: data.total } })}
</span>
{/if}
</div>
<!-- Queue cards -->
<div class="flex flex-wrap gap-3 mb-6">
{#each queues as queue (queue.name)}
{@const isSelected = selectedQueue === queue.name}
{#each data.queues as queue (queue.name)}
{@const isSelected = data.queue === queue.name}
<div
role="button"
tabindex="0"
@@ -194,12 +174,12 @@
{/each}
</div>
{#if selectedQueue}
{#if data.queue}
<!-- Status filter tabs -->
<div class="flex gap-1 mb-4 flex-wrap">
{#each STATUS_FILTERS as f (f.value ?? "all")}
<Button
variant={selectedStatus === f.value ? "default" : "outline"}
variant={data.status === f.value ? "default" : "outline"}
onclick={() => selectStatus(f.value)}
>
{f.label}
@@ -233,70 +213,81 @@
</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
{#each data.jobs as job (job.id)}
<tr class="hover:bg-muted/10 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id}</td>
<td class="px-4 py-3">
<div>
<p class="font-medium">{job.name}</p>
{#if job.failedReason}
<p class="text-xs text-destructive mt-0.5 max-w-xs truncate">
{$_("admin.queues.failed_reason", { values: { reason: job.failedReason } })}
</p>
{/if}
</div>
</td>
<td class="px-4 py-3">
<Badge variant="outline" class={statusColor(job.status)}>{job.status}</Badge>
</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
>{job.attemptsMade}</td
>
</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}
<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.remove")}
class="text-destructive hover:text-destructive hover:bg-destructive/10"
onclick={() => removeJob(job)}
aria-label={$_("admin.queues.retry")}
onclick={() => retryJob(job)}
>
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
<span class="icon-[ri--restart-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}
<Button
size="sm"
variant="ghost"
aria-label={$_("admin.queues.remove")}
class="text-destructive hover:text-destructive hover:bg-destructive/10"
onclick={() => removeJob(job)}
>
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
</Button>
</div>
</td>
</tr>
{/each}
{#if data.jobs.length === 0}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
>{$_("admin.queues.no_jobs")}</td
>
</tr>
{/if}
</tbody>
</table>
</div>
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
start: data.offset + 1,
end: Math.min(data.offset + data.limit, data.total),
total: data.total,
},
})}
</span>
<Pagination
currentPage={Math.floor(data.offset / data.limit) + 1}
totalPages={Math.ceil(data.total / data.limit)}
onPageChange={(p) => navigate({ offset: String((p - 1) * data.limit) })}
/>
</div>
{/if}
{/if}
</div>

View File

@@ -12,6 +12,7 @@
import type { Recording } from "$lib/types";
import TimeAgo from "javascript-time-ago";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props();
const timeAgo = new TimeAgo("en");
@@ -179,7 +180,7 @@
</div>
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4">
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
@@ -189,28 +190,15 @@
},
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}>{$_("common.previous")}</Button
>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}>{$_("common.next")}</Button
>
</div>
<Pagination
currentPage={Math.floor(data.offset / data.limit) + 1}
totalPages={Math.ceil(data.total / data.limit)}
onPageChange={(p) => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String((p - 1) * data.limit));
goto(`?${params.toString()}`);
}}
/>
</div>
{/if}
</div>

View File

@@ -13,6 +13,7 @@
import * as Dialog from "$lib/components/ui/dialog";
import type { User } from "$lib/types";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props();
@@ -228,7 +229,7 @@
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4">
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
@@ -238,32 +239,15 @@
},
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}
>
{$_("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}
>
{$_("common.next")}
</Button>
</div>
<Pagination
currentPage={Math.floor(data.offset / data.limit) + 1}
totalPages={Math.ceil(data.total / data.limit)}
onPageChange={(p) => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String((p - 1) * data.limit));
goto(`?${params.toString()}`);
}}
/>
</div>
{/if}
</div>

View File

@@ -12,6 +12,7 @@
import * as Dialog from "$lib/components/ui/dialog";
import type { Video } from "$lib/types";
import Meta from "$lib/components/meta/meta.svelte";
import Pagination from "$lib/components/pagination/pagination.svelte";
const { data } = $props();
@@ -209,7 +210,7 @@
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4">
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
@@ -219,32 +220,15 @@
},
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}
>
{$_("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}
>
{$_("common.next")}
</Button>
</div>
<Pagination
currentPage={Math.floor(data.offset / data.limit) + 1}
totalPages={Math.ceil(data.total / data.limit)}
onPageChange={(p) => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String((p - 1) * data.limit));
goto(`?${params.toString()}`);
}}
/>
</div>
{/if}
</div>

View File

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

View File

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

View File

@@ -12,13 +12,7 @@
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 { Card, CardContent } from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
@@ -132,14 +126,11 @@
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("me.settings.profile_title")}</h1>
<p class="text-sm text-muted-foreground mt-1">{$_("me.settings.profile_subtitle")}</p>
</div>
<Card class="bg-card/50 border-primary/20 max-w-2xl">
<CardHeader>
<CardTitle>{$_("me.settings.profile_title")}</CardTitle>
<CardDescription>{$_("me.settings.profile_subtitle")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<CardContent class="space-y-4 pt-6">
<form onsubmit={handleProfileSubmit} class="space-y-4">
<div class="space-y-2">
<Label>{$_("me.settings.avatar")}</Label>

View File

@@ -8,13 +8,7 @@
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 { Card, CardContent } from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
@@ -58,16 +52,13 @@
<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">
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("me.settings.privacy_title")}</h1>
<p class="text-sm text-muted-foreground mt-1">{$_("me.settings.privacy_subtitle")}</p>
</div>
<Card class="bg-card/50 border-primary/20 max-w-2xl">
<CardHeader>
<CardTitle>{$_("me.settings.privacy_title")}</CardTitle>
<CardDescription>{$_("me.settings.privacy_subtitle")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<CardContent class="space-y-4 pt-6">
<form onsubmit={handleSecuritySubmit} class="space-y-4">
<div class="space-y-2">
<Label for="email">{$_("me.settings.email")}</Label>

View File

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

View File

@@ -41,7 +41,9 @@
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">
<div
class="min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 relative overflow-hidden"
>
<SexyBackground />
<div class="container mx-auto px-4 relative z-10">

View File

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

View File

@@ -1,7 +1,7 @@
<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 type * as ButtplugTypes from "@sexy/buttplug";
import Button from "$lib/components/ui/button/button.svelte";
import { onMount } from "svelte";
import DeviceCard from "$lib/components/device-card/device-card.svelte";
@@ -351,6 +351,7 @@
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("play.title")}</h1>
<p class="text-sm text-muted-foreground mt-1">{$_("play.description")}</p>
</div>
<!-- Recording controls (only when devices are connected) -->

View File

@@ -66,15 +66,15 @@
{#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"
class="flex items-center gap-2 sm:gap-4 px-2 py-2 sm:p-4 rounded-lg hover:bg-accent/10 transition-colors group"
>
<!-- Rank Badge -->
<div class="flex-shrink-0 w-14 text-center">
<div class="flex-shrink-0 w-8 sm:w-14 text-center">
{#if entry.rank <= 3}
<span class="text-3xl">{getMedalEmoji(entry.rank)}</span>
<span class="text-2xl sm:text-3xl">{getMedalEmoji(entry.rank)}</span>
{:else}
<span
class="text-xl font-bold text-muted-foreground group-hover:text-foreground transition-colors"
class="text-base sm:text-xl font-bold text-muted-foreground group-hover:text-foreground transition-colors"
>
#{entry.rank}
</span>
@@ -83,7 +83,7 @@
<!-- Avatar -->
<Avatar
class="h-12 w-12 ring-2 ring-accent/20 group-hover:ring-primary/40 transition-all"
class="h-9 w-9 sm:h-12 sm:w-12 shrink-0 ring-2 ring-accent/20 group-hover:ring-primary/40 transition-all"
>
{#if entry.avatar}
<AvatarImage src={getAssetUrl(entry.avatar, "mini")} alt={entry.display_name} />
@@ -100,17 +100,22 @@
<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">
<div
class="text-xs sm:text-sm text-muted-foreground flex items-center gap-2 sm:gap-3"
>
<span title={$_("gamification.recordings")}>
<span class="icon-[ri--video-line] w-3.5 h-3.5 inline mr-1"></span>
<span class="icon-[ri--video-line] w-3 h-3 sm:w-3.5 sm:h-3.5 inline mr-0.5"
></span>
{entry.recordings_count}
</span>
<span title={$_("gamification.plays")}>
<span class="icon-[ri--play-line] w-3.5 h-3.5 inline mr-1"></span>
<span class="icon-[ri--play-line] w-3 h-3 sm:w-3.5 sm:h-3.5 inline mr-0.5"
></span>
{entry.playbacks_count}
</span>
<span title={$_("gamification.achievements")}>
<span class="icon-[ri--trophy-line] w-3.5 h-3.5 inline mr-1"></span>
<span class="icon-[ri--trophy-line] w-3 h-3 sm:w-3.5 sm:h-3.5 inline mr-0.5"
></span>
{entry.achievements_count}
</span>
</div>
@@ -118,16 +123,18 @@
<!-- Score -->
<div class="text-right flex-shrink-0">
<div class="text-2xl font-bold text-primary">
<div class="text-lg sm:text-2xl font-bold text-primary">
{formatPoints(entry.total_weighted_points)}
</div>
<div class="text-xs text-muted-foreground">
<div class="text-xs text-muted-foreground hidden sm:block">
{$_("gamification.points")}
</div>
</div>
<!-- Arrow indicator -->
<div class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div
class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity hidden sm:block"
>
<span class="icon-[ri--arrow-right-s-line] w-5 h-5 text-muted-foreground"></span>
</div>
</a>
@@ -151,11 +158,13 @@
<!-- 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>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--information-line] w-5 h-5 text-primary"></span>
{$_("gamification.how_it_works")}
</h3>
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground mb-4">
{$_("gamification.how_it_works_description")}
</p>

View File

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

View File

@@ -68,6 +68,7 @@
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("me.recordings.title")}</h1>
<p class="text-sm text-muted-foreground mt-1">{$_("me.recordings.description")}</p>
</div>
{#if recordings.length === 0}

View File

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

View File

@@ -10,7 +10,7 @@ export default defineConfig({
},
build: {
rollupOptions: {
external: ["@sexy.pivoine.art/buttplug"],
external: ["@sexy/buttplug"],
},
},
server: {

View File

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

2372
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff