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>
This commit is contained in:
2026-03-05 11:01:11 +01:00
parent c6126c13e9
commit 97269788ee
31 changed files with 839 additions and 822 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

@@ -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,383 +1,269 @@
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")
export const FileType = builder.objectRef<MediaFile>("File").implement({
fields: (t) => ({
id: t.exposeString("id"),
title: t.exposeString("title", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
filename: t.exposeString("filename"),
mime_type: t.exposeString("mime_type", { nullable: true }),
filesize: t.exposeFloat("filesize", { nullable: true }),
duration: t.exposeInt("duration", { nullable: true }),
uploaded_by: t.exposeString("uploaded_by", { nullable: true }),
date_created: t.expose("date_created", { type: "DateTime" }),
}),
});
export const UserType = builder.objectRef<User>("User").implement({
fields: (t) => ({
id: t.exposeString("id"),
email: t.exposeString("email"),
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
artist_name: t.exposeString("artist_name", { nullable: true }),
slug: t.exposeString("slug", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }),
}),
});
// 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"),
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
artist_name: t.exposeString("artist_name", { nullable: true }),
slug: t.exposeString("slug", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }),
}),
});
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"),
title: t.exposeString("title"),
description: t.exposeString("description", { nullable: true }),
image: t.exposeString("image", { nullable: true }),
movie: t.exposeString("movie", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
upload_date: t.expose("upload_date", { type: "DateTime" }),
premium: t.exposeBoolean("premium", { nullable: true }),
featured: t.exposeBoolean("featured", { nullable: true }),
likes_count: t.exposeInt("likes_count", { nullable: true }),
plays_count: t.exposeInt("plays_count", { nullable: true }),
models: t.expose("models", { type: [VideoModelType], nullable: true }),
movie_file: t.expose("movie_file", { type: VideoFileType, nullable: true }),
}),
});
export const ModelPhotoType = builder.objectRef<ModelPhoto>("ModelPhoto").implement({
fields: (t) => ({
id: t.exposeString("id"),
filename: t.exposeString("filename"),
}),
});
export const ModelType = builder.objectRef<Model>("Model").implement({
fields: (t) => ({
id: t.exposeString("id"),
slug: t.exposeString("slug", { nullable: true }),
artist_name: t.exposeString("artist_name", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
date_created: t.expose("date_created", { type: "DateTime" }),
photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }),
}),
});
export const ArticleAuthorType = builder.objectRef<ArticleAuthor>("ArticleAuthor").implement({
fields: (t) => ({
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 }),
}),
});
export const ArticleType = builder.objectRef<Article>("Article").implement({
fields: (t) => ({
id: t.exposeString("id"),
slug: t.exposeString("slug"),
title: t.exposeString("title"),
excerpt: t.exposeString("excerpt", { nullable: true }),
content: t.exposeString("content", { nullable: true }),
image: t.exposeString("image", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
publish_date: t.expose("publish_date", { type: "DateTime" }),
category: t.exposeString("category", { nullable: true }),
featured: t.exposeBoolean("featured", { nullable: true }),
author: t.expose("author", { type: ArticleAuthorType, nullable: true }),
}),
});
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 }),
}),
});
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"),
description: t.exposeString("description", { nullable: true }),
slug: t.exposeString("slug"),
duration: t.exposeInt("duration"),
events: t.expose("events", { type: "JSON", nullable: true }),
device_info: t.expose("device_info", { type: "JSON", nullable: true }),
user_id: t.exposeString("user_id"),
status: t.exposeString("status"),
tags: t.exposeStringList("tags", { nullable: true }),
linked_video: t.exposeString("linked_video", { nullable: true }),
featured: t.exposeBoolean("featured", { nullable: true }),
public: t.exposeBoolean("public", { nullable: true }),
date_created: t.expose("date_created", { type: "DateTime" }),
date_updated: t.expose("date_updated", { type: "DateTime", nullable: true }),
}),
});
export const VideoLikeResponseType = builder
.objectRef<VideoLikeResponse>("VideoLikeResponse")
.implement({
fields: (t) => ({
id: t.exposeString("id"),
title: t.exposeString("title", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
filename: t.exposeString("filename"),
mime_type: t.exposeString("mime_type", { nullable: true }),
filesize: t.exposeFloat("filesize", { nullable: true }),
duration: t.exposeInt("duration", { nullable: true }),
uploaded_by: t.exposeString("uploaded_by", { nullable: true }),
date_created: t.expose("date_created", { type: "DateTime" }),
liked: t.exposeBoolean("liked"),
likes_count: t.exposeInt("likes_count"),
}),
});
// 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")
export const VideoPlayResponseType = builder
.objectRef<VideoPlayResponse>("VideoPlayResponse")
.implement({
fields: (t) => ({
id: t.exposeString("id"),
email: t.exposeString("email"),
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
artist_name: t.exposeString("artist_name", { nullable: true }),
slug: t.exposeString("slug", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }),
success: t.exposeBoolean("success"),
play_id: t.exposeString("play_id"),
plays_count: t.exposeInt("plays_count"),
}),
});
// 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")
export const VideoLikeStatusType = builder
.objectRef<VideoLikeStatus>("VideoLikeStatus")
.implement({
fields: (t) => ({
id: t.exposeString("id"),
email: t.exposeString("email"),
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
artist_name: t.exposeString("artist_name", { nullable: true }),
slug: t.exposeString("slug", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }),
liked: t.exposeBoolean("liked"),
}),
});
// 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({
fields: (t) => ({
id: t.exposeString("id"),
slug: t.exposeString("slug"),
title: t.exposeString("title"),
description: t.exposeString("description", { nullable: true }),
image: t.exposeString("image", { nullable: true }),
movie: t.exposeString("movie", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
upload_date: t.expose("upload_date", { type: "DateTime" }),
premium: t.exposeBoolean("premium", { nullable: true }),
featured: t.exposeBoolean("featured", { nullable: true }),
likes_count: t.exposeInt("likes_count", { nullable: true }),
plays_count: t.exposeInt("plays_count", { nullable: true }),
models: t.expose("models", { type: [VideoModelType], nullable: true }),
movie_file: t.expose("movie_file", { type: VideoFileType, nullable: true }),
}),
});
export const VideoAnalyticsType = builder.objectRef<VideoAnalytics>("VideoAnalytics").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"),
}),
});
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 AnalyticsType = builder.objectRef<Analytics>("Analytics").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] }),
}),
});
export const VideoFileType = builder
.objectRef<{
id: string;
filename: string;
mime_type: string | null;
duration: number | null;
}>("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 }),
}),
});
// 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({
fields: (t) => ({
id: t.exposeString("id"),
slug: t.exposeString("slug", { nullable: true }),
artist_name: t.exposeString("artist_name", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
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({
fields: (t) => ({
id: t.exposeString("id"),
filename: t.exposeString("filename"),
}),
});
// 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({
fields: (t) => ({
id: t.exposeString("id"),
slug: t.exposeString("slug"),
title: t.exposeString("title"),
excerpt: t.exposeString("excerpt", { nullable: true }),
content: t.exposeString("content", { nullable: true }),
image: t.exposeString("image", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
publish_date: t.expose("publish_date", { type: "DateTime" }),
category: t.exposeString("category", { nullable: true }),
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({
fields: (t) => ({
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 }),
}),
});
// 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({
fields: (t) => ({
id: t.exposeString("id"),
title: t.exposeString("title"),
description: t.exposeString("description", { nullable: true }),
slug: t.exposeString("slug"),
duration: t.exposeInt("duration"),
events: t.expose("events", { type: "JSON", nullable: true }),
device_info: t.expose("device_info", { type: "JSON", nullable: true }),
user_id: t.exposeString("user_id"),
status: t.exposeString("status"),
tags: t.exposeStringList("tags", { nullable: true }),
linked_video: t.exposeString("linked_video", { nullable: true }),
featured: t.exposeBoolean("featured", { nullable: true }),
public: t.exposeBoolean("public", { nullable: true }),
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")
.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 CommentUserType = builder
.objectRef<{
id: string;
first_name: string | null;
last_name: string | null;
avatar: string | null;
}>("CommentUser")
.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 }),
}),
});
// Stats type
export const StatsType = builder
.objectRef<{
videos_count: number;
models_count: number;
viewers_count: number;
}>("Stats")
.implement({
fields: (t) => ({
videos_count: t.exposeInt("videos_count"),
models_count: t.exposeInt("models_count"),
viewers_count: t.exposeInt("viewers_count"),
}),
});
// 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,60 +278,44 @@ 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 UserStatsType = builder.objectRef<UserStats>("UserStats").implement({
fields: (t) => ({
user_id: t.exposeString("user_id"),
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
total_weighted_points: t.exposeFloat("total_weighted_points", { nullable: true }),
recordings_count: t.exposeInt("recordings_count", { nullable: true }),
playbacks_count: t.exposeInt("playbacks_count", { nullable: true }),
comments_count: t.exposeInt("comments_count", { nullable: true }),
achievements_count: t.exposeInt("achievements_count", { nullable: true }),
rank: t.exposeInt("rank"),
}),
});
export const UserAchievementType = builder.objectRef<UserAchievement>("UserAchievement").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 }),
date_unlocked: t.expose("date_unlocked", { type: "DateTime" }),
progress: t.exposeInt("progress", { nullable: true }),
required_count: t.exposeInt("required_count"),
}),
});
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 }),
}),
});
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")
.objectRef<UserGamification>("UserGamification")
.implement({
fields: (t) => ({
stats: t.expose("stats", { type: UserStatsType, nullable: true }),
@@ -454,162 +324,15 @@ export const UserGamificationType = builder
}),
});
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({
fields: (t) => ({
user_id: t.exposeString("user_id"),
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
total_weighted_points: t.exposeFloat("total_weighted_points", { nullable: true }),
recordings_count: t.exposeInt("recordings_count", { nullable: true }),
playbacks_count: t.exposeInt("playbacks_count", { nullable: true }),
comments_count: t.exposeInt("comments_count", { nullable: true }),
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({
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 }),
date_unlocked: t.expose("date_unlocked", { type: "DateTime" }),
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({
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")
.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] }),
}),
});
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({
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"),
}),
});
// 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"),
}),
});
export const AchievementType = builder.objectRef<Achievement>("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"),
}),
});

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

@@ -128,7 +128,7 @@
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}
@@ -171,7 +171,7 @@
<div class="relative flex items-center gap-4">
<Avatar class="h-14 w-14 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

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

@@ -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

@@ -10,8 +10,8 @@
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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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
@@ -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");
@@ -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

@@ -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

@@ -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

@@ -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;
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 | 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':