Compare commits

...

7 Commits

Author SHA1 Message Date
e200514347 refactor: move flyout to left side, restore logo, remove close button
Some checks failed
Build and Push Backend Image / build (push) Failing after 47s
Build and Push Frontend Image / build (push) Successful in 5m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 12:34:57 +01:00
d7057c3681 refactor: improve mobile flyout header
- Replace inline mobile dropdown with sliding flyout panel from right
- Hide burger menu on lg breakpoint, desktop auth buttons use hidden lg:flex
- Add backdrop overlay with opacity transition
- Remove logo from flyout panel header
- Fix backdrop div accessibility with role="presentation"

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 11:01:11 +01:00
c6126c13e9 feat: add backend logger matching frontend text format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 10:22:49 +01:00
fd4050a49f refactor: remove directus.ts shim, import directly from api
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 10:19:05 +01:00
38 changed files with 1143 additions and 1200 deletions

1
.gitignore vendored
View File

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

94
CLAUDE.md Normal file
View File

@@ -0,0 +1,94 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
`sexy.pivoine.art` is a self-hosted adult content platform (18+) built as a pnpm monorepo with three packages: `frontend` (SvelteKit 5), `backend` (Fastify + GraphQL), and `buttplug` (hardware integration via WebBluetooth/WASM).
## Common Commands
Run from the repo root unless otherwise noted.
```bash
# Development
pnpm dev:data # Start postgres & redis via Docker
pnpm dev:backend # Start backend on http://localhost:4000
pnpm dev # Start backend + frontend (frontend on :3000)
# Linting & Formatting
pnpm lint # ESLint across all packages
pnpm lint:fix # Auto-fix ESLint issues
pnpm format # Prettier format all files
pnpm format:check # Check formatting without changes
# Build
pnpm build:frontend # SvelteKit production build
pnpm build:backend # Compile backend TypeScript to dist/
# Database migrations (from packages/backend/)
pnpm migrate # Run pending Drizzle migrations
```
## Architecture
### Monorepo Layout
```
packages/
frontend/ # SvelteKit 2 + Svelte 5 + Tailwind CSS 4
backend/ # Fastify v5 + GraphQL Yoga v5 + Drizzle ORM
buttplug/ # TypeScript/Rust hybrid, compiles to WASM
```
### Backend (`packages/backend/src/`)
- **`index.ts`** — Fastify server entry: registers plugins (CORS, multipart, static), mounts GraphQL at `/graphql`, serves transformed assets at `/assets/:id`
- **`graphql/builder.ts`** — Pothos schema builder (code-first GraphQL)
- **`graphql/context.ts`** — Injects `currentUser` from Redis session into every request
- **`lib/auth.ts`** — Session management: `nanoid(32)` token stored in Redis with 24h TTL, set as httpOnly cookie
- **`db/schema/`** — Drizzle ORM table definitions (users, videos, files, comments, gamification, etc.)
- **`migrations/`** — SQL migration files managed by Drizzle Kit
### Frontend (`packages/frontend/src/`)
- **`lib/api.ts`** — GraphQL client (graphql-request)
- **`lib/services.ts`** — All API calls (login, videos, comments, models, etc.)
- **`lib/types.ts`** — Shared TypeScript types
- **`hooks.server.ts`** — Auth guard: reads session cookie, fetches `me` query, redirects if needed
- **`routes/`** — SvelteKit file-based routing: `/`, `/login`, `/signup`, `/me`, `/models`, `/models/[slug]`, `/videos`, `/play/[slug]`, `/magazine`, `/leaderboard`
### Asset Pipeline
Backend serves images with server-side Sharp transforms, cached to disk as WebP. Presets: `mini` (80×80), `thumbnail` (300×300), `preview` (800px wide), `medium` (1400px wide), `banner` (1600×480 cropped).
### Gamification
Points + achievements system tracked in `user_points` and `user_stats` tables. Logic in `packages/backend/src/lib/gamification.ts` and the `gamification` resolver.
## Code Style
- **TypeScript strict mode** in all packages
- **ESLint flat config** (`eslint.config.js` at root) — `any` is allowed but discouraged; enforces consistent type imports
- **Prettier**: 2-space indent, trailing commas, 100-char line width, Svelte plugin
- Migrations folder (`packages/backend/src/migrations/`) is excluded from lint
## Environment Variables (Backend)
| Variable | Purpose |
|----------|---------|
| `DATABASE_URL` | PostgreSQL connection string |
| `REDIS_URL` | Redis connection string |
| `COOKIE_SECRET` | Session cookie signing |
| `CORS_ORIGIN` | Frontend origin URL |
| `UPLOAD_DIR` | File storage path |
| `SMTP_HOST/PORT/EMAIL_FROM` | Email (Nodemailer) |
## Docker
```bash
docker compose up -d # Start all services (postgres, redis, backend, frontend)
arty up -d <service> # Preferred way to manage containers in this project
```
Production images are built and pushed to `dev.pivoine.art` via Gitea Actions on push to `main`.

View File

@@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "UPLOAD_DIR=/home/valknar/sexy-uploads DATABASE_URL=postgresql://sexy:sexy@localhost:5432/sexy REDIS_URL=redis://localhost:6379 tsx watch src/index.ts",
"dev": "UPLOAD_DIR=../../.data/uploads DATABASE_URL=postgresql://sexy:sexy@localhost:5432/sexy REDIS_URL=redis://localhost:6379 tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
@@ -14,6 +14,7 @@
"check": "tsc --noEmit"
},
"dependencies": {
"@sexy.pivoine.art/types": "workspace:*",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^10.0.2",
"@fastify/multipart": "^9.0.3",

View File

@@ -25,6 +25,7 @@ builder.queryField("commentsForVideo", (t) =>
id: users.id,
first_name: users.first_name,
last_name: users.last_name,
artist_name: users.artist_name,
avatar: users.avatar,
})
.from(users)
@@ -66,6 +67,7 @@ builder.mutationField("createCommentForVideo", (t) =>
id: users.id,
first_name: users.first_name,
last_name: users.last_name,
artist_name: users.artist_name,
avatar: users.avatar,
})
.from(users)

View File

