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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ target/
|
||||
pkg/
|
||||
|
||||
.claude/
|
||||
.data/
|
||||
|
||||
94
CLAUDE.md
Normal file
94
CLAUDE.md
Normal 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`.
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
description: string | null | undefined;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}[];
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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(" ")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] })),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
16
packages/types/package.json
Normal file
16
packages/types/package.json
Normal 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
281
packages/types/src/index.ts
Normal 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;
|
||||
}
|
||||
10
packages/types/tsconfig.json
Normal file
10
packages/types/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user