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/
|
pkg/
|
||||||
|
|
||||||
.claude/
|
.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"
|
"check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@sexy.pivoine.art/types": "workspace:*",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^10.0.2",
|
"@fastify/cors": "^10.0.2",
|
||||||
"@fastify/multipart": "^9.0.3",
|
"@fastify/multipart": "^9.0.3",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ builder.queryField("commentsForVideo", (t) =>
|
|||||||
id: users.id,
|
id: users.id,
|
||||||
first_name: users.first_name,
|
first_name: users.first_name,
|
||||||
last_name: users.last_name,
|
last_name: users.last_name,
|
||||||
|
artist_name: users.artist_name,
|
||||||
avatar: users.avatar,
|
avatar: users.avatar,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
@@ -66,6 +67,7 @@ builder.mutationField("createCommentForVideo", (t) =>
|
|||||||
id: users.id,
|
id: users.id,
|
||||||
first_name: users.first_name,
|
first_name: users.first_name,
|
||||||
last_name: users.last_name,
|
last_name: users.last_name,
|
||||||
|
artist_name: users.artist_name,
|
||||||
avatar: users.avatar,
|
avatar: users.avatar,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
|
import type {
|
||||||
|
MediaFile,
|
||||||
|
User,
|
||||||
|
VideoModel,
|
||||||
|
VideoFile,
|
||||||
|
Video,
|
||||||
|
ModelPhoto,
|
||||||
|
Model,
|
||||||
|
ArticleAuthor,
|
||||||
|
Article,
|
||||||
|
CommentUser,
|
||||||
|
Comment,
|
||||||
|
Stats,
|
||||||
|
Recording,
|
||||||
|
VideoLikeStatus,
|
||||||
|
VideoLikeResponse,
|
||||||
|
VideoPlayResponse,
|
||||||
|
VideoAnalytics,
|
||||||
|
Analytics,
|
||||||
|
LeaderboardEntry,
|
||||||
|
UserStats,
|
||||||
|
UserAchievement,
|
||||||
|
RecentPoint,
|
||||||
|
UserGamification,
|
||||||
|
Achievement,
|
||||||
|
} from "@sexy.pivoine.art/types";
|
||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
|
|
||||||
// File type
|
export const FileType = builder.objectRef<MediaFile>("File").implement({
|
||||||
export const FileType = builder
|
|
||||||
.objectRef<{
|
|
||||||
id: string;
|
|
||||||
title: string | null;
|
|
||||||
description: string | null;
|
|
||||||
filename: string;
|
|
||||||
mime_type: string | null;
|
|
||||||
filesize: number | null;
|
|
||||||
duration: number | null;
|
|
||||||
uploaded_by: string | null;
|
|
||||||
date_created: Date;
|
|
||||||
}>("File")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
title: t.exposeString("title", { nullable: true }),
|
title: t.exposeString("title", { nullable: true }),
|
||||||
@@ -25,26 +38,9 @@ export const FileType = builder
|
|||||||
uploaded_by: t.exposeString("uploaded_by", { nullable: true }),
|
uploaded_by: t.exposeString("uploaded_by", { nullable: true }),
|
||||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// User type
|
export const UserType = builder.objectRef<User>("User").implement({
|
||||||
export const UserType = builder
|
|
||||||
.objectRef<{
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
first_name: string | null;
|
|
||||||
last_name: string | null;
|
|
||||||
artist_name: string | null;
|
|
||||||
slug: string | null;
|
|
||||||
description: string | null;
|
|
||||||
tags: string[] | null;
|
|
||||||
role: "model" | "viewer" | "admin";
|
|
||||||
avatar: string | null;
|
|
||||||
banner: string | null;
|
|
||||||
email_verified: boolean;
|
|
||||||
date_created: Date;
|
|
||||||
}>("User")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
email: t.exposeString("email"),
|
email: t.exposeString("email"),
|
||||||
@@ -60,26 +56,10 @@ export const UserType = builder
|
|||||||
email_verified: t.exposeBoolean("email_verified"),
|
email_verified: t.exposeBoolean("email_verified"),
|
||||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// CurrentUser type (same shape, used for auth context)
|
// CurrentUser is the same shape as User
|
||||||
export const CurrentUserType = builder
|
export const CurrentUserType = builder.objectRef<User>("CurrentUser").implement({
|
||||||
.objectRef<{
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
first_name: string | null;
|
|
||||||
last_name: string | null;
|
|
||||||
artist_name: string | null;
|
|
||||||
slug: string | null;
|
|
||||||
description: string | null;
|
|
||||||
tags: string[] | null;
|
|
||||||
role: "model" | "viewer" | "admin";
|
|
||||||
avatar: string | null;
|
|
||||||
banner: string | null;
|
|
||||||
email_verified: boolean;
|
|
||||||
date_created: Date;
|
|
||||||
}>("CurrentUser")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
email: t.exposeString("email"),
|
email: t.exposeString("email"),
|
||||||
@@ -95,37 +75,27 @@ export const CurrentUserType = builder
|
|||||||
email_verified: t.exposeBoolean("email_verified"),
|
email_verified: t.exposeBoolean("email_verified"),
|
||||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Video type
|
export const VideoModelType = builder.objectRef<VideoModel>("VideoModel").implement({
|
||||||
export const VideoType = builder
|
fields: (t) => ({
|
||||||
.objectRef<{
|
id: t.exposeString("id"),
|
||||||
id: string;
|
artist_name: t.exposeString("artist_name", { nullable: true }),
|
||||||
slug: string;
|
slug: t.exposeString("slug", { nullable: true }),
|
||||||
title: string;
|
avatar: t.exposeString("avatar", { nullable: true }),
|
||||||
description: string | null;
|
}),
|
||||||
image: string | null;
|
});
|
||||||
movie: string | null;
|
|
||||||
tags: string[] | null;
|
export const VideoFileType = builder.objectRef<VideoFile>("VideoFile").implement({
|
||||||
upload_date: Date;
|
fields: (t) => ({
|
||||||
premium: boolean | null;
|
id: t.exposeString("id"),
|
||||||
featured: boolean | null;
|
filename: t.exposeString("filename"),
|
||||||
likes_count: number | null;
|
mime_type: t.exposeString("mime_type", { nullable: true }),
|
||||||
plays_count: number | null;
|
duration: t.exposeInt("duration", { nullable: true }),
|
||||||
models?: {
|
}),
|
||||||
id: string;
|
});
|
||||||
artist_name: string | null;
|
|
||||||
slug: string | null;
|
export const VideoType = builder.objectRef<Video>("Video").implement({
|
||||||
avatar: string | null;
|
|
||||||
}[];
|
|
||||||
movie_file?: {
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
mime_type: string | null;
|
|
||||||
duration: number | null;
|
|
||||||
} | null;
|
|
||||||
}>("Video")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
slug: t.exposeString("slug"),
|
slug: t.exposeString("slug"),
|
||||||
@@ -142,54 +112,16 @@ export const VideoType = builder
|
|||||||
models: t.expose("models", { type: [VideoModelType], nullable: true }),
|
models: t.expose("models", { type: [VideoModelType], nullable: true }),
|
||||||
movie_file: t.expose("movie_file", { type: VideoFileType, nullable: true }),
|
movie_file: t.expose("movie_file", { type: VideoFileType, nullable: true }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const VideoModelType = builder
|
export const ModelPhotoType = builder.objectRef<ModelPhoto>("ModelPhoto").implement({
|
||||||
.objectRef<{
|
|
||||||
id: string;
|
|
||||||
artist_name: string | null;
|
|
||||||
slug: string | null;
|
|
||||||
avatar: string | null;
|
|
||||||
}>("VideoModel")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
|
||||||
id: t.exposeString("id"),
|
|
||||||
artist_name: t.exposeString("artist_name", { nullable: true }),
|
|
||||||
slug: t.exposeString("slug", { nullable: true }),
|
|
||||||
avatar: t.exposeString("avatar", { nullable: true }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const VideoFileType = builder
|
|
||||||
.objectRef<{
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
mime_type: string | null;
|
|
||||||
duration: number | null;
|
|
||||||
}>("VideoFile")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
filename: t.exposeString("filename"),
|
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<Model>("Model").implement({
|
||||||
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) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
slug: t.exposeString("slug", { nullable: true }),
|
slug: t.exposeString("slug", { nullable: true }),
|
||||||
@@ -201,41 +133,18 @@ export const ModelType = builder
|
|||||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||||
photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }),
|
photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ModelPhotoType = builder
|
export const ArticleAuthorType = builder.objectRef<ArticleAuthor>("ArticleAuthor").implement({
|
||||||
.objectRef<{
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
}>("ModelPhoto")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
first_name: t.exposeString("first_name", { nullable: true }),
|
||||||
filename: t.exposeString("filename"),
|
last_name: t.exposeString("last_name", { nullable: true }),
|
||||||
|
avatar: t.exposeString("avatar", { nullable: true }),
|
||||||
|
description: t.exposeString("description", { nullable: true }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Article type
|
export const ArticleType = builder.objectRef<Article>("Article").implement({
|
||||||
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) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
slug: t.exposeString("slug"),
|
slug: t.exposeString("slug"),
|
||||||
@@ -249,44 +158,39 @@ export const ArticleType = builder
|
|||||||
featured: t.exposeBoolean("featured", { nullable: true }),
|
featured: t.exposeBoolean("featured", { nullable: true }),
|
||||||
author: t.expose("author", { type: ArticleAuthorType, nullable: true }),
|
author: t.expose("author", { type: ArticleAuthorType, nullable: true }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ArticleAuthorType = builder
|
export const CommentUserType = builder.objectRef<CommentUser>("CommentUser").implement({
|
||||||
.objectRef<{
|
|
||||||
first_name: string | null;
|
|
||||||
last_name: string | null;
|
|
||||||
avatar: string | null;
|
|
||||||
description: string | null;
|
|
||||||
}>("ArticleAuthor")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
|
id: t.exposeString("id"),
|
||||||
first_name: t.exposeString("first_name", { nullable: true }),
|
first_name: t.exposeString("first_name", { nullable: true }),
|
||||||
last_name: t.exposeString("last_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 }),
|
avatar: t.exposeString("avatar", { nullable: true }),
|
||||||
description: t.exposeString("description", { nullable: true }),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Recording type
|
export const CommentType = builder.objectRef<Comment>("Comment").implement({
|
||||||
export const RecordingType = builder
|
fields: (t) => ({
|
||||||
.objectRef<{
|
id: t.exposeInt("id"),
|
||||||
id: string;
|
collection: t.exposeString("collection"),
|
||||||
title: string;
|
item_id: t.exposeString("item_id"),
|
||||||
description: string | null;
|
comment: t.exposeString("comment"),
|
||||||
slug: string;
|
user_id: t.exposeString("user_id"),
|
||||||
duration: number;
|
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||||
events: object[] | null;
|
user: t.expose("user", { type: CommentUserType, nullable: true }),
|
||||||
device_info: object[] | null;
|
}),
|
||||||
user_id: string;
|
});
|
||||||
status: string;
|
|
||||||
tags: string[] | null;
|
export const StatsType = builder.objectRef<Stats>("Stats").implement({
|
||||||
linked_video: string | null;
|
fields: (t) => ({
|
||||||
featured: boolean | null;
|
videos_count: t.exposeInt("videos_count"),
|
||||||
public: boolean | null;
|
models_count: t.exposeInt("models_count"),
|
||||||
date_created: Date;
|
viewers_count: t.exposeInt("viewers_count"),
|
||||||
date_updated: Date | null;
|
}),
|
||||||
}>("Recording")
|
});
|
||||||
.implement({
|
|
||||||
|
export const RecordingType = builder.objectRef<Recording>("Recording").implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
title: t.exposeString("title"),
|
title: t.exposeString("title"),
|
||||||
@@ -304,80 +208,62 @@ export const RecordingType = builder
|
|||||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||||
date_updated: t.expose("date_updated", { type: "DateTime", nullable: true }),
|
date_updated: t.expose("date_updated", { type: "DateTime", nullable: true }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Comment type
|
export const VideoLikeResponseType = builder
|
||||||
export const CommentType = builder
|
.objectRef<VideoLikeResponse>("VideoLikeResponse")
|
||||||
.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({
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeInt("id"),
|
liked: t.exposeBoolean("liked"),
|
||||||
collection: t.exposeString("collection"),
|
likes_count: t.exposeInt("likes_count"),
|
||||||
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
|
export const VideoPlayResponseType = builder
|
||||||
.objectRef<{
|
.objectRef<VideoPlayResponse>("VideoPlayResponse")
|
||||||
id: string;
|
|
||||||
first_name: string | null;
|
|
||||||
last_name: string | null;
|
|
||||||
avatar: string | null;
|
|
||||||
}>("CommentUser")
|
|
||||||
.implement({
|
.implement({
|
||||||
|
fields: (t) => ({
|
||||||
|
success: t.exposeBoolean("success"),
|
||||||
|
play_id: t.exposeString("play_id"),
|
||||||
|
plays_count: t.exposeInt("plays_count"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VideoLikeStatusType = builder
|
||||||
|
.objectRef<VideoLikeStatus>("VideoLikeStatus")
|
||||||
|
.implement({
|
||||||
|
fields: (t) => ({
|
||||||
|
liked: t.exposeBoolean("liked"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VideoAnalyticsType = builder.objectRef<VideoAnalytics>("VideoAnalytics").implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
first_name: t.exposeString("first_name", { nullable: true }),
|
title: t.exposeString("title"),
|
||||||
last_name: t.exposeString("last_name", { nullable: true }),
|
slug: t.exposeString("slug"),
|
||||||
avatar: t.exposeString("avatar", { nullable: true }),
|
upload_date: t.expose("upload_date", { type: "DateTime" }),
|
||||||
|
likes: t.exposeInt("likes"),
|
||||||
|
plays: t.exposeInt("plays"),
|
||||||
|
completed_plays: t.exposeInt("completed_plays"),
|
||||||
|
completion_rate: t.exposeFloat("completion_rate"),
|
||||||
|
avg_watch_time: t.exposeInt("avg_watch_time"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stats type
|
export const AnalyticsType = builder.objectRef<Analytics>("Analytics").implement({
|
||||||
export const StatsType = builder
|
|
||||||
.objectRef<{
|
|
||||||
videos_count: number;
|
|
||||||
models_count: number;
|
|
||||||
viewers_count: number;
|
|
||||||
}>("Stats")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
videos_count: t.exposeInt("videos_count"),
|
total_videos: t.exposeInt("total_videos"),
|
||||||
models_count: t.exposeInt("models_count"),
|
total_likes: t.exposeInt("total_likes"),
|
||||||
viewers_count: t.exposeInt("viewers_count"),
|
total_plays: t.exposeInt("total_plays"),
|
||||||
|
plays_by_date: t.expose("plays_by_date", { type: "JSON" }),
|
||||||
|
likes_by_date: t.expose("likes_by_date", { type: "JSON" }),
|
||||||
|
videos: t.expose("videos", { type: [VideoAnalyticsType] }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gamification types
|
|
||||||
export const LeaderboardEntryType = builder
|
export const LeaderboardEntryType = builder
|
||||||
.objectRef<{
|
.objectRef<LeaderboardEntry>("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;
|
|
||||||
}>("LeaderboardEntry")
|
|
||||||
.implement({
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
user_id: t.exposeString("user_id"),
|
user_id: t.exposeString("user_id"),
|
||||||
@@ -392,80 +278,7 @@ export const LeaderboardEntryType = builder
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AchievementType = builder
|
export const UserStatsType = builder.objectRef<UserStats>("UserStats").implement({
|
||||||
.objectRef<{
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
icon: string | null;
|
|
||||||
category: string | null;
|
|
||||||
required_count: number;
|
|
||||||
points_reward: number;
|
|
||||||
}>("Achievement")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
|
||||||
id: t.exposeString("id"),
|
|
||||||
code: t.exposeString("code"),
|
|
||||||
name: t.exposeString("name"),
|
|
||||||
description: t.exposeString("description", { nullable: true }),
|
|
||||||
icon: t.exposeString("icon", { nullable: true }),
|
|
||||||
category: t.exposeString("category", { nullable: true }),
|
|
||||||
required_count: t.exposeInt("required_count"),
|
|
||||||
points_reward: t.exposeInt("points_reward"),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const UserGamificationType = builder
|
|
||||||
.objectRef<{
|
|
||||||
stats: {
|
|
||||||
user_id: string;
|
|
||||||
total_raw_points: number | null;
|
|
||||||
total_weighted_points: number | null;
|
|
||||||
recordings_count: number | null;
|
|
||||||
playbacks_count: number | null;
|
|
||||||
comments_count: number | null;
|
|
||||||
achievements_count: number | null;
|
|
||||||
rank: number;
|
|
||||||
} | null;
|
|
||||||
achievements: {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
icon: string | null;
|
|
||||||
category: string | null;
|
|
||||||
date_unlocked: Date;
|
|
||||||
progress: number | null;
|
|
||||||
required_count: number;
|
|
||||||
}[];
|
|
||||||
recent_points: {
|
|
||||||
action: string;
|
|
||||||
points: number;
|
|
||||||
date_created: Date;
|
|
||||||
recording_id: string | null;
|
|
||||||
}[];
|
|
||||||
}>("UserGamification")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
|
||||||
stats: t.expose("stats", { type: UserStatsType, nullable: true }),
|
|
||||||
achievements: t.expose("achievements", { type: [UserAchievementType] }),
|
|
||||||
recent_points: t.expose("recent_points", { type: [RecentPointType] }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const UserStatsType = builder
|
|
||||||
.objectRef<{
|
|
||||||
user_id: string;
|
|
||||||
total_raw_points: number | null;
|
|
||||||
total_weighted_points: number | null;
|
|
||||||
recordings_count: number | null;
|
|
||||||
playbacks_count: number | null;
|
|
||||||
comments_count: number | null;
|
|
||||||
achievements_count: number | null;
|
|
||||||
rank: number;
|
|
||||||
}>("UserStats")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
user_id: t.exposeString("user_id"),
|
user_id: t.exposeString("user_id"),
|
||||||
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
|
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
|
||||||
@@ -476,21 +289,9 @@ export const UserStatsType = builder
|
|||||||
achievements_count: t.exposeInt("achievements_count", { nullable: true }),
|
achievements_count: t.exposeInt("achievements_count", { nullable: true }),
|
||||||
rank: t.exposeInt("rank"),
|
rank: t.exposeInt("rank"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UserAchievementType = builder
|
export const UserAchievementType = builder.objectRef<UserAchievement>("UserAchievement").implement({
|
||||||
.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) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
code: t.exposeString("code"),
|
code: t.exposeString("code"),
|
||||||
@@ -502,114 +303,36 @@ export const UserAchievementType = builder
|
|||||||
progress: t.exposeInt("progress", { nullable: true }),
|
progress: t.exposeInt("progress", { nullable: true }),
|
||||||
required_count: t.exposeInt("required_count"),
|
required_count: t.exposeInt("required_count"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const RecentPointType = builder
|
export const RecentPointType = builder.objectRef<RecentPoint>("RecentPoint").implement({
|
||||||
.objectRef<{
|
|
||||||
action: string;
|
|
||||||
points: number;
|
|
||||||
date_created: Date;
|
|
||||||
recording_id: string | null;
|
|
||||||
}>("RecentPoint")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
action: t.exposeString("action"),
|
action: t.exposeString("action"),
|
||||||
points: t.exposeInt("points"),
|
points: t.exposeInt("points"),
|
||||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||||
recording_id: t.exposeString("recording_id", { nullable: true }),
|
recording_id: t.exposeString("recording_id", { nullable: true }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Analytics types
|
export const UserGamificationType = builder
|
||||||
export const AnalyticsType = builder
|
.objectRef<UserGamification>("UserGamification")
|
||||||
.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({
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
total_videos: t.exposeInt("total_videos"),
|
stats: t.expose("stats", { type: UserStatsType, nullable: true }),
|
||||||
total_likes: t.exposeInt("total_likes"),
|
achievements: t.expose("achievements", { type: [UserAchievementType] }),
|
||||||
total_plays: t.exposeInt("total_plays"),
|
recent_points: t.expose("recent_points", { type: [RecentPointType] }),
|
||||||
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
|
export const AchievementType = builder.objectRef<Achievement>("Achievement").implement({
|
||||||
.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) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
title: t.exposeString("title"),
|
code: t.exposeString("code"),
|
||||||
slug: t.exposeString("slug"),
|
name: t.exposeString("name"),
|
||||||
upload_date: t.expose("upload_date", { type: "DateTime" }),
|
description: t.exposeString("description", { nullable: true }),
|
||||||
likes: t.exposeInt("likes"),
|
icon: t.exposeString("icon", { nullable: true }),
|
||||||
plays: t.exposeInt("plays"),
|
category: t.exposeString("category", { nullable: true }),
|
||||||
completed_plays: t.exposeInt("completed_plays"),
|
required_count: t.exposeInt("required_count"),
|
||||||
completion_rate: t.exposeFloat("completion_rate"),
|
points_reward: t.exposeInt("points_reward"),
|
||||||
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"),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sexy.pivoine.art/buttplug": "workspace:*",
|
"@sexy.pivoine.art/buttplug": "workspace:*",
|
||||||
|
"@sexy.pivoine.art/types": "workspace:*",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
"graphql-request": "^7.1.2",
|
"graphql-request": "^7.1.2",
|
||||||
"javascript-time-ago": "^2.6.4",
|
"javascript-time-ago": "^2.6.4",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const getGraphQLClient = (fetchFn?: typeof globalThis.fetch) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getAssetUrl = (
|
export const getAssetUrl = (
|
||||||
id: string,
|
id: string | null | undefined,
|
||||||
transform?: "mini" | "thumbnail" | "preview" | "medium" | "banner",
|
transform?: "mini" | "thumbnail" | "preview" | "medium" | "banner",
|
||||||
) => {
|
) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
user={{
|
user={{
|
||||||
name:
|
name:
|
||||||
authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
|
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,
|
email: authStatus.user!.email,
|
||||||
}}
|
}}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
<div class="relative flex items-center gap-4">
|
<div class="relative flex items-center gap-4">
|
||||||
<Avatar class="h-14 w-14 ring-2 ring-primary/30">
|
<Avatar class="h-14 w-14 ring-2 ring-primary/30">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={getAssetUrl(authStatus.user!.avatar?.id, "mini")}
|
src={getAssetUrl(authStatus.user!.avatar, "mini")}
|
||||||
alt={authStatus.user!.artist_name}
|
alt={authStatus.user!.artist_name}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string | null | undefined;
|
||||||
image?: string;
|
image?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
||||||
import { Button } from "$lib/components/ui/button";
|
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";
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -68,18 +68,18 @@
|
|||||||
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
|
<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="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="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>
|
||||||
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
|
<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="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="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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Device Info -->
|
<!-- Device Info -->
|
||||||
<div class="space-y-1">
|
<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
|
<div
|
||||||
class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1"
|
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>
|
<span class="text-xs opacity-60">• {device.capabilities.join(", ")}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if recording.device_info.length > 2}
|
{#if (recording.device_info?.length ?? 0) > 2}
|
||||||
<div class="text-xs text-muted-foreground/60 px-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"
|
? "s"
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</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(
|
return loggedApiCall(
|
||||||
"updateProfile",
|
"updateProfile",
|
||||||
async () => {
|
async () => {
|
||||||
@@ -551,7 +551,7 @@ export async function getStats(fetchFn?: typeof globalThis.fetch) {
|
|||||||
|
|
||||||
// Stub — Directus folder concept dropped
|
// Stub — Directus folder concept dropped
|
||||||
export async function getFolders(_fetchFn?: typeof globalThis.fetch) {
|
export async function getFolders(_fetchFn?: typeof globalThis.fetch) {
|
||||||
return loggedApiCall("getFolders", async () => []);
|
return loggedApiCall("getFolders", async () => [] as { id: string; name: string }[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Files ───────────────────────────────────────────────────────────────────
|
// ─── Files ───────────────────────────────────────────────────────────────────
|
||||||
@@ -618,6 +618,7 @@ export async function getCommentsForVideo(item: string, fetchFn?: typeof globalT
|
|||||||
id: string;
|
id: string;
|
||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
last_name: string | null;
|
last_name: string | null;
|
||||||
|
artist_name: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
} | 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 {
|
import type { CurrentUser } from "@sexy.pivoine.art/types";
|
||||||
id: string;
|
import type { ButtplugClientDevice } from "@sexy.pivoine.art/buttplug";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CurrentUser extends User {
|
// ─── Frontend-only types ─────────────────────────────────────────────────────
|
||||||
avatar: File;
|
|
||||||
role: "model" | "viewer" | "admin";
|
|
||||||
policies: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthStatus {
|
export interface AuthStatus {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
@@ -28,78 +42,11 @@ export interface AuthStatus {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface File {
|
export interface ShareContent {
|
||||||
id: string;
|
|
||||||
filesize: number;
|
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
duration: number;
|
url: string;
|
||||||
directus_files_id?: File;
|
type: "video" | "model" | "article" | "link";
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceActuator {
|
export interface DeviceActuator {
|
||||||
@@ -120,86 +67,3 @@ export interface BluetoothDevice {
|
|||||||
lastSeen: Date;
|
lastSeen: Date;
|
||||||
info: ButtplugClientDevice;
|
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;
|
ref?: U | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const calcReadingTime = (text: string) => {
|
export const calcReadingTime = (text: string | null | undefined) => {
|
||||||
const wordsPerMinute = 200; // Average case.
|
const wordsPerMinute = 200; // Average case.
|
||||||
const textLength = text.split(" ").length; // Split by words
|
const textLength = (text ?? "").split(" ").length; // Split by words
|
||||||
if (textLength > 0) {
|
if (textLength > 0) {
|
||||||
return Math.ceil(textLength / wordsPerMinute);
|
return Math.ceil(textLength / wordsPerMinute);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUserInitials = (name: string) => {
|
export const getUserInitials = (name: string | null | undefined) => {
|
||||||
if (!name) return "??";
|
if (!name) return "??";
|
||||||
return name
|
return name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
// Format points with comma separator
|
// Format points with comma separator
|
||||||
function formatPoints(points: number): string {
|
function formatPoints(points: number | null | undefined): string {
|
||||||
return Math.round(points).toLocaleString($locale || "en");
|
return Math.round(points ?? 0).toLocaleString($locale || "en");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get medal emoji for top 3
|
// Get medal emoji for top 3
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user initials
|
// Get user initials
|
||||||
function getUserInitials(name: string): string {
|
function getUserInitials(name: string | null | undefined): string {
|
||||||
if (!name) return "?";
|
if (!name) return "?";
|
||||||
const parts = name.split(" ");
|
const parts = name.split(" ");
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
.filter((article) => {
|
.filter((article) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
article.excerpt.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
article.excerpt?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
article.author.first_name.toLowerCase().includes(searchQuery.toLowerCase());
|
article.author?.first_name?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
const matchesCategory = categoryFilter === "all" || article.category === categoryFilter;
|
const matchesCategory = categoryFilter === "all" || article.category === categoryFilter;
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
})
|
})
|
||||||
@@ -189,12 +189,12 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(featuredArticle.author.avatar, "mini")}
|
src={getAssetUrl(featuredArticle.author?.avatar, "mini")}
|
||||||
alt={featuredArticle.author.first_name}
|
alt={featuredArticle.author?.first_name}
|
||||||
class="w-10 h-10 rounded-full object-cover"
|
class="w-10 h-10 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div>
|
<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">
|
<div class="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
<span>{timeAgo.format(new Date(featuredArticle.publish_date))}</span>
|
<span>{timeAgo.format(new Date(featuredArticle.publish_date))}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<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
|
<a
|
||||||
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
|
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
|
||||||
href="/tags/{tag}"
|
href="/tags/{tag}"
|
||||||
@@ -287,12 +287,12 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(article.author.avatar, "mini")}
|
src={getAssetUrl(article.author?.avatar, "mini")}
|
||||||
alt={article.author.first_name}
|
alt={article.author?.first_name}
|
||||||
class="w-8 h-8 rounded-full object-cover"
|
class="w-8 h-8 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div>
|
<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">
|
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||||
{timeAgo.format(new Date(article.publish_date))}
|
{timeAgo.format(new Date(article.publish_date))}
|
||||||
|
|||||||
@@ -139,6 +139,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Author Bio -->
|
<!-- Author Bio -->
|
||||||
|
{#if data.article.author}
|
||||||
<Card class="p-0 bg-gradient-to-r from-card/50 to-card">
|
<Card class="p-0 bg-gradient-to-r from-card/50 to-card">
|
||||||
<CardContent class="p-6">
|
<CardContent class="p-6">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
@@ -164,15 +165,13 @@
|
|||||||
>
|
>
|
||||||
{data.article.author.website}
|
{data.article.author.website}
|
||||||
</a>
|
</a>
|
||||||
<!-- <a href="https://{data.article.author.social.website}" class="text-primary hover:underline">
|
|
||||||
{data.article.author.social.website}
|
|
||||||
</a> -->
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export async function load({ locals, fetch }) {
|
|||||||
|
|
||||||
const recordings = await getRecordings(fetch).catch(() => []);
|
const recordings = await getRecordings(fetch).catch(() => []);
|
||||||
|
|
||||||
const analytics = isModel(locals.authStatus.user)
|
const analytics = isModel(locals.authStatus.user!)
|
||||||
? await getAnalytics(fetch).catch(() => null)
|
? await getAnalytics(fetch).catch(() => null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
let lastName = $state(data.authStatus.user!.last_name);
|
let lastName = $state(data.authStatus.user!.last_name);
|
||||||
let artistName = $state(data.authStatus.user!.artist_name);
|
let artistName = $state(data.authStatus.user!.artist_name);
|
||||||
let description = $state(data.authStatus.user!.description);
|
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 email = $state(data.authStatus.user!.email);
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
@@ -60,8 +60,8 @@
|
|||||||
|
|
||||||
let avatarId = undefined;
|
let avatarId = undefined;
|
||||||
|
|
||||||
if (!avatar?.id && data.authStatus.user!.avatar?.id) {
|
if (!avatar?.id && data.authStatus.user!.avatar) {
|
||||||
await removeFile(data.authStatus.user!.avatar.id);
|
await removeFile(data.authStatus.user!.avatar);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (avatar?.file) {
|
if (avatar?.file) {
|
||||||
@@ -143,10 +143,10 @@
|
|||||||
function setExistingAvatar() {
|
function setExistingAvatar() {
|
||||||
if (data.authStatus.user!.avatar) {
|
if (data.authStatus.user!.avatar) {
|
||||||
avatar = {
|
avatar = {
|
||||||
id: data.authStatus.user!.avatar.id,
|
id: data.authStatus.user!.avatar,
|
||||||
url: getAssetUrl(data.authStatus.user!.avatar.id, "mini")!,
|
url: getAssetUrl(data.authStatus.user!.avatar, "mini")!,
|
||||||
name: data.authStatus.user!.artist_name,
|
name: data.authStatus.user!.artist_name ?? "",
|
||||||
size: data.authStatus.user!.avatar.filesize,
|
size: 0,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
avatar = undefined;
|
avatar = undefined;
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
.filter((model) => {
|
.filter((model) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
searchQuery === "" ||
|
searchQuery === "" ||
|
||||||
model.artist_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
model.artist_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
model.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
model.tags?.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
const matchesCategory = categoryFilter === "all" || model.category === categoryFilter;
|
const matchesCategory = categoryFilter === "all";
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
// }
|
// }
|
||||||
// if (sortBy === "rating") return b.rating - a.rating;
|
// if (sortBy === "rating") return b.rating - a.rating;
|
||||||
// if (sortBy === "videos") return b.videos - a.videos;
|
// 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>
|
</script>
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="flex items-center justify-between text-sm text-muted-foreground mb-4">
|
<div class="flex items-center justify-between text-sm text-muted-foreground mb-4">
|
||||||
<!-- <span>{model.videos} videos</span> -->
|
<!-- <span>{model.videos} videos</span> -->
|
||||||
<span class="capitalize">{model.category}</span>
|
<!-- category not available -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
let images = $derived(
|
let images = $derived(
|
||||||
data.model.photos.map((p) => ({
|
(data.model.photos ?? []).map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
url: getAssetUrl(p.id),
|
url: getAssetUrl(p.id),
|
||||||
thumbnail: getAssetUrl(p.id, "thumbnail"),
|
thumbnail: getAssetUrl(p.id, "thumbnail"),
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Meta
|
<Meta
|
||||||
title={data.model.artist_name}
|
title={data.model.artist_name ?? ""}
|
||||||
description={data.model.description}
|
description={data.model.description}
|
||||||
image={getAssetUrl(data.model.avatar, "medium")!}
|
image={getAssetUrl(data.model.avatar, "medium")!}
|
||||||
/>
|
/>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
{#if data.model.banner}
|
{#if data.model.banner}
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(data.model.banner, "banner")}
|
src={getAssetUrl(data.model.banner, "banner")}
|
||||||
alt={$_(data.model.artist_name)}
|
alt={data.model.artist_name ?? ""}
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { getRecording } from "$lib/services";
|
import { getRecording } from "$lib/services";
|
||||||
|
import type { Recording } from "$lib/types";
|
||||||
|
|
||||||
export async function load({ locals, url, fetch }) {
|
export async function load({ locals, url, fetch }) {
|
||||||
const recordingId = url.searchParams.get("recording");
|
const recordingId = url.searchParams.get("recording");
|
||||||
|
|
||||||
let recording = null;
|
let recording: Recording | null = null;
|
||||||
if (recordingId && locals.authStatus.authenticated) {
|
if (recordingId && locals.authStatus.authenticated) {
|
||||||
try {
|
try {
|
||||||
recording = await getRecording(recordingId, fetch);
|
recording = await getRecording(recordingId, fetch);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
InputType,
|
InputType,
|
||||||
DeviceOutputValueConstructor,
|
DeviceOutputValueConstructor,
|
||||||
} from "@sexy.pivoine.art/buttplug";
|
} from "@sexy.pivoine.art/buttplug";
|
||||||
import type { ButtplugMessage } from "@sexy.pivoine.art/buttplug";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
import Button from "$lib/components/ui/button/button.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
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;
|
if (msg.InputReading === undefined) return;
|
||||||
const reading = msg.InputReading;
|
const reading = msg.InputReading;
|
||||||
const device = devices.find((d) => d.info.index === reading.DeviceIndex);
|
const device = devices.find((d) => d.info.index === reading.DeviceIndex);
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
if (!feature) return;
|
if (!feature) return;
|
||||||
|
|
||||||
actuator.value = value;
|
actuator.value = value;
|
||||||
const outputType = actuator.outputType as OutputType;
|
const outputType = actuator.outputType as typeof OutputType;
|
||||||
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
|
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
|
||||||
|
|
||||||
// Capture event if recording
|
// Capture event if recording
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if we need to map devices
|
// 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;
|
showMappingDialog = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -284,7 +284,7 @@
|
|||||||
function scheduleNextEvent() {
|
function scheduleNextEvent() {
|
||||||
if (!data.recording || !isPlaying || !playbackStartTime) return;
|
if (!data.recording || !isPlaying || !playbackStartTime) return;
|
||||||
|
|
||||||
const events = data.recording.events;
|
const events = (data.recording.events ?? []) as RecordedEvent[];
|
||||||
if (currentEventIndex >= events.length) {
|
if (currentEventIndex >= events.length) {
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
toast.success("Playback finished");
|
toast.success("Playback finished");
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
// Send command to device via feature
|
// Send command to device via feature
|
||||||
const feature = device.info.features.get(actuator.featureIndex);
|
const feature = device.info.features.get(actuator.featureIndex);
|
||||||
if (feature) {
|
if (feature) {
|
||||||
const outputType = actuator.outputType as OutputType;
|
const outputType = actuator.outputType as typeof OutputType;
|
||||||
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
|
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,9 +347,10 @@
|
|||||||
playbackProgress = targetTime;
|
playbackProgress = targetTime;
|
||||||
|
|
||||||
// Find the event index at this time
|
// 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) {
|
if (currentEventIndex === -1) {
|
||||||
currentEventIndex = data.recording.events.length;
|
currentEventIndex = seekEvents.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlaying) {
|
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 class="mt-4 pt-4 border-t border-border/50 grid grid-cols-3 gap-4 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-muted-foreground">Events</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-muted-foreground">Devices</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-muted-foreground">Status</p>
|
<p class="text-xs text-muted-foreground">Status</p>
|
||||||
@@ -603,7 +604,7 @@
|
|||||||
{#if data.recording}
|
{#if data.recording}
|
||||||
<DeviceMappingDialog
|
<DeviceMappingDialog
|
||||||
open={showMappingDialog}
|
open={showMappingDialog}
|
||||||
recordedDevices={data.recording.device_info}
|
recordedDevices={(data.recording.device_info ?? []) as DeviceInfo[]}
|
||||||
connectedDevices={devices}
|
connectedDevices={devices}
|
||||||
onConfirm={handleMappingConfirm}
|
onConfirm={handleMappingConfirm}
|
||||||
onCancel={handleMappingCancel}
|
onCancel={handleMappingCancel}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const GET = async () => {
|
|||||||
excludeRoutePatterns: ["^/signup/verify", "^/password/reset", "^/me", "^/play", "^/tags/.+"],
|
excludeRoutePatterns: ["^/signup/verify", "^/password/reset", "^/me", "^/play", "^/tags/.+"],
|
||||||
paramValues: {
|
paramValues: {
|
||||||
"/magazine/[slug]": (await getArticles(fetch)).map((a) => a.slug),
|
"/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),
|
"/videos/[slug]": (await getVideos(fetch)).map((a) => a.slug),
|
||||||
},
|
},
|
||||||
defaultChangefreq: "always",
|
defaultChangefreq: "always",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getItemsByTag } from "$lib/services";
|
|||||||
const getItems = (category, tag: string, fetch) => {
|
const getItems = (category, tag: string, fetch) => {
|
||||||
return getItemsByTag(category, fetch).then((items) =>
|
return getItemsByTag(category, fetch).then((items) =>
|
||||||
items
|
items
|
||||||
?.filter((i) => i.tags.includes(tag))
|
?.filter((i) => i.tags?.includes(tag))
|
||||||
.map((i) => ({ ...i, category, title: i["artist_name"] || i["title"] })),
|
.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;
|
achievements_count: number | null;
|
||||||
rank: number;
|
rank: number;
|
||||||
} | null;
|
} | null;
|
||||||
achievements: unknown[];
|
achievements: {
|
||||||
recent_points: unknown[];
|
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;
|
} | null;
|
||||||
}>(USER_PROFILE_QUERY, { id });
|
}>(USER_PROFILE_QUERY, { id });
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<Meta
|
<Meta
|
||||||
title={displayName}
|
title={displayName}
|
||||||
description={data.user.description || `${displayName}'s profile`}
|
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">
|
<div class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
|
||||||
@@ -91,12 +91,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</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}
|
{#if data.user.description}
|
||||||
<p class="text-muted-foreground mb-4">
|
<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="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-center p-4 rounded-lg bg-accent/10">
|
||||||
<div class="text-3xl font-bold text-primary">
|
<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>
|
||||||
<div class="text-sm text-muted-foreground mt-1">
|
<div class="text-sm text-muted-foreground mt-1">
|
||||||
{$_("gamification.points")}
|
{$_("gamification.points")}
|
||||||
@@ -188,7 +183,7 @@
|
|||||||
{$_("gamification.achievements")} ({data.gamification.achievements.length})
|
{$_("gamification.achievements")} ({data.gamification.achievements.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
<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
|
<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"
|
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}
|
title={achievement.description}
|
||||||
@@ -199,7 +194,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{#if achievement.date_unlocked}
|
{#if achievement.date_unlocked}
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground">
|
||||||
{new Date(achievement.date_unlocked).toLocaleDateString($locale)}
|
{new Date(achievement.date_unlocked).toLocaleDateString($locale ?? undefined)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -377,7 +377,7 @@
|
|||||||
<div class="flex gap-3 mb-6">
|
<div class="flex gap-3 mb-6">
|
||||||
<Avatar class="h-8 w-8 ring-2 ring-accent/20 transition-all duration-200">
|
<Avatar class="h-8 w-8 ring-2 ring-accent/20 transition-all duration-200">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={getAssetUrl(data.authStatus.user!.avatar.id, "mini")}
|
src={getAssetUrl(data.authStatus.user!.avatar, "mini")}
|
||||||
alt={data.authStatus.user!.artist_name}
|
alt={data.authStatus.user!.artist_name}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
@@ -432,27 +432,27 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#each data.comments as comment (comment.id)}
|
{#each data.comments as comment (comment.id)}
|
||||||
<div class="flex gap-3">
|
<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
|
<Avatar
|
||||||
class="h-8 w-8 ring-2 ring-accent/20 hover:ring-primary/40 transition-all duration-200 cursor-pointer"
|
class="h-8 w-8 ring-2 ring-accent/20 hover:ring-primary/40 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={getAssetUrl(comment.user_created.avatar as string, "mini")}
|
src={getAssetUrl(comment.user?.avatar, "mini")}
|
||||||
alt={comment.user_created.artist_name}
|
alt={comment.user?.artist_name}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200"
|
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>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<a
|
<a
|
||||||
href="/users/{comment.user_created.id}"
|
href="/users/{comment.user?.id}"
|
||||||
class="font-medium text-sm hover:text-primary transition-colors"
|
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"
|
<span class="text-xs text-muted-foreground"
|
||||||
>{timeAgo.format(new Date(comment.date_created))}</span
|
>{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':
|
'@pothos/plugin-errors':
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.9.0(@pothos/core@4.12.0(graphql@16.13.1))(graphql@16.13.1)
|
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:
|
argon2:
|
||||||
specifier: ^0.43.0
|
specifier: ^0.43.0
|
||||||
version: 0.43.1
|
version: 0.43.1
|
||||||
@@ -151,6 +154,9 @@ importers:
|
|||||||
'@sexy.pivoine.art/buttplug':
|
'@sexy.pivoine.art/buttplug':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../buttplug
|
version: link:../buttplug
|
||||||
|
'@sexy.pivoine.art/types':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../types
|
||||||
graphql:
|
graphql:
|
||||||
specifier: ^16.11.0
|
specifier: ^16.11.0
|
||||||
version: 16.13.1
|
version: 16.13.1
|
||||||
@@ -252,6 +258,12 @@ importers:
|
|||||||
specifier: 3.5.0
|
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))
|
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:
|
packages:
|
||||||
|
|
||||||
'@antfu/install-pkg@1.1.0':
|
'@antfu/install-pkg@1.1.0':
|
||||||
|
|||||||
Reference in New Issue
Block a user