@@ -1,19 +1,32 @@
import type {
MediaFile,
User,
VideoModel,
VideoFile,
Video,
ModelPhoto,
Model,
ArticleAuthor,
Article,
CommentUser,
Comment,
Stats,
Recording,
VideoLikeStatus,
VideoLikeResponse,
VideoPlayResponse,
VideoAnalytics,
Analytics,
LeaderboardEntry,
UserStats,
UserAchievement,
RecentPoint,
UserGamification,
Achievement,
} from "@sexy.pivoine.art/types";
import { builder } from "../builder";
// File type
export const FileType = builder
.objectRef<{
id: string;
title: string | null;
description: string | null;
filename: string;
mime_type: string | null;
filesize: number | null;
duration: number | null;
uploaded_by: string | null;
date_created: Date;
}>("File")
.implement({
export const FileType = builder.objectRef<MediaFile>("File").implement({
fields: (t) => ({
id: t.exposeString("id"),
title: t.exposeString("title", { nullable: true }),
@@ -25,26 +38,9 @@ export const FileType = builder
uploaded_by: t.exposeString("uploaded_by", { nullable: true }),
date_created: t.expose("date_created", { type: "DateTime" }),
}),
});
});
// User type
export const UserType = builder
.objectRef<{
id: string;
email: string;
first_name: string | null;
last_name: string | null;
artist_name: string | null;
slug: string | null;
description: string | null;
tags: string[] | null;
role: "model" | "viewer" | "admin";
avatar: string | null;
banner: string | null;
email_verified: boolean;
date_created: Date;
}>("User")
.implement({
export const UserType = builder.objectRef<User>("User").implement({
fields: (t) => ({
id: t.exposeString("id"),
email: t.exposeString("email"),
@@ -60,26 +56,10 @@ export const UserType = builder
email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }),
}),
});
});
// CurrentUser type (same shape, used for auth context)
export const CurrentUserType = builder
.objectRef<{
id: string;
email: string;
first_name: string | null;
last_name: string | null;
artist_name: string | null;
slug: string | null;
description: string | null;
tags: string[] | null;
role: "model" | "viewer" | "admin";
avatar: string | null;
banner: string | null;
email_verified: boolean;
date_created: Date;
}>("CurrentUser")
.implement({
// CurrentUser is the same shape as User
export const CurrentUserType = builder.objectRef<User>("CurrentUser").implement({
fields: (t) => ({
id: t.exposeString("id"),
email: t.exposeString("email"),
@@ -95,37 +75,27 @@ export const CurrentUserType = builder
email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }),
}),
});
});
// Video type
export const VideoType = builder
.objectRef<{
id: string;
slug: string;
title: string;
description: string | null;
image: string | null;
movie: string | null;
tags: string[] | null;
upload_date: Date;
premium: boolean | null;
featured: boolean | null;
likes_count: number | null;
plays_count: number | null;
models?: {
id: string;
artist_name: string | null;
slug: string | null;
avatar: string | null;
}[];
movie_file?: {
id: string;
filename: string;
mime_type: string | null;
duration: number | null;
} | null;
}>("Video")
.implement({
export const VideoModelType = builder.objectRef<VideoModel>("VideoModel").implement({
fields: (t) => ({
id: t.exposeString("id"),
artist_name: t.exposeString("artist_name", { nullable: true }),
slug: t.exposeString("slug", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
}),
});
export const VideoFileType = builder.objectRef<VideoFile>("VideoFile").implement({
fields: (t) => ({
id: t.exposeString("id"),
filename: t.exposeString("filename"),
mime_type: t.exposeString("mime_type", { nullable: true }),
duration: t.exposeInt("duration", { nullable: true }),
}),
});
export const VideoType = builder.objectRef<Video>("Video").implement({
fields: (t) => ({
id: t.exposeString("id"),
slug: t.exposeString("slug"),
@@ -142,54 +112,16 @@ export const VideoType = builder
models: t.expose("models", { type: [VideoModelType], nullable: true }),
movie_file: t.expose("movie_file", { type: VideoFileType, nullable: true }),
}),
});
});
export const VideoModelType = builder
.objectRef<{
id: string;
artist_name: string | null;
slug: string | null;
avatar: string | null;
}>("VideoModel")
.implement({
fields: (t) => ({
id: t.exposeString("id"),
artist_name: t.exposeString("artist_name", { nullable: true }),
slug: t.exposeString("slug", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
}),
});
export const VideoFileType = builder
.objectRef<{
id: string;
filename: string;
mime_type: string | null;
duration: number | null;
}>("VideoFile")
.implement({
export const ModelPhotoType = builder.objectRef<ModelPhoto>("ModelPhoto").implement({
fields: (t) => ({
id: t.exposeString("id"),
filename: t.exposeString("filename"),
mime_type: t.exposeString("mime_type", { nullable: true }),
duration: t.exposeInt("duration", { nullable: true }),
}),
});
});
// Model type (model profile, enriched user)
export const ModelType = builder
.objectRef<{
id: string;
slug: string | null;
artist_name: string | null;
description: string | null;
avatar: string | null;
banner: string | null;
tags: string[] | null;
date_created: Date;
photos?: { id: string; filename: string }[];
}>("Model")
.implement({
export const ModelType = builder.objectRef<Model>("Model").implement({
fields: (t) => ({
id: t.exposeString("id"),
slug: t.exposeString("slug", { nullable: true }),
@@ -201,41 +133,18 @@ export const ModelType = builder
date_created: t.expose("date_created", { type: "DateTime" }),
photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }),
}),
});
});
export const ModelPhotoType = builder
.objectRef<{
id: string;
filename: string;
}>("ModelPhoto")
.implement({
export const ArticleAuthorType = builder.objectRef<ArticleAuthor>("ArticleAuthor").implement({
fields: (t) => ({
id: t.exposeString("id"),
filename: t.exposeString("filename"),
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
}),
});
});
// Article type
export const ArticleType = builder
.objectRef<{
id: string;
slug: string;
title: string;
excerpt: string | null;
content: string | null;
image: string | null;
tags: string[] | null;
publish_date: Date;
category: string | null;
featured: boolean | null;
author?: {
first_name: string | null;
last_name: string | null;
avatar: string | null;
description: string | null;
} | null;
}>("Article")
.implement({
export const ArticleType = builder.objectRef<Article>("Article").implement({
fields: (t) => ({
id: t.exposeString("id"),
slug: t.exposeString("slug"),
@@ -249,44 +158,39 @@ export const ArticleType = builder
featured: t.exposeBoolean("featured", { nullable: true }),
author: t.expose("author", { type: ArticleAuthorType, nullable: true }),
}),
});
});
export const ArticleAuthorType = builder
.objectRef<{
first_name: string | null;
last_name: string | null;
avatar: string | null;
description: string | null;
}>("ArticleAuthor")
.implement({
export const CommentUserType = builder.objectRef<CommentUser>("CommentUser").implement({
fields: (t) => ({
id: t.exposeString("id"),
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
artist_name: t.exposeString("artist_name", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
}),
});
});
// Recording type
export const RecordingType = builder
.objectRef<{
id: string;
title: string;
description: string | null;
slug: string;
duration: number;
events: object[] | null;
device_info: object[] | null;
user_id: string;
status: string;
tags: string[] | null;
linked_video: string | null;
featured: boolean | null;
public: boolean | null;
date_created: Date;
date_updated: Date | null;
}>("Recording")
.implement({
export const CommentType = builder.objectRef<Comment>("Comment").implement({
fields: (t) => ({
id: t.exposeInt("id"),
collection: t.exposeString("collection"),
item_id: t.exposeString("item_id"),
comment: t.exposeString("comment"),
user_id: t.exposeString("user_id"),
date_created: t.expose("date_created", { type: "DateTime" }),
user: t.expose("user", { type: CommentUserType, nullable: true }),
}),
});
export const StatsType = builder.objectRef<Stats>("Stats").implement({
fields: (t) => ({
videos_count: t.exposeInt("videos_count"),
models_count: t.exposeInt("models_count"),
viewers_count: t.exposeInt("viewers_count"),
}),
});
export const RecordingType = builder.objectRef<Recording>("Recording").implement({
fields: (t) => ({
id: t.exposeString("id"),
title: t.exposeString("title"),
@@ -304,80 +208,62 @@ export const RecordingType = builder
date_created: t.expose("date_created", { type: "DateTime" }),
date_updated: t.expose("date_updated", { type: "DateTime", nullable: true }),
}),
});
});
// Comment type
export const CommentType = builder
.objectRef<{
id: number;
collection: string;
item_id: string;
comment: string;
user_id: string;
date_created: Date;
user?: {
id: string;
first_name: string | null;
last_name: string | null;
avatar: string | null;
} | null;
}>("Comment")
export const VideoLikeResponseType = builder
.objectRef<VideoLikeResponse>("VideoLikeResponse")
.implement({
fields: (t) => ({
id: t.exposeInt("id"),
collection: t.exposeString("collection"),
item_id: t.exposeString("item_id"),
comment: t.exposeString("comment"),
user_id: t.exposeString("user_id"),
date_created: t.expose("date_created", { type: "DateTime" }),
user: t.expose("user", { type: CommentUserType, nullable: true }),
liked: t.exposeBoolean("liked"),
likes_count: t.exposeInt("likes_count"),
}),
});
export const CommentUserType = builder
.objectRef<{
id: string;
first_name: string | null;
last_name: string | null;
avatar: string | null;
}>("CommentUser")
export const VideoPlayResponseType = builder
.objectRef<VideoPlayResponse>("VideoPlayResponse")
.implement({
fields: (t) => ({
success: t.exposeBoolean("success"),
play_id: t.exposeString("play_id"),
plays_count: t.exposeInt("plays_count"),
}),
});
export const VideoLikeStatusType = builder
.objectRef<VideoLikeStatus>("VideoLikeStatus")
.implement({
fields: (t) => ({
liked: t.exposeBoolean("liked"),
}),
});
export const VideoAnalyticsType = builder.objectRef<VideoAnalytics>("VideoAnalytics").implement({
fields: (t) => ({
id: t.exposeString("id"),
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
title: t.exposeString("title"),
slug: t.exposeString("slug"),
upload_date: t.expose("upload_date", { type: "DateTime" }),
likes: t.exposeInt("likes"),
plays: t.exposeInt("plays"),
completed_plays: t.exposeInt("completed_plays"),
completion_rate: t.exposeFloat("completion_rate"),
avg_watch_time: t.exposeInt("avg_watch_time"),
}),
});
});
// Stats type
export const StatsType = builder
.objectRef<{
videos_count: number;
models_count: number;
viewers_count: number;
}>("Stats")
.implement({
export const AnalyticsType = builder.objectRef<Analytics>("Analytics").implement({
fields: (t) => ({
videos_count: t.exposeInt("videos_count"),
models_count: t.exposeInt("models_count"),
viewers_count: t.exposeInt("viewers_count"),
total_videos: t.exposeInt("total_videos"),
total_likes: t.exposeInt("total_likes"),
total_plays: t.exposeInt("total_plays"),
plays_by_date: t.expose("plays_by_date", { type: "JSON" }),
likes_by_date: t.expose("likes_by_date", { type: "JSON" }),
videos: t.expose("videos", { type: [VideoAnalyticsType] }),
}),
});
});
// Gamification types
export const LeaderboardEntryType = builder
.objectRef<{
user_id: string;
display_name: string | null;
avatar: string | null;
total_weighted_points: number | null;
total_raw_points: number | null;
recordings_count: number | null;
playbacks_count: number | null;
achievements_count: number | null;
rank: number;
}>("LeaderboardEntry")
.objectRef<LeaderboardEntry>("LeaderboardEntry")
.implement({
fields: (t) => ({
user_id: t.exposeString("user_id"),
@@ -392,80 +278,7 @@ export const LeaderboardEntryType = builder
}),
});
export const AchievementType = builder
.objectRef<{
id: string;
code: string;
name: string;
description: string | null;
icon: string | null;
category: string | null;
required_count: number;
points_reward: number;
}>("Achievement")
.implement({
fields: (t) => ({
id: t.exposeString("id"),
code: t.exposeString("code"),
name: t.exposeString("name"),
description: t.exposeString("description", { nullable: true }),
icon: t.exposeString("icon", { nullable: true }),
category: t.exposeString("category", { nullable: true }),
required_count: t.exposeInt("required_count"),
points_reward: t.exposeInt("points_reward"),
}),
});
export const UserGamificationType = builder
.objectRef<{
stats: {
user_id: string;
total_raw_points: number | null;
total_weighted_points: number | null;
recordings_count: number | null;
playbacks_count: number | null;
comments_count: number | null;
achievements_count: number | null;
rank: number;
} | null;
achievements: {
id: string;
code: string;
name: string;
description: string | null;
icon: string | null;
category: string | null;
date_unlocked: Date;
progress: number | null;
required_count: number;
}[];
recent_points: {
action: string;
points: number;
date_created: Date;
recording_id: string | null;
}[];
}>("UserGamification")
.implement({
fields: (t) => ({
stats: t.expose("stats", { type: UserStatsType, nullable: true }),
achievements: t.expose("achievements", { type: [UserAchievementType] }),
recent_points: t.expose("recent_points", { type: [RecentPointType] }),
}),
});
export const UserStatsType = builder
.objectRef<{
user_id: string;
total_raw_points: number | null;
total_weighted_points: number | null;
recordings_count: number | null;
playbacks_count: number | null;
comments_count: number | null;
achievements_count: number | null;
rank: number;
}>("UserStats")
.implement({
export const UserStatsType = builder.objectRef<UserStats>("UserStats").implement({
fields: (t) => ({
user_id: t.exposeString("user_id"),
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
@@ -476,21 +289,9 @@ export const UserStatsType = builder
achievements_count: t.exposeInt("achievements_count", { nullable: true }),
rank: t.exposeInt("rank"),
}),
});
});
export const UserAchievementType = builder
.objectRef<{
id: string;
code: string;
name: string;
description: string | null;
icon: string | null;
category: string | null;
date_unlocked: Date;
progress: number | null;
required_count: number;
}>("UserAchievement")
.implement({
export const UserAchievementType = builder.objectRef<UserAchievement>("UserAchievement").implement({
fields: (t) => ({
id: t.exposeString("id"),
code: t.exposeString("code"),
@@ -502,114 +303,36 @@ export const UserAchievementType = builder
progress: t.exposeInt("progress", { nullable: true }),
required_count: t.exposeInt("required_count"),
}),
});
});
export const RecentPointType = builder
.objectRef<{
action: string;
points: number;
date_created: Date;
recording_id: string | null;
}>("RecentPoint")
.implement({
export const RecentPointType = builder.objectRef<RecentPoint>("RecentPoint").implement({
fields: (t) => ({
action: t.exposeString("action"),
points: t.exposeInt("points"),
date_created: t.expose("date_created", { type: "DateTime" }),
recording_id: t.exposeString("recording_id", { nullable: true }),
}),
});
});
// Analytics types
export const AnalyticsType = builder
.objectRef<{
total_videos: number;
total_likes: number;
total_plays: number;
plays_by_date: Record<string, number>;
likes_by_date: Record<string, number>;
videos: {
id: string;
title: string;
slug: string;
upload_date: Date;
likes: number;
plays: number;
completed_plays: number;
completion_rate: number;
avg_watch_time: number;
}[];
}>("Analytics")
export const UserGamificationType = builder
.objectRef<UserGamification>("UserGamification")
.implement({
fields: (t) => ({
total_videos: t.exposeInt("total_videos"),
total_likes: t.exposeInt("total_likes"),
total_plays: t.exposeInt("total_plays"),
plays_by_date: t.expose("plays_by_date", { type: "JSON" }),
likes_by_date: t.expose("likes_by_date", { type: "JSON" }),
videos: t.expose("videos", { type: [VideoAnalyticsType] }),
stats: t.expose("stats", { type: UserStatsType, nullable: true }),
achievements: t.expose("achievements", { type: [UserAchievementType] }),
recent_points: t.expose("recent_points", { type: [RecentPointType] }),
}),
});
export const VideoAnalyticsType = builder
.objectRef<{
id: string;
title: string;
slug: string;
upload_date: Date;
likes: number;
plays: number;
completed_plays: number;
completion_rate: number;
avg_watch_time: number;
}>("VideoAnalytics")
.implement({
export const AchievementType = builder.objectRef<Achievement>("Achievement").implement({
fields: (t) => ({
id: t.exposeString("id"),
title: t.exposeString("title"),
slug: t.exposeString("slug"),
upload_date: t.expose("upload_date", { type: "DateTime" }),
likes: t.exposeInt("likes"),
plays: t.exposeInt("plays"),
completed_plays: t.exposeInt("completed_plays"),
completion_rate: t.exposeFloat("completion_rate"),
avg_watch_time: t.exposeInt("avg_watch_time"),
code: t.exposeString("code"),
name: t.exposeString("name"),
description: t.exposeString("description", { nullable: true }),
icon: t.exposeString("icon", { nullable: true }),
category: t.exposeString("category", { nullable: true }),
required_count: t.exposeInt("required_count"),
points_reward: t.exposeInt("points_reward"),
}),
});
// Response types
export const VideoLikeResponseType = builder
.objectRef<{
liked: boolean;
likes_count: number;
}>("VideoLikeResponse")
.implement({
fields: (t) => ({
liked: t.exposeBoolean("liked"),
likes_count: t.exposeInt("likes_count"),
}),
});
export const VideoPlayResponseType = builder
.objectRef<{
success: boolean;
play_id: string;
plays_count: number;
}>("VideoPlayResponse")
.implement({
fields: (t) => ({
success: t.exposeBoolean("success"),
play_id: t.exposeString("play_id"),
plays_count: t.exposeInt("plays_count"),
}),
});
export const VideoLikeStatusType = builder
.objectRef<{
liked: boolean;
}>("VideoLikeStatus")
.implement({
fields: (t) => ({
liked: t.exposeBoolean("liked"),
}),
});
});

