Compare commits
27 Commits
52aa00dd13
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b660dde9e | |||
| a5ad58ac7f | |||
| 3e21b88e07 | |||
| c3436233f4 | |||
| b3596d0b0a | |||
| 9b8b07c653 | |||
| 22a2e63687 | |||
| a05a96a8aa | |||
| d2deb3a218 | |||
| d0f0d865b6 | |||
| a30692b1ac | |||
| 60531771cf | |||
| bb6bf7ca11 | |||
| fdc16957a4 | |||
| f8cb365e09 | |||
| ad4f5b3700 | |||
| 3fd876180a | |||
| c5b04be981 | |||
| 96cffb9be1 | |||
| 9b1771ed6a | |||
| b842106e44 | |||
| 9abcd715d7 | |||
| ab0af9a773 | |||
| fbd2efa994 | |||
| 79932157bf | |||
| 04b0ec1a71 | |||
| cc693d8be7 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
21
LICENSE
Normal 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.
|
||||
40
README.md
40
README.md
@@ -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
|
||||
|
||||
[](https://dev.pivoine.art/valknar/sexy/actions)
|
||||
[](https://dev.pivoine.art/valknar/sexy/actions)
|
||||
[](https://dev.pivoine.art/valknar/sexy/actions)
|
||||
[](LICENSE)
|
||||
[](https://sexy.pivoine.art)
|
||||
[](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."_
|
||||
|
||||
[](https://dev.pivoine.art/valknar/sexy)
|
||||
[](https://dev.pivoine.art/valknar/sexy/issues)
|
||||
[](https://sexy.pivoine.art)
|
||||
[](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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
12
package.json
12
package.json
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@sexy.pivoine.art/buttplug",
|
||||
"name": "@sexy/buttplug",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -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
18
packages/email/email.css
Normal 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;
|
||||
}
|
||||
25
packages/email/package.json
Normal file
25
packages/email/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
25
packages/email/src/index.ts
Normal file
25
packages/email/src/index.ts
Normal 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}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
42
packages/email/src/render.ts
Normal file
42
packages/email/src/render.ts
Normal 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;
|
||||
}
|
||||
81
packages/email/templates/layouts/main.html
Normal file
81
packages/email/templates/layouts/main.html
Normal 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 }}
|
||||
‌ ‌ ‌ ‌ ‌ ‌
|
||||
</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">
|
||||
© {{ new Date().getFullYear() }} sexy — 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>
|
||||
41
packages/email/templates/password-reset.html
Normal file
41
packages/email/templates/password-reset.html
Normal 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>
|
||||
40
packages/email/templates/verification.html
Normal file
40
packages/email/templates/verification.html
Normal 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>
|
||||
14
packages/email/tsconfig.json
Normal file
14
packages/email/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ["@sexy.pivoine.art/buttplug"],
|
||||
external: ["@sexy/buttplug"],
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
||||
@@ -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
2372
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user