Compare commits
7 Commits
efc7624ba3
...
e200514347
| Author | SHA1 | Date | |
|---|---|---|---|
| e200514347 | |||
| d7057c3681 | |||
| d820a8f6be | |||
| 9bef2469d1 | |||
| 97269788ee | |||
| c6126c13e9 | |||
| fd4050a49f |
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`.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "UPLOAD_DIR=/home/valknar/sexy-uploads DATABASE_URL=postgresql://sexy:sexy@localhost:5432/sexy REDIS_URL=redis://localhost:6379 tsx watch src/index.ts",
|
"dev": "UPLOAD_DIR=../../.data/uploads DATABASE_URL=postgresql://sexy:sexy@localhost:5432/sexy REDIS_URL=redis://localhost:6379 tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
@@ -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"),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -13,17 +13,14 @@ import { schema } from "./graphql/index";
|
|||||||
import { buildContext } from "./graphql/context";
|
import { buildContext } from "./graphql/context";
|
||||||
import { db } from "./db/connection";
|
import { db } from "./db/connection";
|
||||||
import { redis } from "./lib/auth";
|
import { redis } from "./lib/auth";
|
||||||
|
import { logger } from "./lib/logger";
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || "4000");
|
const PORT = parseInt(process.env.PORT || "4000");
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
|
||||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:3000";
|
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:3000";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({ loggerInstance: logger });
|
||||||
logger: {
|
|
||||||
level: process.env.LOG_LEVEL || "info",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await fastify.register(fastifyCookie, {
|
await fastify.register(fastifyCookie, {
|
||||||
secret: process.env.COOKIE_SECRET || "change-me-in-production",
|
secret: process.env.COOKIE_SECRET || "change-me-in-production",
|
||||||
|
|||||||
91
packages/backend/src/lib/logger.ts
Normal file
91
packages/backend/src/lib/logger.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
||||||
|
|
||||||
|
const LEVEL_VALUES: Record<LogLevel, number> = {
|
||||||
|
trace: 10,
|
||||||
|
debug: 20,
|
||||||
|
info: 30,
|
||||||
|
warn: 40,
|
||||||
|
error: 50,
|
||||||
|
fatal: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createLogger(bindings: Record<string, unknown> = {}, initialLevel: LogLevel = "info") {
|
||||||
|
let currentLevel = initialLevel;
|
||||||
|
|
||||||
|
function shouldLog(level: LogLevel): boolean {
|
||||||
|
return LEVEL_VALUES[level] >= LEVEL_VALUES[currentLevel];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessage(level: LogLevel, arg: unknown, msg?: string): string {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
let message: string;
|
||||||
|
const meta: Record<string, unknown> = { ...bindings };
|
||||||
|
|
||||||
|
if (typeof arg === "string") {
|
||||||
|
message = arg;
|
||||||
|
} else if (arg !== null && typeof arg === "object") {
|
||||||
|
// Pino-style: log(obj, msg?) — strip internal pino keys
|
||||||
|
const { msg: m, level: _l, time: _t, pid: _p, hostname: _h, req: _req, res: _res, reqId, ...rest } = arg as Record<string, unknown>;
|
||||||
|
message = msg || (typeof m === "string" ? m : "");
|
||||||
|
if (reqId) meta.reqId = reqId;
|
||||||
|
Object.assign(meta, rest);
|
||||||
|
} else {
|
||||||
|
message = String(arg ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [`[${timestamp}]`, `[${level.toUpperCase()}]`, message];
|
||||||
|
let result = parts.join(" ");
|
||||||
|
|
||||||
|
const metaEntries = Object.entries(meta).filter(([k]) => k !== "reqId");
|
||||||
|
const reqId = meta.reqId;
|
||||||
|
if (reqId) result = `[${timestamp}] [${level.toUpperCase()}] [${reqId}] ${message}`;
|
||||||
|
|
||||||
|
if (metaEntries.length > 0) {
|
||||||
|
result += " " + JSON.stringify(Object.fromEntries(metaEntries));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(level: LogLevel, arg: unknown, msg?: string) {
|
||||||
|
if (!shouldLog(level)) return;
|
||||||
|
const formatted = formatMessage(level, arg, msg);
|
||||||
|
switch (level) {
|
||||||
|
case "trace":
|
||||||
|
case "debug":
|
||||||
|
console.debug(formatted);
|
||||||
|
break;
|
||||||
|
case "info":
|
||||||
|
console.info(formatted);
|
||||||
|
break;
|
||||||
|
case "warn":
|
||||||
|
console.warn(formatted);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
case "fatal":
|
||||||
|
console.error(formatted);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get level() {
|
||||||
|
return currentLevel;
|
||||||
|
},
|
||||||
|
set level(l: string) {
|
||||||
|
currentLevel = l as LogLevel;
|
||||||
|
},
|
||||||
|
trace: (arg: unknown, msg?: string) => write("trace", arg, msg),
|
||||||
|
debug: (arg: unknown, msg?: string) => write("debug", arg, msg),
|
||||||
|
info: (arg: unknown, msg?: string) => write("info", arg, msg),
|
||||||
|
warn: (arg: unknown, msg?: string) => write("warn", arg, msg),
|
||||||
|
error: (arg: unknown, msg?: string) => write("error", arg, msg),
|
||||||
|
fatal: (arg: unknown, msg?: string) => write("fatal", arg, msg),
|
||||||
|
silent: () => {},
|
||||||
|
child: (newBindings: Record<string, unknown>) =>
|
||||||
|
createLogger({ ...bindings, ...newBindings }, currentLevel),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = createLogger({}, (process.env.LOG_LEVEL as LogLevel) || "info");
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
<div class="w-full h-auto">
|
|
||||||
<svg
|
|
||||||
version="1.0"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 1280.000000 904.000000"
|
|
||||||
stroke-width="5"
|
|
||||||
stroke="#ce47eb"
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
>
|
|
||||||
<metadata> Created by potrace 1.15, written by Peter Selinger 2001-2017 </metadata>
|
|
||||||
<g transform="translate(0.000000,904.000000) scale(0.100000,-0.100000)">
|
|
||||||
<path
|
|
||||||
d="M7930 7043 c-73 -10 -95 -18 -134 -51 -25 -20 -66 -53 -91 -72 -26
|
|
||||||
-19 -69 -66 -96 -104 -116 -164 -130 -314 -59 -664 32 -164 36 -217 18 -256
|
|
||||||
-13 -30 -14 -30 -140 -52 -75 -12 -105 -13 -129 -5 -18 6 -59 11 -93 11 -123
|
|
||||||
-1 -213 -66 -379 -275 -245 -308 -501 -567 -686 -693 l-92 -64 -82 7 c-53 5
|
|
||||||
-88 13 -100 23 -21 18 -66 20 -167 7 -73 -9 -124 -31 -159 -69 -22 -23 -23
|
|
||||||
-31 -18 -94 6 -58 4 -71 -11 -84 -44 -40 -203 -119 -295 -149 -56 -18 -144
|
|
||||||
-50 -195 -71 -50 -21 -138 -51 -195 -67 -232 -65 -369 -131 -595 -284 -182
|
|
||||||
-124 -172 -123 -208 -27 -23 60 -39 81 -189 245 -279 305 -319 354 -368 458
|
|
||||||
-46 94 -47 98 -32 127 8 16 15 36 15 43 0 8 14 41 30 72 17 31 30 63 30 70 0
|
|
||||||
7 7 18 15 25 8 7 15 26 15 42 0 42 15 65 49 71 17 4 37 17 46 30 14 23 14 30
|
|
||||||
-9 101 -28 88 -21 130 22 141 20 5 23 10 18 31 -4 13 -1 34 5 46 13 25 33 239
|
|
||||||
31 336 0 42 -8 78 -23 108 -31 65 -121 158 -209 217 -41 28 -77 55 -79 60 -2
|
|
||||||
5 -17 24 -33 43 -23 26 -48 39 -111 58 -183 55 -239 61 -361 36 -156 -33 -333
|
|
||||||
-185 -425 -368 -72 -143 -93 -280 -96 -622 -2 -240 -5 -288 -24 -379 -12 -57
|
|
||||||
-30 -120 -40 -140 -11 -20 -61 -84 -113 -142 -52 -58 -105 -121 -118 -140 -13
|
|
||||||
-19 -45 -58 -72 -88 -93 -106 -127 -193 -237 -616 -33 -127 -67 -251 -76 -275
|
|
||||||
-9 -25 -48 -153 -86 -285 -78 -264 -163 -502 -334 -935 -135 -340 -194 -526
|
|
||||||
-290 -910 -20 -80 -47 -180 -61 -223 -13 -43 -24 -92 -24 -109 0 -42 -43 -79
|
|
||||||
-132 -112 -56 -20 -108 -52 -213 -132 -77 -58 -162 -117 -190 -131 -85 -43
|
|
||||||
-107 -75 -62 -89 12 -3 30 -15 40 -25 10 -11 30 -19 45 -19 29 0 146 52 175
|
|
||||||
77 9 9 19 14 22 12 2 -3 -21 -24 -51 -47 -55 -43 -63 -59 -42 -80 30 -30 130
|
|
||||||
5 198 69 54 52 127 109 139 109 20 0 11 -27 -25 -80 -38 -56 -38 -74 0 -91 33
|
|
||||||
-16 67 7 135 89 31 37 70 71 95 84 l42 20 82 -21 c45 -11 95 -21 111 -21 17 0
|
|
||||||
50 -11 75 -25 58 -32 136 -35 166 -5 35 35 26 57 -40 90 -59 30 -156 132 -186
|
|
||||||
195 -30 63 -31 124 -3 258 43 213 95 336 279 657 126 219 231 423 267 520 14
|
|
||||||
36 40 128 58 205 19 77 50 185 69 240 55 159 182 450 195 447 7 -1 9 7 5 23
|
|
||||||
-10 38 0 30 37 -30 42 -69 60 -53 28 27 -36 92 -39 98 -34 98 3 0 14 -18 25
|
|
||||||
-41 14 -26 26 -39 35 -35 9 3 28 -22 59 -81 65 -121 162 -266 237 -353 35 -41
|
|
||||||
174 -196 309 -345 359 -394 379 -421 409 -549 25 -103 90 -214 169 -287 74
|
|
||||||
-67 203 -135 332 -173 110 -33 472 -112 575 -125 325 -44 688 -30 1453 54 172
|
|
||||||
19 352 35 400 35 112 1 156 11 272 66 139 66 171 103 171 197 0 64 -11 95 -52
|
|
||||||
141 -17 20 -30 38 -28 39 2 1 13 7 24 13 11 6 21 23 23 38 2 14 12 31 23 36
|
|
||||||
12 7 19 21 19 38 0 19 7 30 23 37 14 6 23 21 25 39 2 16 10 36 18 44 10 9 13
|
|
||||||
24 9 41 -4 20 -1 28 16 36 58 26 47 86 -21 106 -38 12 -40 14 -40 51 0 51 -18
|
|
||||||
82 -82 145 -73 70 -132 105 -358 213 -547 260 -919 419 -1210 517 -13 5 -13 6
|
|
||||||
0 10 8 3 22 13 30 22 23 26 363 124 434 125 l60 1 21 -85 c29 -118 59 -175
|
|
||||||
129 -245 118 -117 234 -156 461 -158 171 -1 271 17 445 80 268 96 361 157 602
|
|
||||||
396 93 92 171 159 246 209 155 105 513 381 595 458 131 122 189 224 277 485
|
|
||||||
109 325 149 342 163 70 9 -163 30 -242 143 -531 53 -137 98 -258 101 -270 3
|
|
||||||
-14 -5 -28 -29 -46 -18 -14 -94 -80 -168 -147 -137 -123 -261 -216 -306 -227
|
|
||||||
-17 -4 -46 4 -92 27 -60 29 -80 34 -192 41 -69 4 -144 11 -166 14 -103 15
|
|
||||||
-115 -61 -15 -95 19 -6 46 -11 61 -11 44 0 91 -20 88 -38 -2 -8 -15 -24 -30
|
|
||||||
-35 -22 -17 -30 -18 -42 -7 -21 16 -46 6 -46 -19 0 -25 -29 -35 -110 -35 -57
|
|
||||||
-1 -65 -3 -68 -21 -4 -29 44 -54 120 -62 35 -3 66 -12 71 -19 4 -7 31 -25 59
|
|
||||||
-39 41 -21 60 -24 93 -19 25 3 45 2 49 -4 3 -5 34 -9 69 -7 52 1 72 7 108 32
|
|
||||||
58 40 97 59 135 66 32 6 462 230 516 269 18 12 33 17 35 12 2 -6 30 -62 62
|
|
||||||
-126 l58 -116 -3 -112 c-2 -61 -6 -115 -9 -119 -2 -5 -100 -8 -217 -8 -221 0
|
|
||||||
-452 -23 -868 -88 -85 -13 -225 -33 -310 -45 -189 -26 -314 -52 -440 -92 -203
|
|
||||||
-65 -284 -132 -304 -254 -15 -90 30 -173 137 -251 28 -20 113 -85 187 -142 74
|
|
||||||
-58 171 -129 215 -158 105 -71 324 -181 563 -283 106 -45 194 -86 197 -90 9
|
|
||||||
-14 -260 -265 -361 -337 -100 -71 -130 -102 -188 -193 -16 -24 -53 -73 -82
|
|
||||||
-107 -30 -35 -67 -89 -83 -121 -20 -41 -63 -92 -135 -163 -86 -87 -106 -112
|
|
||||||
-112 -144 -4 -22 -15 -53 -26 -70 -23 -38 -23 -73 -1 -105 39 -56 94 -81 132
|
|
||||||
-60 18 9 21 8 21 -9 0 -33 11 -51 41 -67 20 -10 35 -12 46 -5 13 7 21 3 36
|
|
||||||
-15 11 -14 29 -24 44 -24 15 0 34 -7 44 -16 9 -8 27 -16 40 -16 13 -1 33 -8
|
|
||||||
44 -15 11 -7 29 -13 40 -13 50 0 129 132 140 232 21 203 78 389 136 444 17 16
|
|
||||||
51 56 74 89 89 124 200 212 433 343 l142 81 14 -27 c16 -32 36 -151 36 -220 0
|
|
||||||
-35 6 -54 21 -71 43 -46 143 -68 168 -37 6 8 14 37 18 65 5 46 11 56 47 85 23
|
|
||||||
18 61 44 86 58 91 53 151 145 153 234 0 38 -5 50 -33 79 -19 19 -53 42 -77 51
|
|
||||||
-24 9 -43 19 -43 23 0 3 28 24 62 46 81 52 213 178 298 284 63 79 75 89 148
|
|
||||||
122 l80 37 32 -49 c79 -122 233 -192 370 -170 222 37 395 196 428 396 18 107
|
|
||||||
35 427 30 560 -9 217 -63 344 -223 514 -52 56 -95 106 -95 111 0 5 4 12 10 15
|
|
||||||
55 34 235 523 290 785 10 52 28 118 39 145 10 28 29 103 41 169 27 142 24 271
|
|
||||||
-7 352 -28 72 -115 215 -185 303 -65 82 -118 184 -125 241 -11 82 59 182 93
|
|
||||||
135 9 -12 17 -14 31 -7 10 6 25 7 33 2 8 -4 27 -6 41 -3 28 5 44 45 33 80 -5
|
|
||||||
15 -4 15 4 4 12 -17 17 -6 76 144 39 99 43 100 22 10 -8 -33 -13 -62 -10 -64
|
|
||||||
10 -10 65 154 83 249 6 30 16 80 22 110 19 85 16 216 -5 278 -11 32 -22 50
|
|
||||||
-29 45 -7 -4 -8 0 -3 13 4 10 4 15 0 12 -6 -7 -89 109 -89 124 0 4 -6 13 -14
|
|
||||||
20 -10 10 -12 10 -7 1 14 -24 -10 -13 -40 19 -16 17 -23 27 -15 23 9 -5 12 -4
|
|
||||||
8 2 -11 18 -131 71 -188 82 -50 11 -127 14 -259 12 -25 -1 -57 -7 -72 -15 -17
|
|
||||||
-9 -28 -11 -28 -4 0 6 -9 8 -22 3 -13 -4 -31 -7 -41 -6 -9 0 -15 -4 -12 -9 3
|
|
||||||
-6 0 -7 -8 -4 -20 7 -127 -84 -176 -149 -43 -57 -111 -185 -111 -208 0 -19
|
|
||||||
-55 -135 -69 -143 -6 -4 -11 -12 -11 -18 0 -19 29 13 66 73 19 33 37 59 40 59
|
|
||||||
10 0 -65 -126 -103 -173 -30 -36 -39 -53 -30 -59 9 -6 9 -8 0 -8 -9 0 -10 -7
|
|
||||||
-2 -27 6 -16 10 -29 10 -30 -1 -11 23 -63 29 -63 4 0 20 10 36 22 30 24 26 14
|
|
||||||
-13 -39 -13 -18 -20 -33 -14 -33 19 0 74 65 97 115 13 27 24 43 24 34 0 -25
|
|
||||||
-21 -81 -42 -111 -23 -34 -23 -46 0 -25 18 16 19 14 21 -70 3 -183 25 -289 76
|
|
||||||
-381 26 -46 33 -96 15 -107 -6 -3 -86 -17 -178 -30 -240 -35 -301 -61 -360
|
|
||||||
-152 -62 -96 -73 -147 -83 -378 -9 -214 -20 -312 -32 -285 -20 45 -77 356 -91
|
|
||||||
492 -18 174 -34 243 -72 325 -58 121 -120 163 -243 163 -63 0 -80 3 -85 16
|
|
||||||
-11 29 -6 103 13 196 43 209 51 282 51 479 -1 301 -22 464 -76 571 -32 64
|
|
||||||
-132 168 -191 200 -79 43 -224 72 -303 61z m2438 -421 c18 -14 38 -35 44 -46
|
|
||||||
9 -16 -39 22 -102 82 -11 11 27 -13 58 -36z m142 -188 c17 -52 7 -51 -11 1 -9
|
|
||||||
25 -13 42 -8 40 4 -3 13 -21 19 -41z m-1000 -42 c0 -5 -7 -17 -15 -28 -14 -18
|
|
||||||
-14 -17 -4 9 12 27 19 34 19 19z m1037 -14 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13
|
|
||||||
3 -3 4 -12 1 -19z m10 -40 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13 3 -3 4 -12 1
|
|
||||||
-19z m-53 -327 c-4 -23 -9 -40 -11 -37 -3 3 -2 23 2 46 4 23 9 39 11 37 3 -2
|
|
||||||
2 -23 -2 -46z m-17 -73 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13 3 -3 4 -12 1 -19z
|
|
||||||
m-3487 -790 c-17 -35 -55 -110 -84 -168 -29 -58 -72 -163 -96 -235 -45 -134
|
|
||||||
-64 -175 -84 -175 -6 1 -23 18 -38 40 -31 44 -71 60 -155 60 -29 0 -53 3 -52
|
|
||||||
8 0 4 63 59 141 122 182 149 293 258 347 343 24 37 45 67 47 67 3 0 -10 -28
|
|
||||||
-26 -62z m-4768 -415 c-37 -46 -160 -176 -140 -148 21 29 160 185 165 185 3 0
|
|
||||||
-9 -17 -25 -37z m38 -52 c-11 -21 -30 -37 -30 -25 0 8 30 44 37 44 2 0 -1 -9
|
|
||||||
-7 -19z m1692 -588 c22 -30 39 -56 36 -58 -5 -5 -107 115 -122 143 -15 28 42
|
|
||||||
-29 86 -85z m-100 -108 c6 -11 -13 3 -42 30 -28 28 -56 59 -62 70 -6 11 13 -2
|
|
||||||
42 -30 28 -27 56 -59 62 -70z m1587 -1 c29 -6 22 -10 -71 -40 -57 -19 -128
|
|
||||||
-41 -158 -49 -58 -15 -288 -41 -296 -33 -2 3 23 19 56 37 45 24 98 40 208 61
|
|
||||||
153 29 208 34 261 24z m-860 -1488 c150 -59 299 -94 495 -114 l68 -7 -42 -27
|
|
||||||
-42 -28 -111 20 c-62 11 -196 28 -300 38 -103 10 -189 21 -192 23 -2 3 -1 21
|
|
||||||
4 40 5 19 12 46 15 62 4 15 9 27 13 27 3 0 45 -15 92 -34z m3893 -371 l37 -6
|
|
||||||
-55 -72 c-31 -40 -59 -72 -62 -73 -4 -1 -51 44 -104 100 l-97 101 122 -22 c67
|
|
||||||
-13 139 -25 159 -28z"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
@@ -5,13 +5,12 @@
|
|||||||
import type { AuthStatus } from "$lib/types";
|
import type { AuthStatus } from "$lib/types";
|
||||||
import { logout } from "$lib/services";
|
import { logout } from "$lib/services";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import LogoutButton from "../logout-button/logout-button.svelte";
|
import LogoutButton from "../logout-button/logout-button.svelte";
|
||||||
import Separator from "../ui/separator/separator.svelte";
|
import Separator from "../ui/separator/separator.svelte";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||||
import { getUserInitials } from "$lib/utils";
|
import { getUserInitials } from "$lib/utils";
|
||||||
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
||||||
import Girls from "../girls/girls.svelte";
|
|
||||||
import Logo from "../logo/logo.svelte";
|
import Logo from "../logo/logo.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -78,24 +77,14 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Desktop Login Button -->
|
<!-- Desktop Auth Actions -->
|
||||||
{#if authStatus.authenticated}
|
{#if authStatus.authenticated}
|
||||||
<div class="w-full flex items-center justify-end">
|
<div class="w-full hidden lg:flex items-center justify-end">
|
||||||
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
|
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
|
||||||
<!-- Notifications -->
|
|
||||||
<!-- <Button variant="ghost" size="sm" class="relative h-9 w-9 rounded-full p-0 hover:bg-background/80">
|
|
||||||
<BellIcon class="h-4 w-4" />
|
|
||||||
<Badge class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-gradient-to-r from-primary to-accent p-0 text-xs text-primary-foreground">3</Badge>
|
|
||||||
<span class="sr-only">Notifications</span>
|
|
||||||
</Button> -->
|
|
||||||
|
|
||||||
<!-- <Separator orientation="vertical" class="mx-1 h-6 bg-border/50" /> -->
|
|
||||||
|
|
||||||
<!-- User Actions -->
|
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="icon"
|
size="icon"
|
||||||
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "text-foreground" : "hover:text-foreground"}`}
|
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "text-foreground" : "hover:text-foreground"}`}
|
||||||
href="/me"
|
href="/me"
|
||||||
title={$_("header.dashboard")}
|
title={$_("header.dashboard")}
|
||||||
>
|
>
|
||||||
@@ -109,7 +98,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="icon"
|
size="icon"
|
||||||
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
|
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
|
||||||
href="/play"
|
href="/play"
|
||||||
title={$_("header.play")}
|
title={$_("header.play")}
|
||||||
>
|
>
|
||||||
@@ -120,15 +109,13 @@
|
|||||||
<span class="sr-only">{$_("header.play")}</span>
|
<span class="sr-only">{$_("header.play")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Separator orientation="vertical" class="hidden md:flex mx-1 h-6 bg-border/50" />
|
<Separator orientation="vertical" class="mx-1 h-6 bg-border/50" />
|
||||||
|
|
||||||
<!-- Slide Logout Button -->
|
|
||||||
|
|
||||||
<LogoutButton
|
<LogoutButton
|
||||||
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}
|
||||||
@@ -136,7 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex w-full items-center justify-end gap-4">
|
<div class="hidden lg:flex w-full items-center justify-end gap-4">
|
||||||
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button>
|
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button>
|
||||||
<Button
|
<Button
|
||||||
href="/signup"
|
href="/signup"
|
||||||
@@ -145,6 +132,9 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Burger button — mobile/tablet only -->
|
||||||
|
<div class="lg:hidden ml-auto">
|
||||||
<BurgerMenuButton
|
<BurgerMenuButton
|
||||||
label={$_("header.navigation")}
|
label={$_("header.navigation")}
|
||||||
bind:isMobileMenuOpen
|
bind:isMobileMenuOpen
|
||||||
@@ -152,26 +142,38 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mobile Navigation -->
|
|
||||||
<div
|
|
||||||
class={`border-t border-border/20 bg-background/95 bg-gradient-to-br from-primary to-accent backdrop-blur-xl max-h-[calc(100vh-4rem)] overflow-y-auto shadow-xl/30 transition-all duration-250 ${isMobileMenuOpen ? "opacity-100" : "opacity-0"}`}
|
|
||||||
>
|
|
||||||
{#if isMobileMenuOpen}
|
|
||||||
<div class="container mx-auto grid grid-cols-1 lg:grid-cols-3">
|
|
||||||
<div class="hidden lg:flex col-span-2">
|
|
||||||
<Girls />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="py-6 px-4 space-y-6 lg:col-start-3 border-t border-border/20 bg-background/95">
|
</header>
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
class={`fixed inset-0 z-40 bg-black/60 backdrop-blur-sm transition-opacity duration-300 lg:hidden ${isMobileMenuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
||||||
|
onclick={closeMenu}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Flyout panel -->
|
||||||
|
<div
|
||||||
|
class={`fixed inset-y-0 left-0 z-50 w-80 max-w-[85vw] bg-card/95 backdrop-blur-xl shadow-2xl shadow-primary/20 border-r border-border/30 transform transition-transform duration-300 ease-in-out lg:hidden overflow-y-auto flex flex-col ${isMobileMenuOpen ? "translate-x-0" : "-translate-x-full"}`}
|
||||||
|
aria-hidden={!isMobileMenuOpen}
|
||||||
|
>
|
||||||
|
<!-- Panel header -->
|
||||||
|
<div class="flex items-center px-5 py-4 border-b border-border/30">
|
||||||
|
<Logo hideName={true} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 py-6 px-5 space-y-6">
|
||||||
<!-- User Profile Card -->
|
<!-- User Profile Card -->
|
||||||
{#if authStatus.authenticated}
|
{#if authStatus.authenticated}
|
||||||
<div
|
<div
|
||||||
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4 backdrop-blur-sm"
|
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4"
|
||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"></div>
|
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"></div>
|
||||||
<div class="relative flex items-center gap-4">
|
<div class="relative flex items-center gap-3">
|
||||||
<Avatar class="h-14 w-14 ring-2 ring-primary/30">
|
<Avatar class="h-12 w-12 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
|
||||||
@@ -180,184 +182,146 @@
|
|||||||
{getUserInitials(authStatus.user!.artist_name)}
|
{getUserInitials(authStatus.user!.artist_name)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<div class="flex flex-1 flex-col gap-0.5 min-w-0">
|
||||||
<p class="text-base font-semibold text-foreground">
|
<p class="text-sm font-semibold text-foreground truncate">
|
||||||
{authStatus.user!.artist_name}
|
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
{authStatus.user!.email}
|
{authStatus.user!.email}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-2 mt-1">
|
<div class="flex items-center gap-1.5 mt-0.5">
|
||||||
<div class="h-2 w-2 rounded-full bg-green-500"></div>
|
<div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
|
||||||
<span class="text-xs text-muted-foreground">Online</span>
|
<span class="text-xs text-muted-foreground">Online</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Notifications Badge -->
|
|
||||||
<!-- <Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="relative h-10 w-10 rounded-full p-0"
|
|
||||||
>
|
|
||||||
<BellIcon class="h-4 w-4" />
|
|
||||||
<Badge
|
|
||||||
class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-gradient-to-r from-primary to-accent p-0 text-xs text-primary-foreground"
|
|
||||||
>3</Badge
|
|
||||||
>
|
|
||||||
</Button> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Navigation Cards -->
|
|
||||||
<div class="space-y-3">
|
<!-- Navigation -->
|
||||||
|
<div class="space-y-2">
|
||||||
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
{$_("header.navigation")}
|
{$_("header.navigation")}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-1.5">
|
||||||
{#each navLinks as link (link.href)}
|
{#each navLinks as link (link.href)}
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
class="flex items-center justify-between rounded-xl border border-border/50 bg-card/50 p-4 backdrop-blur-sm transition-all hover:bg-card hover:border-primary/20 {isActiveLink(
|
class={`flex items-center justify-between rounded-xl border px-4 py-3 transition-all duration-200 hover:border-primary/30 hover:bg-primary/5 ${
|
||||||
link,
|
isActiveLink(link)
|
||||||
)
|
? "border-primary/40 bg-primary/8 text-foreground"
|
||||||
? 'border-primary/30 bg-primary/5'
|
: "border-border/40 bg-card/50 text-foreground/85"
|
||||||
: ''}"
|
}`}
|
||||||
onclick={() => (isMobileMenuOpen = false)}
|
onclick={closeMenu}
|
||||||
>
|
>
|
||||||
<span class="font-medium text-foreground">{link.name}</span>
|
<span class="font-medium text-sm">{link.name}</span>
|
||||||
<div class="flex items-center gap-2">
|
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||||
<!-- {#if isActiveLink(link)}
|
|
||||||
<div class="h-2 w-2 rounded-full bg-primary"></div>
|
|
||||||
{/if} -->
|
|
||||||
<span class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Account Actions -->
|
<!-- Account -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-2">
|
||||||
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
{$_("header.account")}
|
{$_("header.account")}
|
||||||
</h3>
|
</h3>
|
||||||
|
<div class="grid gap-1.5">
|
||||||
<div class="grid gap-2">
|
|
||||||
{#if authStatus.authenticated}
|
{#if authStatus.authenticated}
|
||||||
<a
|
<a
|
||||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/me" }) ? "border-primary/30 bg-primary/5" : ""}`}
|
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/me" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
||||||
href="/me"
|
href="/me"
|
||||||
onclick={closeMenu}
|
onclick={closeMenu}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10"
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="icon-[ri--dashboard-2-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
class="icon-[ri--dashboard-2-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<div class="flex flex-1 flex-col gap-0.5">
|
||||||
<div class="flex items-center gap-2">
|
<span class="text-sm font-medium text-foreground">{$_("header.dashboard")}</span>
|
||||||
<span class="font-medium text-foreground">{$_("header.dashboard")}</span>
|
<span class="text-xs text-muted-foreground">{$_("header.dashboard_hint")}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-muted-foreground">{$_("header.dashboard_hint")}</span>
|
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
|
||||||
></span>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/play" }) ? "border-primary/30 bg-primary/5" : ""}`}
|
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/play" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
||||||
href="/play"
|
href="/play"
|
||||||
onclick={closeMenu}
|
onclick={closeMenu}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10"
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="icon-[ri--rocket-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
class="icon-[ri--rocket-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<div class="flex flex-1 flex-col gap-0.5">
|
||||||
<div class="flex items-center gap-2">
|
<span class="text-sm font-medium text-foreground">{$_("header.play")}</span>
|
||||||
<span class="font-medium text-foreground">{$_("header.play")}</span>
|
<span class="text-xs text-muted-foreground">{$_("header.play_hint")}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-muted-foreground">{$_("header.play_hint")}</span>
|
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
|
||||||
></span>
|
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<a
|
<a
|
||||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/login" }) ? "border-primary/30 bg-primary/5" : ""}`}
|
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/login" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
||||||
href="/login"
|
href="/login"
|
||||||
onclick={closeMenu}
|
onclick={closeMenu}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10"
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="icon-[ri--login-circle-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
class="icon-[ri--login-circle-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<div class="flex flex-1 flex-col gap-0.5">
|
||||||
<div class="flex items-center gap-2">
|
<span class="text-sm font-medium text-foreground">{$_("header.login")}</span>
|
||||||
<span class="font-medium text-foreground">{$_("header.login")}</span>
|
<span class="text-xs text-muted-foreground">{$_("header.login_hint")}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-muted-foreground">{$_("header.login_hint")}</span>
|
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
|
||||||
></span>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/signup" }) ? "border-primary/30 bg-primary/5" : ""}`}
|
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/signup" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
||||||
href="/signup"
|
href="/signup"
|
||||||
onclick={closeMenu}
|
onclick={closeMenu}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10"
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-accent/10 transition-colors"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="icon-[ri--heart-add-2-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
class="icon-[ri--heart-add-2-line] h-4 w-4 text-muted-foreground group-hover:text-accent transition-colors"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<div class="flex flex-1 flex-col gap-0.5">
|
||||||
<div class="flex items-center gap-2">
|
<span class="text-sm font-medium text-foreground">{$_("header.signup")}</span>
|
||||||
<span class="font-medium text-foreground">{$_("header.signup")}</span>
|
<span class="text-xs text-muted-foreground">{$_("header.signup_hint")}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-muted-foreground">{$_("header.signup_hint")}</span>
|
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
|
||||||
></span>
|
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if authStatus.authenticated}
|
{#if authStatus.authenticated}
|
||||||
<!-- Logout Button -->
|
|
||||||
<button
|
<button
|
||||||
class="cursor-pointer flex w-full items-center gap-4 rounded-xl border border-destructive/20 bg-destructive/5 p-4 text-left backdrop-blur-sm transition-all hover:bg-destructive/10 hover:border-destructive/30 group"
|
class="cursor-pointer flex w-full items-center gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 transition-all duration-200 hover:bg-destructive/10 hover:border-destructive/30 group"
|
||||||
onclick={handleLogout}
|
onclick={handleLogout}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-destructive/10 group-hover:bg-destructive/20 transition-all"
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-destructive/10 group-hover:bg-destructive/20 transition-colors"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"></span>
|
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<div class="flex flex-1 flex-col gap-0.5 text-left">
|
||||||
<span class="font-medium text-foreground">{$_("header.logout")}</span>
|
<span class="text-sm font-medium text-foreground">{$_("header.logout")}</span>
|
||||||
<span class="text-sm text-muted-foreground">{$_("header.logout_hint")}</span>
|
<span class="text-xs text-muted-foreground">{$_("header.logout_hint")}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
// Re-export from api.ts for backwards compatibility
|
|
||||||
// All components that import from $lib/directus continue to work
|
|
||||||
export {
|
|
||||||
apiUrl as directusApiUrl,
|
|
||||||
getAssetUrl,
|
|
||||||
isModel,
|
|
||||||
getGraphQLClient as getDirectusInstance,
|
|
||||||
} from "./api.js";
|
|
||||||
@@ -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(" ")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
import { formatVideoDuration } from "$lib/utils.js";
|
import { formatVideoDuration } from "$lib/utils.js";
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
|
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
import type { Article } from "$lib/types";
|
import type { Article } from "$lib/types";
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import { calcReadingTime } from "$lib/utils.js";
|
import { calcReadingTime } from "$lib/utils.js";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
@@ -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))}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { calcReadingTime } from "$lib/utils";
|
import { calcReadingTime } from "$lib/utils";
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||||
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import { getAnalytics, getFolders, getRecordings } from "$lib/services";
|
import { getAnalytics, getFolders, getRecordings } from "$lib/services";
|
||||||
import { isModel } from "$lib/directus";
|
import { isModel } from "$lib/api";
|
||||||
|
|
||||||
export async function load({ locals, fetch }) {
|
export async function load({ locals, fetch }) {
|
||||||
// Redirect to login if not authenticated
|
// Redirect to login if not authenticated
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto, invalidateAll } from "$app/navigation";
|
import { goto, invalidateAll } from "$app/navigation";
|
||||||
import { getAssetUrl, isModel } from "$lib/directus";
|
import { getAssetUrl, isModel } from "$lib/api";
|
||||||
import * as Alert from "$lib/components/ui/alert";
|
import * as Alert from "$lib/components/ui/alert";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { deleteRecording, removeFile, updateProfile, uploadFile } from "$lib/services";
|
import { deleteRecording, removeFile, updateProfile, uploadFile } from "$lib/services";
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs";
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||||
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
||||||
@@ -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
|
||||||
@@ -127,10 +127,10 @@
|
|||||||
|
|
||||||
recordedEvents.push({
|
recordedEvents.push({
|
||||||
timestamp,
|
timestamp,
|
||||||
deviceIndex: device.info.index,
|
device_index: device.info.index,
|
||||||
deviceName: device.name,
|
device_name: device.name,
|
||||||
actuatorIndex: actuatorIdx,
|
actuator_index: actuatorIdx,
|
||||||
actuatorType: actuator.outputType,
|
actuator_type: actuator.outputType,
|
||||||
value: (value / actuator.maxSteps) * 100, // Normalize to 0-100
|
value: (value / actuator.maxSteps) * 100, // Normalize to 0-100
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
@@ -313,16 +313,16 @@
|
|||||||
|
|
||||||
function executeEvent(event: RecordedEvent) {
|
function executeEvent(event: RecordedEvent) {
|
||||||
// Get mapped device
|
// Get mapped device
|
||||||
const device = deviceMappings.get(event.deviceName);
|
const device = deviceMappings.get(event.device_name);
|
||||||
if (!device) {
|
if (!device) {
|
||||||
console.warn(`No device mapping for: ${event.deviceName}`);
|
console.warn(`No device mapping for: ${event.device_name}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find matching actuator by type
|
// Find matching actuator by type
|
||||||
const actuator = device.actuators.find((a) => a.outputType === event.actuatorType);
|
const actuator = device.actuators.find((a) => a.outputType === event.actuator_type);
|
||||||
if (!actuator) {
|
if (!actuator) {
|
||||||
console.warn(`Actuator type ${event.actuatorType} not found on ${device.name}`);
|
console.warn(`Actuator type ${event.actuator_type} not found on ${device.name}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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"] })),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { _, locale } from "svelte-i18n";
|
import { _, locale } from "svelte-i18n";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
import { formatVideoDuration } from "$lib/utils";
|
import { formatVideoDuration } from "$lib/utils";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import "media-chrome";
|
import "media-chrome";
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||||
@@ -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;
|
||||||
|
device_index: number;
|
||||||
|
device_name: string;
|
||||||
|
actuator_index: number;
|
||||||
|
actuator_type: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
name: string;
|
||||||
|
index: number;
|
||||||
|
capabilities: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recording {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
slug: string;
|
||||||
|
duration: number;
|
||||||
|
events: object[] | null;
|
||||||
|
device_info: object[] | null;
|
||||||
|
user_id: string;
|
||||||
|
status: "draft" | "published" | "archived";
|
||||||
|
tags: string[] | null;
|
||||||
|
linked_video: string | null;
|
||||||
|
featured: boolean | null;
|
||||||
|
public: boolean | null;
|
||||||
|
original_recording_id?: string | null;
|
||||||
|
date_created: Date;
|
||||||
|
date_updated: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Video interactions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface VideoLikeStatus {
|
||||||
|
liked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPlayRecord {
|
||||||
|
id: string;
|
||||||
|
video_id: string;
|
||||||
|
duration_watched?: number;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoLikeResponse {
|
||||||
|
liked: boolean;
|
||||||
|
likes_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPlayResponse {
|
||||||
|
success: boolean;
|
||||||
|
play_id: string;
|
||||||
|
plays_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Analytics ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface VideoAnalytics {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
upload_date: Date;
|
||||||
|
likes: number;
|
||||||
|
plays: number;
|
||||||
|
completed_plays: number;
|
||||||
|
completion_rate: number;
|
||||||
|
avg_watch_time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Analytics {
|
||||||
|
total_videos: number;
|
||||||
|
total_likes: number;
|
||||||
|
total_plays: number;
|
||||||
|
plays_by_date: Record<string, number>;
|
||||||
|
likes_by_date: Record<string, number>;
|
||||||
|
videos: VideoAnalytics[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Gamification ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LeaderboardEntry {
|
||||||
|
user_id: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
total_weighted_points: number | null;
|
||||||
|
total_raw_points: number | null;
|
||||||
|
recordings_count: number | null;
|
||||||
|
playbacks_count: number | null;
|
||||||
|
achievements_count: number | null;
|
||||||
|
rank: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStats {
|
||||||
|
user_id: string;
|
||||||
|
total_raw_points: number | null;
|
||||||
|
total_weighted_points: number | null;
|
||||||
|
recordings_count: number | null;
|
||||||
|
playbacks_count: number | null;
|
||||||
|
comments_count: number | null;
|
||||||
|
achievements_count: number | null;
|
||||||
|
rank: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAchievement {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
category: string | null;
|
||||||
|
date_unlocked: Date;
|
||||||
|
progress: number | null;
|
||||||
|
required_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentPoint {
|
||||||
|
action: string;
|
||||||
|
points: number;
|
||||||
|
date_created: Date;
|
||||||
|
recording_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserGamification {
|
||||||
|
stats: UserStats | null;
|
||||||
|
achievements: UserAchievement[];
|
||||||
|
recent_points: RecentPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Achievement {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
category: string | null;
|
||||||
|
required_count: number;
|
||||||
|
points_reward: number;
|
||||||
|
}
|
||||||
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