View File

@@ -13,17 +13,14 @@ import { schema } from "./graphql/index";
import { buildContext } from "./graphql/context";
import { db } from "./db/connection";
import { redis } from "./lib/auth";
import { logger } from "./lib/logger";
const PORT = parseInt(process.env.PORT || "4000");
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:3000";
async function main() {
const fastify = Fastify({
logger: {
level: process.env.LOG_LEVEL || "info",
},
});
const fastify = Fastify({ loggerInstance: logger });
await fastify.register(fastifyCookie, {
secret: process.env.COOKIE_SECRET || "change-me-in-production",

View File

@@ -0,0 +1,91 @@
type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
const LEVEL_VALUES: Record<LogLevel, number> = {
trace: 10,
debug: 20,
info: 30,
warn: 40,
error: 50,
fatal: 60,
};
function createLogger(bindings: Record<string, unknown> = {}, initialLevel: LogLevel = "info") {
let currentLevel = initialLevel;
function shouldLog(level: LogLevel): boolean {
return LEVEL_VALUES[level] >= LEVEL_VALUES[currentLevel];
}
function formatMessage(level: LogLevel, arg: unknown, msg?: string): string {
const timestamp = new Date().toISOString();
let message: string;
const meta: Record<string, unknown> = { ...bindings };
if (typeof arg === "string") {
message = arg;
} else if (arg !== null && typeof arg === "object") {
// Pino-style: log(obj, msg?) — strip internal pino keys
const { msg: m, level: _l, time: _t, pid: _p, hostname: _h, req: _req, res: _res, reqId, ...rest } = arg as Record<string, unknown>;
message = msg || (typeof m === "string" ? m : "");
if (reqId) meta.reqId = reqId;
Object.assign(meta, rest);
} else {
message = String(arg ?? "");
}
const parts = [`[${timestamp}]`, `[${level.toUpperCase()}]`, message];
let result = parts.join(" ");
const metaEntries = Object.entries(meta).filter(([k]) => k !== "reqId");
const reqId = meta.reqId;
if (reqId) result = `[${timestamp}] [${level.toUpperCase()}] [${reqId}] ${message}`;
if (metaEntries.length > 0) {
result += " " + JSON.stringify(Object.fromEntries(metaEntries));
}
return result;
}
function write(level: LogLevel, arg: unknown, msg?: string) {
if (!shouldLog(level)) return;
const formatted = formatMessage(level, arg, msg);
switch (level) {
case "trace":
case "debug":
console.debug(formatted);
break;
case "info":
console.info(formatted);
break;
case "warn":
console.warn(formatted);
break;
case "error":
case "fatal":
console.error(formatted);
break;
}
}
return {
get level() {
return currentLevel;
},
set level(l: string) {
currentLevel = l as LogLevel;
},
trace: (arg: unknown, msg?: string) => write("trace", arg, msg),
debug: (arg: unknown, msg?: string) => write("debug", arg, msg),
info: (arg: unknown, msg?: string) => write("info", arg, msg),
warn: (arg: unknown, msg?: string) => write("warn", arg, msg),
error: (arg: unknown, msg?: string) => write("error", arg, msg),
fatal: (arg: unknown, msg?: string) => write("fatal", arg, msg),
silent: () => {},
child: (newBindings: Record<string, unknown>) =>
createLogger({ ...bindings, ...newBindings }, currentLevel),
};
}
export const logger = createLogger({}, (process.env.LOG_LEVEL as LogLevel) || "info");

View File

@@ -43,6 +43,7 @@
},
"dependencies": {
"@sexy.pivoine.art/buttplug": "workspace:*",
"@sexy.pivoine.art/types": "workspace:*",
"graphql": "^16.11.0",
"graphql-request": "^7.1.2",
"javascript-time-ago": "^2.6.4",

View File

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

View File

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

View File

@@ -5,13 +5,12 @@
import type { AuthStatus } from "$lib/types";
import { logout } from "$lib/services";
import { goto } from "$app/navigation";
import { getAssetUrl } from "$lib/directus";
import { getAssetUrl } from "$lib/api";
import LogoutButton from "../logout-button/logout-button.svelte";
import Separator from "../ui/separator/separator.svelte";
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
import Girls from "../girls/girls.svelte";
import Logo from "../logo/logo.svelte";
interface Props {
@@ -78,24 +77,14 @@
{/each}
</nav>
<!-- Desktop Login Button -->
<!-- Desktop Auth Actions -->
{#if authStatus.authenticated}
<div class="w-full flex items-center justify-end">
<div class="w-full hidden lg:flex items-center justify-end">
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
<!-- Notifications -->
<!-- <Button variant="ghost" size="sm" class="relative h-9 w-9 rounded-full p-0 hover:bg-background/80">
<BellIcon class="h-4 w-4" />
<Badge class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-gradient-to-r from-primary to-accent p-0 text-xs text-primary-foreground">3</Badge>
<span class="sr-only">Notifications</span>
</Button> -->
<!-- <Separator orientation="vertical" class="mx-1 h-6 bg-border/50" /> -->
<!-- User Actions -->
<Button
variant="link"
size="icon"
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "text-foreground" : "hover:text-foreground"}`}
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "text-foreground" : "hover:text-foreground"}`}
href="/me"
title={$_("header.dashboard")}
>
@@ -109,7 +98,7 @@
<Button
variant="link"
size="icon"
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
href="/play"
title={$_("header.play")}
>
@@ -120,15 +109,13 @@
<span class="sr-only">{$_("header.play")}</span>
</Button>
<Separator orientation="vertical" class="hidden md:flex mx-1 h-6 bg-border/50" />
<!-- Slide Logout Button -->
<Separator orientation="vertical" class="mx-1 h-6 bg-border/50" />
<LogoutButton
user={{
name:
authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
avatar: getAssetUrl(authStatus.user!.avatar?.id, "mini")!,
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
email: authStatus.user!.email,
}}
onLogout={handleLogout}
@@ -136,7 +123,7 @@
</div>
</div>
{:else}
<div class="flex w-full items-center justify-end gap-4">
<div class="hidden lg:flex w-full items-center justify-end gap-4">
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button>
<Button
href="/signup"
@@ -145,6 +132,9 @@
>
</div>
{/if}
<!-- Burger button — mobile/tablet only -->
<div class="lg:hidden ml-auto">
<BurgerMenuButton
label={$_("header.navigation")}
bind:isMobileMenuOpen
@@ -152,26 +142,38 @@
/>
</div>
</div>
<!-- Mobile Navigation -->
<div
class={`border-t border-border/20 bg-background/95 bg-gradient-to-br from-primary to-accent backdrop-blur-xl max-h-[calc(100vh-4rem)] overflow-y-auto shadow-xl/30 transition-all duration-250 ${isMobileMenuOpen ? "opacity-100" : "opacity-0"}`}
>
{#if isMobileMenuOpen}
<div class="container mx-auto grid grid-cols-1 lg:grid-cols-3">
<div class="hidden lg:flex col-span-2">
<Girls />
</div>
<div class="py-6 px-4 space-y-6 lg:col-start-3 border-t border-border/20 bg-background/95">
</header>
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
role="presentation"
class={`fixed inset-0 z-40 bg-black/60 backdrop-blur-sm transition-opacity duration-300 lg:hidden ${isMobileMenuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
onclick={closeMenu}
></div>
<!-- Flyout panel -->
<div
class={`fixed inset-y-0 left-0 z-50 w-80 max-w-[85vw] bg-card/95 backdrop-blur-xl shadow-2xl shadow-primary/20 border-r border-border/30 transform transition-transform duration-300 ease-in-out lg:hidden overflow-y-auto flex flex-col ${isMobileMenuOpen ? "translate-x-0" : "-translate-x-full"}`}
aria-hidden={!isMobileMenuOpen}
>
<!-- Panel header -->
<div class="flex items-center px-5 py-4 border-b border-border/30">
<Logo hideName={true} />
</div>
<div class="flex-1 py-6 px-5 space-y-6">
<!-- User Profile Card -->
{#if authStatus.authenticated}
<div
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4 backdrop-blur-sm"
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4"
>
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"></div>
<div class="relative flex items-center gap-4">
<Avatar class="h-14 w-14 ring-2 ring-primary/30">
<div class="relative flex items-center gap-3">
<Avatar class="h-12 w-12 ring-2 ring-primary/30">
<AvatarImage
src={getAssetUrl(authStatus.user!.avatar?.id, "mini")}
src={getAssetUrl(authStatus.user!.avatar, "mini")}
alt={authStatus.user!.artist_name}
/>
<AvatarFallback
@@ -180,184 +182,146 @@
{getUserInitials(authStatus.user!.artist_name)}
</AvatarFallback>
</Avatar>
<div class="flex flex-1 flex-col gap-1">
<p class="text-base font-semibold text-foreground">
{authStatus.user!.artist_name}
<div class="flex flex-1 flex-col gap-0.5 min-w-0">
<p class="text-sm font-semibold text-foreground truncate">
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
</p>
<p class="text-sm text-muted-foreground">
<p class="text-xs text-muted-foreground truncate">
{authStatus.user!.email}
</p>
<div class="flex items-center gap-2 mt-1">
<div class="h-2 w-2 rounded-full bg-green-500"></div>
<div class="flex items-center gap-1.5 mt-0.5">
<div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
<span class="text-xs text-muted-foreground">Online</span>
</div>
</div>
<!-- Notifications Badge -->
<!-- <Button
variant="ghost"
size="sm"
class="relative h-10 w-10 rounded-full p-0"
>
<BellIcon class="h-4 w-4" />
<Badge
class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-gradient-to-r from-primary to-accent p-0 text-xs text-primary-foreground"
>3</Badge
>
</Button> -->
</div>
</div>
{/if}
<!-- Navigation Cards -->
<div class="space-y-3">
<!-- Navigation -->
<div class="space-y-2">
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{$_("header.navigation")}
</h3>
<div class="grid gap-2">
<div class="grid gap-1.5">
{#each navLinks as link (link.href)}
<a
href={link.href}
class="flex items-center justify-between rounded-xl border border-border/50 bg-card/50 p-4 backdrop-blur-sm transition-all hover:bg-card hover:border-primary/20 {isActiveLink(
link,
)
? 'border-primary/30 bg-primary/5'
: ''}"
onclick={() => (isMobileMenuOpen = false)}
class={`flex items-center justify-between rounded-xl border px-4 py-3 transition-all duration-200 hover:border-primary/30 hover:bg-primary/5 ${
isActiveLink(link)
? "border-primary/40 bg-primary/8 text-foreground"
: "border-border/40 bg-card/50 text-foreground/85"
}`}
onclick={closeMenu}
>
<span class="font-medium text-foreground">{link.name}</span>
<div class="flex items-center gap-2">
<!-- {#if isActiveLink(link)}
<div class="h-2 w-2 rounded-full bg-primary"></div>
{/if} -->
<span class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground"
></span>
</div>
<span class="font-medium text-sm">{link.name}</span>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
{/each}
</div>
</div>
<!-- Account Actions -->
<div class="space-y-3">
<!-- Account -->
<div class="space-y-2">
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{$_("header.account")}
</h3>
<div class="grid gap-2">
<div class="grid gap-1.5">
{#if authStatus.authenticated}
<a
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/me" }) ? "border-primary/30 bg-primary/5" : ""}`}
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/me" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
href="/me"
onclick={closeMenu}
>
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10"
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
>
<span
class="icon-[ri--dashboard-2-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
class="icon-[ri--dashboard-2-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground">{$_("header.dashboard")}</span>
<div class="flex flex-1 flex-col gap-0.5">
<span class="text-sm font-medium text-foreground">{$_("header.dashboard")}</span>
<span class="text-xs text-muted-foreground">{$_("header.dashboard_hint")}</span>
</div>
<span class="text-sm text-muted-foreground">{$_("header.dashboard_hint")}</span>
</div>
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
></span>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
<a
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/play" }) ? "border-primary/30 bg-primary/5" : ""}`}
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/play" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
href="/play"
onclick={closeMenu}
>
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10"
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
>
<span
class="icon-[ri--rocket-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
class="icon-[ri--rocket-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground">{$_("header.play")}</span>
<div class="flex flex-1 flex-col gap-0.5">
<span class="text-sm font-medium text-foreground">{$_("header.play")}</span>
<span class="text-xs text-muted-foreground">{$_("header.play_hint")}</span>
</div>
<span class="text-sm text-muted-foreground">{$_("header.play_hint")}</span>
</div>
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
></span>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
{:else}
<a
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/login" }) ? "border-primary/30 bg-primary/5" : ""}`}
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/login" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
href="/login"
onclick={closeMenu}
>
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10"
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
>
<span
class="icon-[ri--login-circle-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
class="icon-[ri--login-circle-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground">{$_("header.login")}</span>
<div class="flex flex-1 flex-col gap-0.5">
<span class="text-sm font-medium text-foreground">{$_("header.login")}</span>
<span class="text-xs text-muted-foreground">{$_("header.login_hint")}</span>
</div>
<span class="text-sm text-muted-foreground">{$_("header.login_hint")}</span>
</div>
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
></span>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
<a
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/signup" }) ? "border-primary/30 bg-primary/5" : ""}`}
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/signup" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
href="/signup"
onclick={closeMenu}
>
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10"
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-accent/10 transition-colors"
>
<span
class="icon-[ri--heart-add-2-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
class="icon-[ri--heart-add-2-line] h-4 w-4 text-muted-foreground group-hover:text-accent transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground">{$_("header.signup")}</span>
<div class="flex flex-1 flex-col gap-0.5">
<span class="text-sm font-medium text-foreground">{$_("header.signup")}</span>
<span class="text-xs text-muted-foreground">{$_("header.signup_hint")}</span>
</div>
<span class="text-sm text-muted-foreground">{$_("header.signup_hint")}</span>
</div>
<span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
></span>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
{/if}
</div>
</div>
{#if authStatus.authenticated}
<!-- Logout Button -->
<button
class="cursor-pointer flex w-full items-center gap-4 rounded-xl border border-destructive/20 bg-destructive/5 p-4 text-left backdrop-blur-sm transition-all hover:bg-destructive/10 hover:border-destructive/30 group"
class="cursor-pointer flex w-full items-center gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 transition-all duration-200 hover:bg-destructive/10 hover:border-destructive/30 group"
onclick={handleLogout}
>
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-destructive/10 group-hover:bg-destructive/20 transition-all"
class="flex h-8 w-8 items-center justify-center rounded-lg bg-destructive/10 group-hover:bg-destructive/20 transition-colors"
>
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"></span>
</div>
<div class="flex flex-1 flex-col gap-1">
<span class="font-medium text-foreground">{$_("header.logout")}</span>
<span class="text-sm text-muted-foreground">{$_("header.logout_hint")}</span>
<div class="flex flex-1 flex-col gap-0.5 text-left">
<span class="text-sm font-medium text-foreground">{$_("header.logout")}</span>
<span class="text-xs text-muted-foreground">{$_("header.logout_hint")}</span>
</div>
</button>
{/if}
</div>
</div>
{/if}
</div>
</header>
</div>

View File

@@ -4,7 +4,7 @@
interface Props {
title: string;
description: string;
description: string | null | undefined;
image?: string;
}

View File

@@ -2,7 +2,7 @@
import { _ } from "svelte-i18n";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import type { Recording } from "$lib/types";
import type { Recording, DeviceInfo } from "$lib/types";
import { cn } from "$lib/utils";
interface Props {
@@ -68,18 +68,18 @@
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
<span class="icon-[ri--pulse-line] w-4 h-4 text-accent mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span>
<span class="font-medium text-sm">{recording.events.length}</span>
<span class="font-medium text-sm">{recording.events?.length ?? 0}</span>
</div>
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
<span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
<span class="font-medium text-sm">{recording.device_info.length}</span>
<span class="font-medium text-sm">{recording.device_info?.length ?? 0}</span>
</div>
</div>
<!-- Device Info -->
<div class="space-y-1">
{#each recording.device_info.slice(0, 2) as device (device.name)}
{#each ((recording.device_info ?? []) as DeviceInfo[]).slice(0, 2) as device (device.name)}
<div
class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1"
>
@@ -88,9 +88,9 @@
<span class="text-xs opacity-60">{device.capabilities.join(", ")}</span>
</div>
{/each}
{#if recording.device_info.length > 2}
{#if (recording.device_info?.length ?? 0) > 2}
<div class="text-xs text-muted-foreground/60 px-2">
+{recording.device_info.length - 2} more device{recording.device_info.length - 2 > 1
+{(recording.device_info?.length ?? 0) - 2} more device{(recording.device_info?.length ?? 0) - 2 > 1
? "s"
: ""}
</div>

View File

@@ -1,8 +0,0 @@
// Re-export from api.ts for backwards compatibility
// All components that import from $lib/directus continue to work
export {
apiUrl as directusApiUrl,
getAssetUrl,
isModel,
getGraphQLClient as getDirectusInstance,
} from "./api.js";

View File

@@ -510,7 +510,7 @@ const UPDATE_PROFILE_MUTATION = gql`
}
`;
export async function updateProfile(user: Partial<User>) {
export async function updateProfile(user: Partial<User> & { password?: string }) {
return loggedApiCall(
"updateProfile",
async () => {
@@ -551,7 +551,7 @@ export async function getStats(fetchFn?: typeof globalThis.fetch) {
// Stub — Directus folder concept dropped
export async function getFolders(_fetchFn?: typeof globalThis.fetch) {
return loggedApiCall("getFolders", async () => []);
return loggedApiCall("getFolders", async () => [] as { id: string; name: string }[]);
}
// ─── Files ───────────────────────────────────────────────────────────────────
@@ -618,6 +618,7 @@ export async function getCommentsForVideo(item: string, fetchFn?: typeof globalT
id: string;
first_name: string | null;
last_name: string | null;
artist_name: string | null;
avatar: string | null;
} | null;
}[];

View File

@@ -1,24 +1,38 @@
import { type ButtplugClientDevice } from "@sexy.pivoine.art/buttplug";
export type {
MediaFile,
User,
CurrentUser,
VideoModel,
VideoFile,
Video,
ModelPhoto,
Model,
ArticleAuthor,
Article,
CommentUser,
Comment,
Stats,
RecordedEvent,
DeviceInfo,
Recording,
VideoLikeStatus,
VideoPlayRecord,
VideoLikeResponse,
VideoPlayResponse,
VideoAnalytics,
Analytics,
LeaderboardEntry,
UserStats,
UserAchievement,
RecentPoint,
UserGamification,
Achievement,
} from "@sexy.pivoine.art/types";
export interface User {
id: string;
first_name: string;
last_name: string;
artist_name: string;
slug: string;
email: string;
description: string;
tags: string[];
avatar: string | File;
password: string;
directus_users_id?: User;
}
import type { CurrentUser } from "@sexy.pivoine.art/types";
import type { ButtplugClientDevice } from "@sexy.pivoine.art/buttplug";
export interface CurrentUser extends User {
avatar: File;
role: "model" | "viewer" | "admin";
policies: string[];
}
// ─── Frontend-only types ─────────────────────────────────────────────────────
export interface AuthStatus {
authenticated: boolean;
@@ -28,78 +42,11 @@ export interface AuthStatus {
};
}
export interface File {
id: string;
filesize: number;
export interface ShareContent {
title: string;
description: string;
duration: number;
directus_files_id?: File;
}
export interface Article {
id: string;
slug: string;
title: string;
excerpt: string;
content: string;
image: string;
tags: string[];
publish_date: Date;
author: {
first_name: string;
last_name: string;
avatar: string;
description?: string;
website?: string;
};
category: string;
featured?: boolean;
}
export interface Model {
id: string;
slug: string;
artist_name: string;
description: string;
avatar: string;
category: string;
tags: string[];
join_date: Date;
featured?: boolean;
photos: File[];
banner?: File;
}
export interface Video {
id: string;
slug: string;
title: string;
description: string;
image: string;
movie: File;
models: User[];
tags: string[];
upload_date: Date;
premium?: boolean;
featured?: boolean;
likes_count?: number;
plays_count?: number;
views_count?: number;
}
export interface Comment {
id: string;
comment: string;
item: string;
user_created: User;
date_created: Date;
}
export interface Stats {
videos_count: number;
models_count: number;
viewers_count: number;
url: string;
type: "video" | "model" | "article" | "link";
}
export interface DeviceActuator {
@@ -120,86 +67,3 @@ export interface BluetoothDevice {
lastSeen: Date;
info: ButtplugClientDevice;
}
export interface ShareContent {
title: string;
description: string;
url: string;
type: "video" | "model" | "article" | "link";
}
export interface RecordedEvent {
timestamp: number;
deviceIndex: number;
deviceName: string;
actuatorIndex: number;
actuatorType: string;
value: number;
}
export interface DeviceInfo {
name: string;
index: number;
capabilities: string[];
}
export interface Recording {
id: string;
title: string;
description?: string;
slug: string;
duration: number;
events: RecordedEvent[];
device_info: DeviceInfo[];
user_created: string | User;
date_created: Date;
date_updated?: Date;
status: "draft" | "published" | "archived";
tags?: string[];
linked_video?: string | Video;
featured?: boolean;
public?: boolean;
}
export interface VideoLikeStatus {
liked: boolean;
}
export interface VideoPlayRecord {
id: string;
video_id: string;
duration_watched?: number;
completed: boolean;
}
export interface VideoLikeResponse {
liked: boolean;
likes_count: number;
}
export interface VideoPlayResponse {
success: boolean;
play_id: string;
plays_count: number;
}
export interface VideoAnalytics {
id: string;
title: string;
slug: string;
upload_date: Date;
likes: number;
plays: number;
completed_plays: number;
completion_rate: number;
avg_watch_time: number;
}
export interface Analytics {
total_videos: number;
total_likes: number;
total_plays: number;
plays_by_date: Record<string, number>;
likes_by_date: Record<string, number>;
videos: VideoAnalytics[];
}

View File

@@ -14,16 +14,16 @@ export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
ref?: U | null;
};
export const calcReadingTime = (text: string) => {
export const calcReadingTime = (text: string | null | undefined) => {
const wordsPerMinute = 200; // Average case.
const textLength = text.split(" ").length; // Split by words
const textLength = (text ?? "").split(" ").length; // Split by words
if (textLength > 0) {
return Math.ceil(textLength / wordsPerMinute);
}
return 0;
};
export const getUserInitials = (name: string) => {
export const getUserInitials = (name: string | null | undefined) => {
if (!name) return "??";
return name
.split(" ")

View File

@@ -2,7 +2,7 @@
import { _ } from "svelte-i18n";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { getAssetUrl } from "$lib/directus";
import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte";
import { formatVideoDuration } from "$lib/utils.js";

View File

@@ -3,15 +3,15 @@
import { Button } from "$lib/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
import { getAssetUrl } from "$lib/directus";
import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
const { data } = $props();
// Format points with comma separator
function formatPoints(points: number): string {
return Math.round(points).toLocaleString($locale || "en");
function formatPoints(points: number | null | undefined): string {
return Math.round(points ?? 0).toLocaleString($locale || "en");
}
// Get medal emoji for top 3
@@ -29,7 +29,7 @@
}
// Get user initials
function getUserInitials(name: string): string {
function getUserInitials(name: string | null | undefined): string {
if (!name) return "?";
const parts = name.split(" ");
if (parts.length >= 2) {

View File

@@ -8,7 +8,7 @@
import TimeAgo from "javascript-time-ago";
import type { Article } from "$lib/types";
import { getAssetUrl } from "$lib/directus";
import { getAssetUrl } from "$lib/api";
import { calcReadingTime } from "$lib/utils.js";
import Meta from "$lib/components/meta/meta.svelte";
@@ -26,8 +26,8 @@
.filter((article) => {
const matchesSearch =
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.excerpt.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.author.first_name.toLowerCase().includes(searchQuery.toLowerCase());
article.excerpt?.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.author?.first_name?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = categoryFilter === "all" || article.category === categoryFilter;
return matchesSearch && matchesCategory;
})
@@ -189,12 +189,12 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<img
src={getAssetUrl(featuredArticle.author.avatar, "mini")}
alt={featuredArticle.author.first_name}
src={getAssetUrl(featuredArticle.author?.avatar, "mini")}
alt={featuredArticle.author?.first_name}
class="w-10 h-10 rounded-full object-cover"
/>
<div>
<p class="font-medium">{featuredArticle.author.first_name}</p>
<p class="font-medium">{featuredArticle.author?.first_name}</p>
<div class="flex items-center gap-3 text-sm text-muted-foreground">
<span>{timeAgo.format(new Date(featuredArticle.publish_date))}</span>
<span></span>
@@ -273,7 +273,7 @@
<!-- Tags -->
<div class="flex flex-wrap gap-2 mb-4">
{#each article.tags.slice(0, 3) as tag (tag)}
{#each (article.tags ?? []).slice(0, 3) as tag (tag)}
<a
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
href="/tags/{tag}"
@@ -287,12 +287,12 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<img
src={getAssetUrl(article.author.avatar, "mini")}
alt={article.author.first_name}
src={getAssetUrl(article.author?.avatar, "mini")}
alt={article.author?.first_name}
class="w-8 h-8 rounded-full object-cover"
/>
<div>
<p class="text-sm font-medium">{article.author.first_name}</p>
<p class="text-sm font-medium">{article.author?.first_name}</p>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
{timeAgo.format(new Date(article.publish_date))}

View File

@@ -5,7 +5,7 @@
import { Card, CardContent } from "$lib/components/ui/card";
import { calcReadingTime } from "$lib/utils";
import TimeAgo from "javascript-time-ago";
import { getAssetUrl } from "$lib/directus";
import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
@@ -139,6 +139,7 @@
</div>
<!-- Author Bio -->
{#if data.article.author}
<Card class="p-0 bg-gradient-to-r from-card/50 to-card">
<CardContent class="p-6">
<div class="flex items-start gap-4">
@@ -164,15 +165,13 @@
>
{data.article.author.website}
</a>
<!-- <a href="https://{data.article.author.social.website}" class="text-primary hover:underline">
{data.article.author.social.website}
</a> -->
</div>
{/if}
</div>
</div>
</CardContent>
</Card>
{/if}
</article>
<!-- Sidebar -->

View File

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

View File

@@ -14,7 +14,7 @@
import PeonyBackground from "$lib/components/background/peony-background.svelte";
import { onMount } from "svelte";
import { goto, invalidateAll } from "$app/navigation";
import { getAssetUrl, isModel } from "$lib/directus";
import { getAssetUrl, isModel } from "$lib/api";
import * as Alert from "$lib/components/ui/alert";
import { toast } from "svelte-sonner";
import { deleteRecording, removeFile, updateProfile, uploadFile } from "$lib/services";
@@ -34,7 +34,7 @@
let lastName = $state(data.authStatus.user!.last_name);
let artistName = $state(data.authStatus.user!.artist_name);
let description = $state(data.authStatus.user!.description);
let tags = $state(data.authStatus.user!.tags);
let tags = $state(data.authStatus.user!.tags ?? undefined);
let email = $state(data.authStatus.user!.email);
let password = $state("");
@@ -60,8 +60,8 @@
let avatarId = undefined;
if (!avatar?.id && data.authStatus.user!.avatar?.id) {
await removeFile(data.authStatus.user!.avatar.id);
if (!avatar?.id && data.authStatus.user!.avatar) {
await removeFile(data.authStatus.user!.avatar);
}
if (avatar?.file) {
@@ -143,10 +143,10 @@
function setExistingAvatar() {
if (data.authStatus.user!.avatar) {
avatar = {
id: data.authStatus.user!.avatar.id,
url: getAssetUrl(data.authStatus.user!.avatar.id, "mini")!,
name: data.authStatus.user!.artist_name,
size: data.authStatus.user!.avatar.filesize,
id: data.authStatus.user!.avatar,
url: getAssetUrl(data.authStatus.user!.avatar, "mini")!,
name: data.authStatus.user!.artist_name ?? "",
size: 0,
};
} else {
avatar = undefined;

View File

@@ -4,7 +4,7 @@
import { Card, CardContent } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import { getAssetUrl } from "$lib/directus";
import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte";
let searchQuery = $state("");
@@ -18,9 +18,9 @@
.filter((model) => {
const matchesSearch =
searchQuery === "" ||
model.artist_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesCategory = categoryFilter === "all" || model.category === categoryFilter;
model.artist_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.tags?.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesCategory = categoryFilter === "all";
return matchesSearch && matchesCategory;
})
.sort((a, b) => {
@@ -31,7 +31,7 @@
// }
// if (sortBy === "rating") return b.rating - a.rating;
// if (sortBy === "videos") return b.videos - a.videos;
return a.artist_name.localeCompare(b.artist_name);
return (a.artist_name ?? "").localeCompare(b.artist_name ?? "");
});
});
</script>
@@ -205,7 +205,7 @@
<!-- Stats -->
<div class="flex items-center justify-between text-sm text-muted-foreground mb-4">
<!-- <span>{model.videos} videos</span> -->
<span class="capitalize">{model.category}</span>
<!-- category not available -->
</div>
<!-- Action Buttons -->

View File

@@ -3,7 +3,7 @@
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs";
import { getAssetUrl } from "$lib/directus";
import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
@@ -16,7 +16,7 @@
const { data } = $props();
let images = $derived(
data.model.photos.map((p) => ({
(data.model.photos ?? []).map((p) => ({
...p,
url: getAssetUrl(p.id),
thumbnail: getAssetUrl(p.id, "thumbnail"),
@@ -29,7 +29,7 @@
</script>
<Meta
title={data.model.artist_name}
title={data.model.artist_name ?? ""}
description={data.model.description}
image={getAssetUrl(data.model.avatar, "medium")!}
/>
@@ -44,7 +44,7 @@
{#if data.model.banner}
<img
src={getAssetUrl(data.model.banner, "banner")}
alt={$_(data.model.artist_name)}
alt={data.model.artist_name ?? ""}
class="w-full h-full object-cover"
/>
<div

View File

@@ -1,9 +1,10 @@
import { getRecording } from "$lib/services";
import type { Recording } from "$lib/types";
export async function load({ locals, url, fetch }) {
const recordingId = url.searchParams.get("recording");
let recording = null;
let recording: Recording | null = null;
if (recordingId && locals.authStatus.authenticated) {
try {
recording = await getRecording(recordingId, fetch);

View File

@@ -9,7 +9,6 @@
InputType,
DeviceOutputValueConstructor,
} from "@sexy.pivoine.art/buttplug";
import type { ButtplugMessage } from "@sexy.pivoine.art/buttplug";
import Button from "$lib/components/ui/button/button.svelte";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
@@ -74,7 +73,8 @@
}
}
function handleInputReading(msg: ButtplugMessage) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleInputReading(msg: any) {
if (msg.InputReading === undefined) return;
const reading = msg.InputReading;
const device = devices.find((d) => d.info.index === reading.DeviceIndex);
@@ -92,7 +92,7 @@
if (!feature) return;
actuator.value = value;
const outputType = actuator.outputType as OutputType;
const outputType = actuator.outputType as typeof OutputType;
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
// Capture event if recording
@@ -127,10 +127,10 @@
recordedEvents.push({
timestamp,
deviceIndex: device.info.index,
deviceName: device.name,
actuatorIndex: actuatorIdx,
actuatorType: actuator.outputType,
device_index: device.info.index,
device_name: device.name,
actuator_index: actuatorIdx,
actuator_type: actuator.outputType,
value: (value / actuator.maxSteps) * 100, // Normalize to 0-100
});
}
@@ -225,7 +225,7 @@
}
// Check if we need to map devices
if (deviceMappings.size === 0 && data.recording.device_info.length > 0) {
if (deviceMappings.size === 0 && (data.recording.device_info?.length ?? 0) > 0) {
showMappingDialog = true;
return;
}
@@ -284,7 +284,7 @@
function scheduleNextEvent() {
if (!data.recording || !isPlaying || !playbackStartTime) return;
const events = data.recording.events;
const events = (data.recording.events ?? []) as RecordedEvent[];
if (currentEventIndex >= events.length) {
stopPlayback();
toast.success("Playback finished");
@@ -313,16 +313,16 @@
function executeEvent(event: RecordedEvent) {
// Get mapped device
const device = deviceMappings.get(event.deviceName);
const device = deviceMappings.get(event.device_name);
if (!device) {
console.warn(`No device mapping for: ${event.deviceName}`);
console.warn(`No device mapping for: ${event.device_name}`);
return;
}
// Find matching actuator by type
const actuator = device.actuators.find((a) => a.outputType === event.actuatorType);
const actuator = device.actuators.find((a) => a.outputType === event.actuator_type);
if (!actuator) {
console.warn(`Actuator type ${event.actuatorType} not found on ${device.name}`);
console.warn(`Actuator type ${event.actuator_type} not found on ${device.name}`);
return;
}
@@ -332,7 +332,7 @@
// Send command to device via feature
const feature = device.info.features.get(actuator.featureIndex);
if (feature) {
const outputType = actuator.outputType as OutputType;
const outputType = actuator.outputType as typeof OutputType;
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
}
@@ -347,9 +347,10 @@
playbackProgress = targetTime;
// Find the event index at this time
currentEventIndex = data.recording.events.findIndex((e) => e.timestamp >= targetTime);
const seekEvents = (data.recording.events ?? []) as RecordedEvent[];
currentEventIndex = seekEvents.findIndex((e) => e.timestamp >= targetTime);
if (currentEventIndex === -1) {
currentEventIndex = data.recording.events.length;
currentEventIndex = seekEvents.length;
}
if (isPlaying) {
@@ -548,11 +549,11 @@
<div class="mt-4 pt-4 border-t border-border/50 grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-xs text-muted-foreground">Events</p>
<p class="text-sm font-medium">{data.recording.events.length}</p>
<p class="text-sm font-medium">{data.recording.events?.length ?? 0}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Devices</p>
<p class="text-sm font-medium">{data.recording.device_info.length}</p>
<p class="text-sm font-medium">{data.recording.device_info?.length ?? 0}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Status</p>
@@ -603,7 +604,7 @@
{#if data.recording}
<DeviceMappingDialog
open={showMappingDialog}
recordedDevices={data.recording.device_info}
recordedDevices={(data.recording.device_info ?? []) as DeviceInfo[]}
connectedDevices={devices}
onConfirm={handleMappingConfirm}
onCancel={handleMappingCancel}

View File

@@ -7,7 +7,7 @@ export const GET = async () => {
excludeRoutePatterns: ["^/signup/verify", "^/password/reset", "^/me", "^/play", "^/tags/.+"],
paramValues: {
"/magazine/[slug]": (await getArticles(fetch)).map((a) => a.slug),
"/models/[slug]": (await getModels(fetch)).map((a) => a.slug),
"/models/[slug]": (await getModels(fetch)).map((a) => a.slug).filter((s): s is string => s !== null),
"/videos/[slug]": (await getVideos(fetch)).map((a) => a.slug),
},
defaultChangefreq: "always",

View File

@@ -4,7 +4,7 @@ import { getItemsByTag } from "$lib/services";
const getItems = (category, tag: string, fetch) => {
return getItemsByTag(category, fetch).then((items) =>
items
?.filter((i) => i.tags.includes(tag))
?.filter((i) => i.tags?.includes(tag))
.map((i) => ({ ...i, category, title: i["artist_name"] || i["title"] })),
);
};

View File

@@ -4,7 +4,7 @@
import { Card, CardContent } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import { getAssetUrl } from "$lib/directus";
import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte";
let searchQuery = $state("");

View File

@@ -77,8 +77,23 @@ export const load: PageServerLoad = async ({ params, locals, fetch }) => {
achievements_count: number | null;
rank: number;
} | null;
achievements: unknown[];
recent_points: unknown[];
achievements: {
id: string;
code: string;
name: string;
description: string | null;
icon: string | null;
category: string | null;
date_unlocked: string;
progress: number | null;
required_count: number;
}[];
recent_points: {
action: string;
points: number;
date_created: string;
recording_id: string | null;
}[];
} | null;
}>(USER_PROFILE_QUERY, { id });

View File

@@ -2,7 +2,7 @@
import { _, locale } from "svelte-i18n";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { getAssetUrl } from "$lib/directus";
import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
@@ -27,7 +27,7 @@
<Meta
title={displayName}
description={data.user.description || `${displayName}'s profile`}
image={data.user.avatar ? getAssetUrl(data.user.avatar, "thumbnail") : undefined}
image={data.user.avatar ? getAssetUrl(data.user.avatar, "thumbnail") ?? undefined : undefined}
/>
<div class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
@@ -91,12 +91,7 @@
>
</div>
{#if data.user.location}
<div class="flex items-center gap-2 text-muted-foreground mb-4">
<span class="icon-[ri--map-pin-line] w-4 h-4"></span>
<span>{data.user.location}</span>
</div>
{/if}
{#if data.user.description}
<p class="text-muted-foreground mb-4">
@@ -148,7 +143,7 @@
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="text-center p-4 rounded-lg bg-accent/10">
<div class="text-3xl font-bold text-primary">
{Math.round(data.gamification.stats.total_weighted_points)}
{Math.round(data.gamification.stats.total_weighted_points ?? 0)}
</div>
<div class="text-sm text-muted-foreground mt-1">
{$_("gamification.points")}
@@ -188,7 +183,7 @@
{$_("gamification.achievements")} ({data.gamification.achievements.length})
</h3>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{#each data.gamification.achievements as achievement (achievement.id)}
{#each (data.gamification?.achievements ?? []) as achievement (achievement.id)}
<div
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-accent/10 border border-border/30 hover:border-primary/50 transition-colors"
title={achievement.description}
@@ -199,7 +194,7 @@
</span>
{#if achievement.date_unlocked}
<span class="text-xs text-muted-foreground">
{new Date(achievement.date_unlocked).toLocaleDateString($locale)}
{new Date(achievement.date_unlocked).toLocaleDateString($locale ?? undefined)}
</span>
{/if}
</div>

View File

@@ -4,7 +4,7 @@
import { Card, CardContent } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import { getAssetUrl } from "$lib/directus";
import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte";
import TimeAgo from "javascript-time-ago";
import { formatVideoDuration } from "$lib/utils";

View File

@@ -3,7 +3,7 @@
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import "media-chrome";
import { getAssetUrl } from "$lib/directus";
import { getAssetUrl } from "$lib/api";
import TimeAgo from "javascript-time-ago";
import { page } from "$app/state";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
@@ -377,7 +377,7 @@
<div class="flex gap-3 mb-6">
<Avatar class="h-8 w-8 ring-2 ring-accent/20 transition-all duration-200">
<AvatarImage
src={getAssetUrl(data.authStatus.user!.avatar.id, "mini")}
src={getAssetUrl(data.authStatus.user!.avatar, "mini")}
alt={data.authStatus.user!.artist_name}
/>
<AvatarFallback
@@ -432,27 +432,27 @@
<div class="space-y-4">
{#each data.comments as comment (comment.id)}
<div class="flex gap-3">
<a href="/users/{comment.user_created.id}" class="flex-shrink-0">
<a href="/users/{comment.user?.id}" class="flex-shrink-0">
<Avatar
class="h-8 w-8 ring-2 ring-accent/20 hover:ring-primary/40 transition-all duration-200 cursor-pointer"
>
<AvatarImage
src={getAssetUrl(comment.user_created.avatar as string, "mini")}
alt={comment.user_created.artist_name}
src={getAssetUrl(comment.user?.avatar, "mini")}
alt={comment.user?.artist_name}
/>
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200"
>
{getUserInitials(comment.user_created.artist_name)}
{getUserInitials(comment.user?.artist_name)}
</AvatarFallback>
</Avatar>
</a>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<a
href="/users/{comment.user_created.id}"
href="/users/{comment.user?.id}"
class="font-medium text-sm hover:text-primary transition-colors"
>{comment.user_created.artist_name}</a
>{comment.user?.artist_name}</a
>
<span class="text-xs text-muted-foreground"
>{timeAgo.format(new Date(comment.date_created))}</span

View File

@@ -0,0 +1,16 @@
{
"name": "@sexy.pivoine.art/types",
"version": "1.0.0",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts"
}
},
"scripts": {
"check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

281
packages/types/src/index.ts Normal file
View File

@@ -0,0 +1,281 @@
// ─── Core entities ───────────────────────────────────────────────────────────
export interface MediaFile {
id: string;
title: string | null;
description: string | null;
filename: string;
mime_type: string | null;
filesize: number | null;
duration: number | null;
uploaded_by: string | null;
date_created: Date;
}
export interface User {
id: string;
email: string;
first_name: string | null;
last_name: string | null;
artist_name: string | null;
slug: string | null;
description: string | null;
tags: string[] | null;
role: "model" | "viewer" | "admin";
/** UUID of the avatar file */
avatar: string | null;
/** UUID of the banner file */
banner: string | null;
email_verified: boolean;
date_created: Date;
}
export type CurrentUser = User;
// ─── Video ───────────────────────────────────────────────────────────────────
export interface VideoModel {
id: string;
artist_name: string | null;
slug: string | null;
avatar: string | null;
}
export interface VideoFile {
id: string;
filename: string;
mime_type: string | null;
duration: number | null;
}
export interface Video {
id: string;
slug: string;
title: string;
description: string | null;
image: string | null;
movie: string | null;
tags: string[] | null;
upload_date: Date;
premium: boolean | null;
featured: boolean | null;
likes_count: number | null;
plays_count: number | null;
models?: VideoModel[];
movie_file?: VideoFile | null;
}
// ─── Model ───────────────────────────────────────────────────────────────────
export interface ModelPhoto {
id: string;
filename: string;
}
export interface Model {
id: string;
slug: string | null;
artist_name: string | null;
description: string | null;
avatar: string | null;
banner: string | null;
tags: string[] | null;
date_created: Date;
photos?: ModelPhoto[];
}
// ─── Article ─────────────────────────────────────────────────────────────────
export interface ArticleAuthor {
first_name: string | null;
last_name: string | null;
avatar: string | null;
description: string | null;
website?: string | null;
}
export interface Article {
id: string;
slug: string;
title: string;
excerpt: string | null;
content: string | null;
image: string | null;
tags: string[] | null;
publish_date: Date;
category: string | null;
featured: boolean | null;
author?: ArticleAuthor | null;
}
// ─── Comment ─────────────────────────────────────────────────────────────────
export interface CommentUser {
id: string;
first_name: string | null;
last_name: string | null;
artist_name: string | null;
avatar: string | null;
}
export interface Comment {
id: number;
collection: string;
item_id: string;
comment: string;
user_id: string;
date_created: Date;
user?: CommentUser | null;
}
// ─── Stats ───────────────────────────────────────────────────────────────────
export interface Stats {
videos_count: number;
models_count: number;
viewers_count: number;
}
// ─── Recording ───────────────────────────────────────────────────────────────
export interface RecordedEvent {
timestamp: number;
device_index: number;
device_name: string;
actuator_index: number;
actuator_type: string;
value: number;
}
export interface DeviceInfo {
name: string;
index: number;
capabilities: string[];
}
export interface Recording {
id: string;
title: string;
description: string | null;
slug: string;
duration: number;
events: object[] | null;
device_info: object[] | null;
user_id: string;
status: "draft" | "published" | "archived";
tags: string[] | null;
linked_video: string | null;
featured: boolean | null;
public: boolean | null;
original_recording_id?: string | null;
date_created: Date;
date_updated: Date | null;
}
// ─── Video interactions ───────────────────────────────────────────────────────
export interface VideoLikeStatus {
liked: boolean;
}
export interface VideoPlayRecord {
id: string;
video_id: string;
duration_watched?: number;
completed: boolean;
}
export interface VideoLikeResponse {
liked: boolean;
likes_count: number;
}
export interface VideoPlayResponse {
success: boolean;
play_id: string;
plays_count: number;
}
// ─── Analytics ───────────────────────────────────────────────────────────────
export interface VideoAnalytics {
id: string;
title: string;
slug: string;
upload_date: Date;
likes: number;
plays: number;
completed_plays: number;
completion_rate: number;
avg_watch_time: number;
}
export interface Analytics {
total_videos: number;
total_likes: number;
total_plays: number;
plays_by_date: Record<string, number>;
likes_by_date: Record<string, number>;
videos: VideoAnalytics[];
}
// ─── Gamification ────────────────────────────────────────────────────────────
export interface LeaderboardEntry {
user_id: string;
display_name: string | null;
avatar: string | null;
total_weighted_points: number | null;
total_raw_points: number | null;
recordings_count: number | null;
playbacks_count: number | null;
achievements_count: number | null;
rank: number;
}
export interface UserStats {
user_id: string;
total_raw_points: number | null;
total_weighted_points: number | null;
recordings_count: number | null;
playbacks_count: number | null;
comments_count: number | null;
achievements_count: number | null;
rank: number;
}
export interface UserAchievement {
id: string;
code: string;
name: string;
description: string | null;
icon: string | null;
category: string | null;
date_unlocked: Date;
progress: number | null;
required_count: number;
}
export interface RecentPoint {
action: string;
points: number;
date_created: Date;
recording_id: string | null;
}
export interface UserGamification {
stats: UserStats | null;
achievements: UserAchievement[];
recent_points: RecentPoint[];
}
export interface Achievement {
id: string;
code: string;
name: string;
description: string | null;
icon: string | null;
category: string | null;
required_count: number;
points_reward: number;
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true
},
"include": ["src/**/*"]
}

12
pnpm-lock.yaml generated
View File

@@ -53,6 +53,9 @@ importers:
'@pothos/plugin-errors':
specifier: ^4.2.0
version: 4.9.0(@pothos/core@4.12.0(graphql@16.13.1))(graphql@16.13.1)
'@sexy.pivoine.art/types':
specifier: workspace:*
version: link:../types
argon2:
specifier: ^0.43.0
version: 0.43.1
@@ -151,6 +154,9 @@ importers:
'@sexy.pivoine.art/buttplug':
specifier: workspace:*
version: link:../buttplug
'@sexy.pivoine.art/types':
specifier: workspace:*
version: link:../types
graphql:
specifier: ^16.11.0
version: 16.13.1
@@ -252,6 +258,12 @@ importers:
specifier: 3.5.0
version: 3.5.0(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0))
packages/types:
devDependencies:
typescript:
specifier: ^5.9.3
version: 5.9.3
packages:
'@antfu/install-pkg@1.1.0':