style: apply prettier formatting to all files
All checks were successful
Build and Push Backend Image / build (push) Successful in 46s
Build and Push Frontend Image / build (push) Successful in 5m12s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 22:27:54 +01:00
parent 18116072c9
commit efc7624ba3
184 changed files with 10327 additions and 10220 deletions

View File

@@ -6,7 +6,7 @@ on:
- main - main
- develop - develop
tags: tags:
- 'v*.*.*' - "v*.*.*"
pull_request: pull_request:
branches: branches:
- main - main

View File

@@ -6,7 +6,7 @@ on:
- main - main
- develop - develop
tags: tags:
- 'v*.*.*' - "v*.*.*"
pull_request: pull_request:
branches: branches:
- main - main

View File

@@ -4,7 +4,7 @@
![sexy lips tongue mouth american apparel moist lip gloss ](https://i.gifer.com/1pYe.gif) ![sexy lips tongue mouth american apparel moist lip gloss ](https://i.gifer.com/1pYe.gif)
*"Lust und Liebe gehören zusammen - wer das eine verteufelt, zerstört auch das andere."* _"Lust und Liebe gehören zusammen - wer das eine verteufelt, zerstört auch das andere."_
**Beate Uhse**, Pionierin der sexuellen Befreiung ✈️ **Beate Uhse**, Pionierin der sexuellen Befreiung ✈️
--- ---
@@ -104,10 +104,10 @@ docker compose up -d
**Prerequisites:** **Prerequisites:**
1. Node.js 20.19.1 — *the foundation* 1. Node.js 20.19.1 — _the foundation_
2. `corepack enable`*unlock the tools* 2. `corepack enable`_unlock the tools_
3. `pnpm install`*gather your ingredients* 3. `pnpm install`_gather your ingredients_
4. PostgreSQL 16 + Redis — *the data lovers* 4. PostgreSQL 16 + Redis — _the data lovers_
**Start your pleasure journey:** **Start your pleasure journey:**
@@ -198,13 +198,13 @@ Every request:
Assets are transformed on first request and cached as WebP: Assets are transformed on first request and cached as WebP:
| Preset | Size | Fit | Use | | Preset | Size | Fit | Use |
|--------|------|-----|-----| | ----------- | ----------- | ------ | ---------------- |
| `mini` | 80×80 | cover | Avatars in lists | | `mini` | 80×80 | cover | Avatars in lists |
| `thumbnail` | 300×300 | cover | Profile photos | | `thumbnail` | 300×300 | cover | Profile photos |
| `preview` | 800px wide | inside | Video teasers | | `preview` | 800px wide | inside | Video teasers |
| `medium` | 1400px wide | inside | Full-size images | | `medium` | 1400px wide | inside | Full-size images |
| `banner` | 1600×480 | cover | Profile banners | | `banner` | 1600×480 | cover | Profile banners |
--- ---
@@ -276,33 +276,33 @@ graph LR
### Backend (required) ### Backend (required)
| Variable | Description | | Variable | Description |
|----------|-------------| | --------------- | ----------------------------- |
| `DATABASE_URL` | PostgreSQL connection string | | `DATABASE_URL` | PostgreSQL connection string |
| `REDIS_URL` | Redis connection string | | `REDIS_URL` | Redis connection string |
| `COOKIE_SECRET` | Session cookie signing secret | | `COOKIE_SECRET` | Session cookie signing secret |
| `CORS_ORIGIN` | Allowed frontend origin | | `CORS_ORIGIN` | Allowed frontend origin |
| `UPLOAD_DIR` | Path for uploaded files | | `UPLOAD_DIR` | Path for uploaded files |
### Backend (optional) ### Backend (optional)
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| | ------------ | ------- | ------------------------------ |
| `PORT` | `4000` | Backend listen port | | `PORT` | `4000` | Backend listen port |
| `LOG_LEVEL` | `info` | Fastify log level | | `LOG_LEVEL` | `info` | Fastify log level |
| `SMTP_HOST` | — | Email server for auth flows | | `SMTP_HOST` | — | Email server for auth flows |
| `SMTP_PORT` | `587` | Email server port | | `SMTP_PORT` | `587` | Email server port |
| `EMAIL_FROM` | — | Sender address | | `EMAIL_FROM` | — | Sender address |
| `PUBLIC_URL` | — | Frontend URL (for email links) | | `PUBLIC_URL` | — | Frontend URL (for email links) |
### Frontend ### Frontend
| Variable | Description | | Variable | Description |
|----------|-------------| | --------------------- | --------------------------------------------- |
| `PUBLIC_API_URL` | Backend URL (e.g. `http://sexy_backend:4000`) | | `PUBLIC_API_URL` | Backend URL (e.g. `http://sexy_backend:4000`) |
| `PUBLIC_URL` | Frontend public URL | | `PUBLIC_URL` | Frontend public URL |
| `PUBLIC_UMAMI_ID` | Umami analytics site ID (optional) | | `PUBLIC_UMAMI_ID` | Umami analytics site ID (optional) |
| `PUBLIC_UMAMI_SCRIPT` | Umami script URL (optional) | | `PUBLIC_UMAMI_SCRIPT` | Umami script URL (optional) |
--- ---
@@ -314,23 +314,23 @@ graph LR
**[Palina](https://sexy.pivoine.art) & [Valknar](https://sexy.pivoine.art)** **[Palina](https://sexy.pivoine.art) & [Valknar](https://sexy.pivoine.art)**
*Für die Mäuse...* 🐭💕 _Für die Mäuse..._ 🐭💕
--- ---
### 🙏 Built With ### 🙏 Built With
| Technology | Purpose | | Technology | Purpose |
|------------|---------| | --------------------------------------------------------- | -------------------- |
| [SvelteKit](https://kit.svelte.dev/) | Frontend framework | | [SvelteKit](https://kit.svelte.dev/) | Frontend framework |
| [Fastify](https://fastify.dev/) | HTTP server | | [Fastify](https://fastify.dev/) | HTTP server |
| [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server) | GraphQL server | | [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server) | GraphQL server |
| [Pothos](https://pothos-graphql.dev/) | Code-first schema | | [Pothos](https://pothos-graphql.dev/) | Code-first schema |
| [Drizzle ORM](https://orm.drizzle.team/) | Database | | [Drizzle ORM](https://orm.drizzle.team/) | Database |
| [Sharp](https://sharp.pixelplumbing.com/) | Image transforms | | [Sharp](https://sharp.pixelplumbing.com/) | Image transforms |
| [Buttplug.io](https://buttplug.io/) | Hardware | | [Buttplug.io](https://buttplug.io/) | Hardware |
| [bits-ui](https://www.bits-ui.com/) | UI components | | [bits-ui](https://www.bits-ui.com/) | UI components |
| [Gitea](https://dev.pivoine.art) | Self-hosted VCS & CI | | [Gitea](https://dev.pivoine.art) | Self-hosted VCS & CI |
--- ---
@@ -339,7 +339,7 @@ graph LR
Pioneer of sexual liberation (1919-2001) Pioneer of sexual liberation (1919-2001)
Pilot, Entrepreneur, Freedom Fighter Pilot, Entrepreneur, Freedom Fighter
*"Eine Frau, die ihre Sexualität selbstbestimmt lebt, ist eine freie Frau."* _"Eine Frau, die ihre Sexualität selbstbestimmt lebt, ist eine freie Frau."_
![Beate Uhse Quote](https://img.shields.io/badge/Beate_Uhse-Sexual_Liberation_Pioneer-FF1493?style=for-the-badge&logo=heart&logoColor=white&labelColor=8B008B) ![Beate Uhse Quote](https://img.shields.io/badge/Beate_Uhse-Sexual_Liberation_Pioneer-FF1493?style=for-the-badge&logo=heart&logoColor=white&labelColor=8B008B)
@@ -381,7 +381,7 @@ Pilot, Entrepreneur, Freedom Fighter
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝
</pre> </pre>
*Pleasure is a human right. Technology is freedom. Together, they are power.* _Pleasure is a human right. Technology is freedom. Together, they are power._
**[sexy.pivoine.art](https://sexy.pivoine.art)** | © 2025 Palina & Valknar **[sexy.pivoine.art](https://sexy.pivoine.art)** | © 2025 Palina & Valknar

View File

@@ -1,48 +1,49 @@
{ {
"name": "sexy.pivoine.art", "name": "sexy.pivoine.art",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"build:frontend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/frontend build", "build:frontend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/frontend build",
"build:backend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/backend build", "build:backend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/backend build",
"dev:data": "docker compose up -d postgres redis", "dev:data": "docker compose up -d postgres redis",
"dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev", "dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev",
"dev": "pnpm dev:data && pnpm dev:backend & pnpm --filter @sexy.pivoine.art/frontend dev", "dev": "pnpm dev:data && pnpm dev:backend & pnpm --filter @sexy.pivoine.art/frontend dev",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"check": "pnpm -r --filter=!sexy.pivoine.art check" "check": "pnpm -r --filter=!sexy.pivoine.art check"
}, },
"keywords": [], "keywords": [],
"author": { "author": {
"name": "Valknar", "name": "Valknar",
"email": "valknar@pivoine.art" "email": "valknar@pivoine.art"
}, },
"license": "MIT", "license": "MIT",
"packageManager": "pnpm@10.19.0", "packageManager": "pnpm@10.19.0",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"argon2", "argon2",
"es5-ext", "es5-ext",
"esbuild", "esbuild",
"svelte-preprocess", "svelte-preprocess",
"wasm-pack" "wasm-pack"
], ],
"ignoredBuiltDependencies": [ "ignoredBuiltDependencies": [
"@tailwindcss/oxide", "@tailwindcss/oxide",
"node-sass" "node-sass"
] ]
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"eslint": "^10.0.2", "eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.15.0", "eslint-plugin-svelte": "^3.15.0",
"globals": "^17.4.0", "globals": "^17.4.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript-eslint": "^8.56.1" "prettier-plugin-svelte": "^3.5.1",
} "typescript-eslint": "^8.56.1"
}
} }

View File

@@ -1,18 +1,13 @@
import { import { pgTable, text, timestamp, boolean, index, uniqueIndex } from "drizzle-orm/pg-core";
pgTable,
text,
timestamp,
boolean,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { users } from "./users"; import { users } from "./users";
import { files } from "./files"; import { files } from "./files";
export const articles = pgTable( export const articles = pgTable(
"articles", "articles",
{ {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
slug: text("slug").notNull(), slug: text("slug").notNull(),
title: text("title").notNull(), title: text("title").notNull(),
excerpt: text("excerpt"), excerpt: text("excerpt"),

View File

@@ -1,10 +1,4 @@
import { import { pgTable, text, timestamp, index, integer } from "drizzle-orm/pg-core";
pgTable,
text,
timestamp,
index,
integer,
} from "drizzle-orm/pg-core";
import { users } from "./users"; import { users } from "./users";
export const comments = pgTable( export const comments = pgTable(

View File

@@ -1,16 +1,11 @@
import { import { pgTable, text, timestamp, bigint, integer, index } from "drizzle-orm/pg-core";
pgTable,
text,
timestamp,
bigint,
integer,
index,
} from "drizzle-orm/pg-core";
export const files = pgTable( export const files = pgTable(
"files", "files",
{ {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
title: text("title"), title: text("title"),
description: text("description"), description: text("description"),
filename: text("filename").notNull(), filename: text("filename").notNull(),

View File

@@ -11,15 +11,14 @@ import {
import { users } from "./users"; import { users } from "./users";
import { recordings } from "./recordings"; import { recordings } from "./recordings";
export const achievementStatusEnum = pgEnum("achievement_status", [ export const achievementStatusEnum = pgEnum("achievement_status", ["draft", "published"]);
"draft",
"published",
]);
export const achievements = pgTable( export const achievements = pgTable(
"achievements", "achievements",
{ {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
code: text("code").notNull(), code: text("code").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
description: text("description"), description: text("description"),

View File

@@ -12,16 +12,14 @@ import {
import { users } from "./users"; import { users } from "./users";
import { videos } from "./videos"; import { videos } from "./videos";
export const recordingStatusEnum = pgEnum("recording_status", [ export const recordingStatusEnum = pgEnum("recording_status", ["draft", "published", "archived"]);
"draft",
"published",
"archived",
]);
export const recordings = pgTable( export const recordings = pgTable(
"recordings", "recordings",
{ {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
title: text("title").notNull(), title: text("title").notNull(),
description: text("description"), description: text("description"),
slug: text("slug").notNull(), slug: text("slug").notNull(),
@@ -53,7 +51,9 @@ export const recordings = pgTable(
export const recording_plays = pgTable( export const recording_plays = pgTable(
"recording_plays", "recording_plays",
{ {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
recording_id: text("recording_id") recording_id: text("recording_id")
.notNull() .notNull()
.references(() => recordings.id, { onDelete: "cascade" }), .references(() => recordings.id, { onDelete: "cascade" }),

View File

@@ -15,7 +15,9 @@ export const roleEnum = pgEnum("user_role", ["model", "viewer", "admin"]);
export const users = pgTable( export const users = pgTable(
"users", "users",
{ {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
email: text("email").notNull(), email: text("email").notNull(),
password_hash: text("password_hash").notNull(), password_hash: text("password_hash").notNull(),
first_name: text("first_name"), first_name: text("first_name"),

View File

@@ -14,7 +14,9 @@ import { files } from "./files";
export const videos = pgTable( export const videos = pgTable(
"videos", "videos",
{ {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
slug: text("slug").notNull(), slug: text("slug").notNull(),
title: text("title").notNull(), title: text("title").notNull(),
description: text("description"), description: text("description"),
@@ -50,7 +52,9 @@ export const video_models = pgTable(
export const video_likes = pgTable( export const video_likes = pgTable(
"video_likes", "video_likes",
{ {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
video_id: text("video_id") video_id: text("video_id")
.notNull() .notNull()
.references(() => videos.id, { onDelete: "cascade" }), .references(() => videos.id, { onDelete: "cascade" }),
@@ -68,7 +72,9 @@ export const video_likes = pgTable(
export const video_plays = pgTable( export const video_plays = pgTable(
"video_plays", "video_plays",
{ {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
video_id: text("video_id") video_id: text("video_id")
.notNull() .notNull()
.references(() => videos.id, { onDelete: "cascade" }), .references(() => videos.id, { onDelete: "cascade" }),

View File

@@ -21,7 +21,12 @@ builder.queryField("commentsForVideo", (t) =>
return Promise.all( return Promise.all(
commentList.map(async (c: any) => { commentList.map(async (c: any) => {
const user = await ctx.db const user = await ctx.db
.select({ id: users.id, first_name: users.first_name, last_name: users.last_name, avatar: users.avatar }) .select({
id: users.id,
first_name: users.first_name,
last_name: users.last_name,
avatar: users.avatar,
})
.from(users) .from(users)
.where(eq(users.id, c.user_id)) .where(eq(users.id, c.user_id))
.limit(1); .limit(1);
@@ -57,7 +62,12 @@ builder.mutationField("createCommentForVideo", (t) =>
await checkAchievements(ctx.db, ctx.currentUser.id, "social"); await checkAchievements(ctx.db, ctx.currentUser.id, "social");
const user = await ctx.db const user = await ctx.db
.select({ id: users.id, first_name: users.first_name, last_name: users.last_name, avatar: users.avatar }) .select({
id: users.id,
first_name: users.first_name,
last_name: users.last_name,
avatar: users.avatar,
})
.from(users) .from(users)
.where(eq(users.id, ctx.currentUser.id)) .where(eq(users.id, ctx.currentUser.id))
.limit(1); .limit(1);

View File

@@ -1,6 +1,12 @@
import { builder } from "../builder"; import { builder } from "../builder";
import { LeaderboardEntryType, UserGamificationType, AchievementType } from "../types/index"; import { LeaderboardEntryType, UserGamificationType, AchievementType } from "../types/index";
import { user_stats, users, user_achievements, achievements, user_points } from "../../db/schema/index"; import {
user_stats,
users,
user_achievements,
achievements,
user_points,
} from "../../db/schema/index";
import { eq, desc, gt, count, isNotNull, and } from "drizzle-orm"; import { eq, desc, gt, count, isNotNull, and } from "drizzle-orm";
builder.queryField("leaderboard", (t) => builder.queryField("leaderboard", (t) =>
@@ -73,7 +79,12 @@ builder.queryField("userGamification", (t) =>
}) })
.from(user_achievements) .from(user_achievements)
.leftJoin(achievements, eq(user_achievements.achievement_id, achievements.id)) .leftJoin(achievements, eq(user_achievements.achievement_id, achievements.id))
.where(and(eq(user_achievements.user_id, args.userId), isNotNull(user_achievements.date_unlocked))) .where(
and(
eq(user_achievements.user_id, args.userId),
isNotNull(user_achievements.date_unlocked),
),
)
.orderBy(desc(user_achievements.date_unlocked)); .orderBy(desc(user_achievements.date_unlocked));
const recentPoints = await ctx.db const recentPoints = await ctx.db

View File

@@ -162,11 +162,13 @@ builder.mutationField("updateRecording", (t) =>
updates.title = args.title; updates.title = args.title;
updates.slug = slugify(args.title); updates.slug = slugify(args.title);
} }
if (args.description !== null && args.description !== undefined) updates.description = args.description; if (args.description !== null && args.description !== undefined)
updates.description = args.description;
if (args.tags !== null && args.tags !== undefined) updates.tags = args.tags; if (args.tags !== null && args.tags !== undefined) updates.tags = args.tags;
if (args.status !== null && args.status !== undefined) updates.status = args.status; if (args.status !== null && args.status !== undefined) updates.status = args.status;
if (args.public !== null && args.public !== undefined) updates.public = args.public; if (args.public !== null && args.public !== undefined) updates.public = args.public;
if (args.linkedVideoId !== null && args.linkedVideoId !== undefined) updates.linked_video = args.linkedVideoId; if (args.linkedVideoId !== null && args.linkedVideoId !== undefined)
updates.linked_video = args.linkedVideoId;
const updated = await ctx.db const updated = await ctx.db
.update(recordings) .update(recordings)
@@ -319,11 +321,20 @@ builder.mutationField("updateRecordingPlay", (t) =>
await ctx.db await ctx.db
.update(recording_plays) .update(recording_plays)
.set({ duration_played: args.durationPlayed, completed: args.completed, date_updated: new Date() }) .set({
duration_played: args.durationPlayed,
completed: args.completed,
date_updated: new Date(),
})
.where(eq(recording_plays.id, args.playId)); .where(eq(recording_plays.id, args.playId));
if (args.completed && !wasCompleted && ctx.currentUser) { if (args.completed && !wasCompleted && ctx.currentUser) {
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_COMPLETE", existing[0].recording_id); await awardPoints(
ctx.db,
ctx.currentUser.id,
"RECORDING_COMPLETE",
existing[0].recording_id,
);
await checkAchievements(ctx.db, ctx.currentUser.id, "playback"); await checkAchievements(ctx.db, ctx.currentUser.id, "playback");
} }

View File

@@ -15,9 +15,7 @@ builder.queryField("stats", (t) =>
.select({ count: count() }) .select({ count: count() })
.from(users) .from(users)
.where(eq(users.role, "viewer")); .where(eq(users.role, "viewer"));
const videosCount = await ctx.db const videosCount = await ctx.db.select({ count: count() }).from(videos);
.select({ count: count() })
.from(videos);
return { return {
models_count: modelsCount[0]?.count || 0, models_count: modelsCount[0]?.count || 0,

View File

@@ -28,11 +28,7 @@ builder.queryField("userProfile", (t) =>
id: t.arg.string({ required: true }), id: t.arg.string({ required: true }),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
const user = await ctx.db const user = await ctx.db.select().from(users).where(eq(users.id, args.id)).limit(1);
.select()
.from(users)
.where(eq(users.id, args.id))
.limit(1);
return user[0] || null; return user[0] || null;
}, },
}), }),
@@ -53,13 +49,19 @@ builder.mutationField("updateProfile", (t) =>
if (!ctx.currentUser) throw new GraphQLError("Unauthorized"); if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
const updates: Record<string, unknown> = { date_updated: new Date() }; const updates: Record<string, unknown> = { date_updated: new Date() };
if (args.firstName !== undefined && args.firstName !== null) updates.first_name = args.firstName; if (args.firstName !== undefined && args.firstName !== null)
updates.first_name = args.firstName;
if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName; if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName;
if (args.artistName !== undefined && args.artistName !== null) updates.artist_name = args.artistName; if (args.artistName !== undefined && args.artistName !== null)
if (args.description !== undefined && args.description !== null) updates.description = args.description; updates.artist_name = args.artistName;
if (args.description !== undefined && args.description !== null)
updates.description = args.description;
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags; if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
await ctx.db.update(users).set(updates as any).where(eq(users.id, ctx.currentUser.id)); await ctx.db
.update(users)
.set(updates as any)
.where(eq(users.id, ctx.currentUser.id));
const updated = await ctx.db const updated = await ctx.db
.select() .select()

View File

@@ -1,7 +1,19 @@
import { GraphQLError } from "graphql"; import { GraphQLError } from "graphql";
import { builder } from "../builder"; import { builder } from "../builder";
import { VideoType, VideoLikeResponseType, VideoPlayResponseType, VideoLikeStatusType } from "../types/index"; import {
import { videos, video_models, video_likes, video_plays, users, files } from "../../db/schema/index"; VideoType,
VideoLikeResponseType,
VideoPlayResponseType,
VideoLikeStatusType,
} from "../types/index";
import {
videos,
video_models,
video_likes,
video_plays,
users,
files,
} from "../../db/schema/index";
import { eq, and, lte, desc, inArray, count } from "drizzle-orm"; import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
async function enrichVideo(db: any, video: any) { async function enrichVideo(db: any, video: any) {
@@ -25,8 +37,14 @@ async function enrichVideo(db: any, video: any) {
} }
// Count likes // Count likes
const likesCount = await db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, video.id)); const likesCount = await db
const playsCount = await db.select({ count: count() }).from(video_plays).where(eq(video_plays.video_id, video.id)); .select({ count: count() })
.from(video_likes)
.where(eq(video_likes.video_id, video.id));
const playsCount = await db
.select({ count: count() })
.from(video_plays)
.where(eq(video_plays.video_id, video.id));
return { return {
...video, ...video,
@@ -63,10 +81,15 @@ builder.queryField("videos", (t) =>
query = ctx.db query = ctx.db
.select({ v: videos }) .select({ v: videos })
.from(videos) .from(videos)
.where(and( .where(
lte(videos.upload_date, new Date()), and(
inArray(videos.id, videoIds.map((v: any) => v.video_id)), lte(videos.upload_date, new Date()),
)) inArray(
videos.id,
videoIds.map((v: any) => v.video_id),
),
),
)
.orderBy(desc(videos.upload_date)); .orderBy(desc(videos.upload_date));
} }
@@ -74,10 +97,7 @@ builder.queryField("videos", (t) =>
query = ctx.db query = ctx.db
.select({ v: videos }) .select({ v: videos })
.from(videos) .from(videos)
.where(and( .where(and(lte(videos.upload_date, new Date()), eq(videos.featured, args.featured)))
lte(videos.upload_date, new Date()),
eq(videos.featured, args.featured),
))
.orderBy(desc(videos.upload_date)); .orderBy(desc(videos.upload_date));
} }
@@ -123,7 +143,9 @@ builder.queryField("videoLikeStatus", (t) =>
const existing = await ctx.db const existing = await ctx.db
.select() .select()
.from(video_likes) .from(video_likes)
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id))) .where(
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
)
.limit(1); .limit(1);
return { liked: existing.length > 0 }; return { liked: existing.length > 0 };
}, },
@@ -142,7 +164,9 @@ builder.mutationField("likeVideo", (t) =>
const existing = await ctx.db const existing = await ctx.db
.select() .select()
.from(video_likes) .from(video_likes)
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id))) .where(
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
)
.limit(1); .limit(1);
if (existing.length > 0) throw new GraphQLError("Already liked"); if (existing.length > 0) throw new GraphQLError("Already liked");
@@ -154,10 +178,22 @@ builder.mutationField("likeVideo", (t) =>
await ctx.db await ctx.db
.update(videos) .update(videos)
.set({ likes_count: (await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number + 1 || 1 }) .set({
likes_count:
((
await ctx.db
.select({ c: videos.likes_count })
.from(videos)
.where(eq(videos.id, args.videoId))
.limit(1)
)[0]?.c as number) + 1 || 1,
})
.where(eq(videos.id, args.videoId)); .where(eq(videos.id, args.videoId));
const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId)); const likesCount = await ctx.db
.select({ count: count() })
.from(video_likes)
.where(eq(video_likes.video_id, args.videoId));
return { liked: true, likes_count: likesCount[0]?.count || 1 }; return { liked: true, likes_count: likesCount[0]?.count || 1 };
}, },
}), }),
@@ -175,21 +211,39 @@ builder.mutationField("unlikeVideo", (t) =>
const existing = await ctx.db const existing = await ctx.db
.select() .select()
.from(video_likes) .from(video_likes)
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id))) .where(
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
)
.limit(1); .limit(1);
if (existing.length === 0) throw new GraphQLError("Not liked"); if (existing.length === 0) throw new GraphQLError("Not liked");
await ctx.db await ctx.db
.delete(video_likes) .delete(video_likes)
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id))); .where(
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
);
await ctx.db await ctx.db
.update(videos) .update(videos)
.set({ likes_count: Math.max(((await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number || 1) - 1, 0) }) .set({
likes_count: Math.max(
(((
await ctx.db
.select({ c: videos.likes_count })
.from(videos)
.where(eq(videos.id, args.videoId))
.limit(1)
)[0]?.c as number) || 1) - 1,
0,
),
})
.where(eq(videos.id, args.videoId)); .where(eq(videos.id, args.videoId));
const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId)); const likesCount = await ctx.db
.select({ count: count() })
.from(video_likes)
.where(eq(video_likes.video_id, args.videoId));
return { liked: false, likes_count: likesCount[0]?.count || 0 }; return { liked: false, likes_count: likesCount[0]?.count || 0 };
}, },
}), }),
@@ -203,13 +257,19 @@ builder.mutationField("recordVideoPlay", (t) =>
sessionId: t.arg.string(), sessionId: t.arg.string(),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
const play = await ctx.db.insert(video_plays).values({ const play = await ctx.db
video_id: args.videoId, .insert(video_plays)
user_id: ctx.currentUser?.id || null, .values({
session_id: args.sessionId || null, video_id: args.videoId,
}).returning({ id: video_plays.id }); user_id: ctx.currentUser?.id || null,
session_id: args.sessionId || null,
})
.returning({ id: video_plays.id });
const playsCount = await ctx.db.select({ count: count() }).from(video_plays).where(eq(video_plays.video_id, args.videoId)); const playsCount = await ctx.db
.select({ count: count() })
.from(video_plays)
.where(eq(video_plays.video_id, args.videoId));
await ctx.db await ctx.db
.update(videos) .update(videos)
@@ -237,7 +297,11 @@ builder.mutationField("updateVideoPlay", (t) =>
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
await ctx.db await ctx.db
.update(video_plays) .update(video_plays)
.set({ duration_watched: args.durationWatched, completed: args.completed, date_updated: new Date() }) .set({
duration_watched: args.durationWatched,
completed: args.completed,
date_updated: new Date(),
})
.where(eq(video_plays.id, args.playId)); .where(eq(video_plays.id, args.playId));
return true; return true;
}, },
@@ -262,13 +326,26 @@ builder.queryField("analytics", (t) =>
.where(eq(video_models.user_id, userId)); .where(eq(video_models.user_id, userId));
if (modelVideoIds.length === 0) { if (modelVideoIds.length === 0) {
return { total_videos: 0, total_likes: 0, total_plays: 0, plays_by_date: {}, likes_by_date: {}, videos: [] }; return {
total_videos: 0,
total_likes: 0,
total_plays: 0,
plays_by_date: {},
likes_by_date: {},
videos: [],
};
} }
const videoIds = modelVideoIds.map((v: any) => v.video_id); const videoIds = modelVideoIds.map((v: any) => v.video_id);
const videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds)); const videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds));
const plays = await ctx.db.select().from(video_plays).where(inArray(video_plays.video_id, videoIds)); const plays = await ctx.db
const likes = await ctx.db.select().from(video_likes).where(inArray(video_likes.video_id, videoIds)); .select()
.from(video_plays)
.where(inArray(video_plays.video_id, videoIds));
const likes = await ctx.db
.select()
.from(video_likes)
.where(inArray(video_likes.video_id, videoIds));
const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0); const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0);
const totalPlays = videoList.reduce((sum, v) => sum + (v.plays_count || 0), 0); const totalPlays = videoList.reduce((sum, v) => sum + (v.plays_count || 0), 0);
@@ -290,9 +367,10 @@ builder.queryField("analytics", (t) =>
const videoAnalytics = videoList.map((video) => { const videoAnalytics = videoList.map((video) => {
const vPlays = plays.filter((p) => p.video_id === video.id); const vPlays = plays.filter((p) => p.video_id === video.id);
const completedPlays = vPlays.filter((p) => p.completed).length; const completedPlays = vPlays.filter((p) => p.completed).length;
const avgWatchTime = vPlays.length > 0 const avgWatchTime =
? vPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) / vPlays.length vPlays.length > 0
: 0; ? vPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) / vPlays.length
: 0;
return { return {
id: video.id, id: video.id,

File diff suppressed because it is too large Load Diff

View File

@@ -49,7 +49,12 @@ async function main() {
decorateReply: true, decorateReply: true,
}); });
const yoga = createYoga<{ req: FastifyRequest; reply: FastifyReply; db: typeof db; redis: typeof redis }>({ const yoga = createYoga<{
req: FastifyRequest;
reply: FastifyReply;
db: typeof db;
redis: typeof redis;
}>({
schema, schema,
context: buildContext, context: buildContext,
graphqlEndpoint: "/graphql", graphqlEndpoint: "/graphql",
@@ -101,7 +106,12 @@ async function main() {
if (!existsSync(cacheFile)) { if (!existsSync(cacheFile)) {
const originalPath = path.join(UPLOAD_DIR, id, filename); const originalPath = path.join(UPLOAD_DIR, id, filename);
await sharp(originalPath) await sharp(originalPath)
.resize({ width: preset.width, height: preset.height, fit: preset.fit ?? "inside", withoutEnlargement: true }) .resize({
width: preset.width,
height: preset.height,
fit: preset.fit ?? "inside",
withoutEnlargement: true,
})
.webp({ quality: 92 }) .webp({ quality: 92 })
.toFile(cacheFile); .toFile(cacheFile);
} }

View File

@@ -4,10 +4,12 @@ const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || "localhost", host: process.env.SMTP_HOST || "localhost",
port: parseInt(process.env.SMTP_PORT || "587"), port: parseInt(process.env.SMTP_PORT || "587"),
secure: process.env.SMTP_SECURE === "true", secure: process.env.SMTP_SECURE === "true",
auth: process.env.SMTP_USER ? { auth: process.env.SMTP_USER
user: process.env.SMTP_USER, ? {
pass: process.env.SMTP_PASS, user: process.env.SMTP_USER,
} : undefined, pass: process.env.SMTP_PASS,
}
: undefined,
}); });
const FROM = process.env.EMAIL_FROM || "noreply@sexy.pivoine.art"; const FROM = process.env.EMAIL_FROM || "noreply@sexy.pivoine.art";

View File

@@ -79,7 +79,10 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
const playbacksResult = await db.execute(sql` const playbacksResult = await db.execute(sql`
SELECT COUNT(*) as count FROM recording_plays SELECT COUNT(*) as count FROM recording_plays
WHERE user_id = ${userId} WHERE user_id = ${userId}
AND recording_id NOT IN (${sql.join(ownIds.map(id => sql`${id}`), sql`, `)}) AND recording_id NOT IN (${sql.join(
ownIds.map((id) => sql`${id}`),
sql`, `,
)})
`); `);
playbacksCount = parseInt((playbacksResult.rows[0] as any)?.count || "0"); playbacksCount = parseInt((playbacksResult.rows[0] as any)?.count || "0");
} else { } else {
@@ -135,11 +138,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
} }
} }
export async function checkAchievements( export async function checkAchievements(db: DB, userId: string, category?: string): Promise<void> {
db: DB,
userId: string,
category?: string,
): Promise<void> {
let achievementsQuery = db let achievementsQuery = db
.select() .select()
.from(achievements) .from(achievements)
@@ -176,7 +175,7 @@ export async function checkAchievements(
.update(user_achievements) .update(user_achievements)
.set({ .set({
progress, progress,
date_unlocked: isUnlocked ? (existing[0].date_unlocked || new Date()) : null, date_unlocked: isUnlocked ? existing[0].date_unlocked || new Date() : null,
}) })
.where( .where(
and( and(

View File

@@ -128,7 +128,9 @@ async function migrateUsers() {
? tagsRes.rows[0].tags ? tagsRes.rows[0].tags
: JSON.parse(String(tagsRes.rows[0].tags || "[]")); : JSON.parse(String(tagsRes.rows[0].tags || "[]"));
} }
} catch { /* tags column may not exist on older Directus installs */ } } catch {
/* tags column may not exist on older Directus installs */
}
await query( await query(
`INSERT INTO users (id, email, password_hash, first_name, last_name, artist_name, slug, `INSERT INTO users (id, email, password_hash, first_name, last_name, artist_name, slug,
@@ -279,9 +281,7 @@ async function migrateVideoModels() {
async function migrateVideoLikes() { async function migrateVideoLikes() {
console.log("❤️ Migrating video likes..."); console.log("❤️ Migrating video likes...");
const { rows } = await query( const { rows } = await query(`SELECT id, video_id, user_id, date_created FROM sexy_video_likes`);
`SELECT id, video_id, user_id, date_created FROM sexy_video_likes`,
);
let migrated = 0; let migrated = 0;
for (const row of rows) { for (const row of rows) {

View File

@@ -1,25 +1,25 @@
{ {
"name": "@sexy.pivoine.art/buttplug", "name": "@sexy.pivoine.art/buttplug",
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"files": [ "files": [
"dist" "dist"
], ],
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target bundler --release" "build:wasm": "wasm-pack build --out-dir wasm --out-name index --target bundler --release"
}, },
"dependencies": { "dependencies": {
"eventemitter3": "^5.0.4", "eventemitter3": "^5.0.4",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-wasm": "3.5.0", "vite-plugin-wasm": "3.5.0",
"ws": "^8.19.0" "ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"wasm-pack": "^0.14.0" "wasm-pack": "^0.14.0"
} }
} }

View File

@@ -6,11 +6,11 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
'use strict'; "use strict";
import { IButtplugClientConnector } from './IButtplugClientConnector'; import { IButtplugClientConnector } from "./IButtplugClientConnector";
import { ButtplugMessage } from '../core/Messages'; import { ButtplugMessage } from "../core/Messages";
import { ButtplugBrowserWebsocketConnector } from '../utils/ButtplugBrowserWebsocketConnector'; import { ButtplugBrowserWebsocketConnector } from "../utils/ButtplugBrowserWebsocketConnector";
export class ButtplugBrowserWebsocketClientConnector export class ButtplugBrowserWebsocketClientConnector
extends ButtplugBrowserWebsocketConnector extends ButtplugBrowserWebsocketConnector
@@ -18,7 +18,7 @@ export class ButtplugBrowserWebsocketClientConnector
{ {
public send = (msg: ButtplugMessage): void => { public send = (msg: ButtplugMessage): void => {
if (!this.Connected) { if (!this.Connected) {
throw new Error('ButtplugClient not connected'); throw new Error("ButtplugClient not connected");
} }
this.sendMessage(msg); this.sendMessage(msg);
}; };

View File

@@ -6,20 +6,16 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
'use strict'; "use strict";
import { ButtplugLogger } from '../core/Logging'; import { ButtplugLogger } from "../core/Logging";
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from "eventemitter3";
import { ButtplugClientDevice } from './ButtplugClientDevice'; import { ButtplugClientDevice } from "./ButtplugClientDevice";
import { IButtplugClientConnector } from './IButtplugClientConnector'; import { IButtplugClientConnector } from "./IButtplugClientConnector";
import { ButtplugMessageSorter } from '../utils/ButtplugMessageSorter'; import { ButtplugMessageSorter } from "../utils/ButtplugMessageSorter";
import * as Messages from '../core/Messages'; import * as Messages from "../core/Messages";
import { import { ButtplugError, ButtplugInitError, ButtplugMessageError } from "../core/Exceptions";
ButtplugError, import { ButtplugClientConnectorException } from "./ButtplugClientConnectorException";
ButtplugInitError,
ButtplugMessageError,
} from '../core/Exceptions';
import { ButtplugClientConnectorException } from './ButtplugClientConnectorException';
export class ButtplugClient extends EventEmitter { export class ButtplugClient extends EventEmitter {
protected _pingTimer: NodeJS.Timeout | null = null; protected _pingTimer: NodeJS.Timeout | null = null;
@@ -30,7 +26,7 @@ export class ButtplugClient extends EventEmitter {
protected _isScanning = false; protected _isScanning = false;
private _sorter: ButtplugMessageSorter = new ButtplugMessageSorter(true); private _sorter: ButtplugMessageSorter = new ButtplugMessageSorter(true);
constructor(clientName = 'Generic Buttplug Client') { constructor(clientName = "Generic Buttplug Client") {
super(); super();
this._clientName = clientName; this._clientName = clientName;
this._logger.Debug(`ButtplugClient: Client ${clientName} created.`); this._logger.Debug(`ButtplugClient: Client ${clientName} created.`);
@@ -52,18 +48,16 @@ export class ButtplugClient extends EventEmitter {
} }
public connect = async (connector: IButtplugClientConnector) => { public connect = async (connector: IButtplugClientConnector) => {
this._logger.Info( this._logger.Info(`ButtplugClient: Connecting using ${connector.constructor.name}`);
`ButtplugClient: Connecting using ${connector.constructor.name}`
);
await connector.connect(); await connector.connect();
this._connector = connector; this._connector = connector;
this._connector.addListener('message', this.parseMessages); this._connector.addListener("message", this.parseMessages);
this._connector.addListener('disconnect', this.disconnectHandler); this._connector.addListener("disconnect", this.disconnectHandler);
await this.initializeConnection(); await this.initializeConnection();
}; };
public disconnect = async () => { public disconnect = async () => {
this._logger.Debug('ButtplugClient: Disconnect called'); this._logger.Debug("ButtplugClient: Disconnect called");
this._devices.clear(); this._devices.clear();
this.checkConnector(); this.checkConnector();
await this.shutdownConnection(); await this.shutdownConnection();
@@ -71,25 +65,33 @@ export class ButtplugClient extends EventEmitter {
}; };
public startScanning = async () => { public startScanning = async () => {
this._logger.Debug('ButtplugClient: StartScanning called'); this._logger.Debug("ButtplugClient: StartScanning called");
this._isScanning = true; this._isScanning = true;
await this.sendMsgExpectOk({ StartScanning: { Id: 1 } }); await this.sendMsgExpectOk({ StartScanning: { Id: 1 } });
}; };
public stopScanning = async () => { public stopScanning = async () => {
this._logger.Debug('ButtplugClient: StopScanning called'); this._logger.Debug("ButtplugClient: StopScanning called");
this._isScanning = false; this._isScanning = false;
await this.sendMsgExpectOk({ StopScanning: { Id: 1 } }); await this.sendMsgExpectOk({ StopScanning: { Id: 1 } });
}; };
public stopAllDevices = async () => { public stopAllDevices = async () => {
this._logger.Debug('ButtplugClient: StopAllDevices'); this._logger.Debug("ButtplugClient: StopAllDevices");
await this.sendMsgExpectOk({ StopCmd: { Id: 1, DeviceIndex: undefined, FeatureIndex: undefined, Inputs: true, Outputs: true } }); await this.sendMsgExpectOk({
StopCmd: {
Id: 1,
DeviceIndex: undefined,
FeatureIndex: undefined,
Inputs: true,
Outputs: true,
},
});
}; };
protected disconnectHandler = () => { protected disconnectHandler = () => {
this._logger.Info('ButtplugClient: Disconnect event receieved.'); this._logger.Info("ButtplugClient: Disconnect event receieved.");
this.emit('disconnect'); this.emit("disconnect");
}; };
protected parseMessages = (msgs: Messages.ButtplugMessage[]) => { protected parseMessages = (msgs: Messages.ButtplugMessage[]) => {
@@ -100,10 +102,10 @@ export class ButtplugClient extends EventEmitter {
break; break;
} else if (x.ScanningFinished !== undefined) { } else if (x.ScanningFinished !== undefined) {
this._isScanning = false; this._isScanning = false;
this.emit('scanningfinished', x); this.emit("scanningfinished", x);
} else if (x.InputReading !== undefined) { } else if (x.InputReading !== undefined) {
// TODO this should be emitted from the device or feature, not the client // TODO this should be emitted from the device or feature, not the client
this.emit('inputreading', x); this.emit("inputreading", x);
} else { } else {
console.log(`Unhandled message: ${x}`); console.log(`Unhandled message: ${x}`);
} }
@@ -112,21 +114,17 @@ export class ButtplugClient extends EventEmitter {
protected initializeConnection = async (): Promise<boolean> => { protected initializeConnection = async (): Promise<boolean> => {
this.checkConnector(); this.checkConnector();
const msg = await this.sendMessage( const msg = await this.sendMessage({
{ RequestServerInfo: {
RequestServerInfo: { ClientName: this._clientName,
ClientName: this._clientName, Id: 1,
Id: 1, ProtocolVersionMajor: Messages.MESSAGE_SPEC_VERSION_MAJOR,
ProtocolVersionMajor: Messages.MESSAGE_SPEC_VERSION_MAJOR, ProtocolVersionMinor: Messages.MESSAGE_SPEC_VERSION_MINOR,
ProtocolVersionMinor: Messages.MESSAGE_SPEC_VERSION_MINOR },
} });
}
);
if (msg.ServerInfo !== undefined) { if (msg.ServerInfo !== undefined) {
const serverinfo = msg as Messages.ServerInfo; const serverinfo = msg as Messages.ServerInfo;
this._logger.Info( this._logger.Info(`ButtplugClient: Connected to Server ${serverinfo.ServerName}`);
`ButtplugClient: Connected to Server ${serverinfo.ServerName}`
);
// TODO: maybe store server name, do something with message template version? // TODO: maybe store server name, do something with message template version?
const ping = serverinfo.MaxPingTime; const ping = serverinfo.MaxPingTime;
// If the server version is lower than the client version, the server will disconnect here. // If the server version is lower than the client version, the server will disconnect here.
@@ -153,22 +151,19 @@ export class ButtplugClient extends EventEmitter {
throw ButtplugError.LogAndError( throw ButtplugError.LogAndError(
ButtplugInitError, ButtplugInitError,
this._logger, this._logger,
`Cannot connect to server. ${err.ErrorMessage}` `Cannot connect to server. ${err.ErrorMessage}`,
); );
} }
return false; return false;
} };
private parseDeviceList = (list: Messages.DeviceList) => { private parseDeviceList = (list: Messages.DeviceList) => {
for (let [_, d] of Object.entries(list.Devices)) { for (let [_, d] of Object.entries(list.Devices)) {
if (!this._devices.has(d.DeviceIndex)) { if (!this._devices.has(d.DeviceIndex)) {
const device = ButtplugClientDevice.fromMsg( const device = ButtplugClientDevice.fromMsg(d, this.sendMessageClosure);
d,
this.sendMessageClosure
);
this._logger.Debug(`ButtplugClient: Adding Device: ${device}`); this._logger.Debug(`ButtplugClient: Adding Device: ${device}`);
this._devices.set(d.DeviceIndex, device); this._devices.set(d.DeviceIndex, device);
this.emit('deviceadded', device); this.emit("deviceadded", device);
} else { } else {
this._logger.Debug(`ButtplugClient: Device already added: ${d}`); this._logger.Debug(`ButtplugClient: Device already added: ${d}`);
} }
@@ -176,19 +171,17 @@ export class ButtplugClient extends EventEmitter {
for (let [index, device] of this._devices.entries()) { for (let [index, device] of this._devices.entries()) {
if (!list.Devices.hasOwnProperty(index.toString())) { if (!list.Devices.hasOwnProperty(index.toString())) {
this._devices.delete(index); this._devices.delete(index);
this.emit('deviceremoved', device); this.emit("deviceremoved", device);
} }
} }
} };
protected requestDeviceList = async () => { protected requestDeviceList = async () => {
this.checkConnector(); this.checkConnector();
this._logger.Debug('ButtplugClient: ReceiveDeviceList called'); this._logger.Debug("ButtplugClient: ReceiveDeviceList called");
const response = (await this.sendMessage( const response = await this.sendMessage({
{ RequestDeviceList: { Id: 1 },
RequestDeviceList: { Id: 1 } });
}
));
this.parseDeviceList(response.DeviceList!); this.parseDeviceList(response.DeviceList!);
}; };
@@ -200,9 +193,7 @@ export class ButtplugClient extends EventEmitter {
} }
}; };
protected async sendMessage( protected async sendMessage(msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> {
msg: Messages.ButtplugMessage
): Promise<Messages.ButtplugMessage> {
this.checkConnector(); this.checkConnector();
const p = this._sorter.PrepareOutgoingMessage(msg); const p = this._sorter.PrepareOutgoingMessage(msg);
await this._connector!.send(msg); await this._connector!.send(msg);
@@ -211,15 +202,11 @@ export class ButtplugClient extends EventEmitter {
protected checkConnector() { protected checkConnector() {
if (!this.connected) { if (!this.connected) {
throw new ButtplugClientConnectorException( throw new ButtplugClientConnectorException("ButtplugClient not connected");
'ButtplugClient not connected'
);
} }
} }
protected sendMsgExpectOk = async ( protected sendMsgExpectOk = async (msg: Messages.ButtplugMessage): Promise<void> => {
msg: Messages.ButtplugMessage
): Promise<void> => {
const response = await this.sendMessage(msg); const response = await this.sendMessage(msg);
if (response.Ok !== undefined) { if (response.Ok !== undefined) {
return; return;
@@ -229,13 +216,13 @@ export class ButtplugClient extends EventEmitter {
throw ButtplugError.LogAndError( throw ButtplugError.LogAndError(
ButtplugMessageError, ButtplugMessageError,
this._logger, this._logger,
`Message ${response} not handled by SendMsgExpectOk` `Message ${response} not handled by SendMsgExpectOk`,
); );
} }
}; };
protected sendMessageClosure = async ( protected sendMessageClosure = async (
msg: Messages.ButtplugMessage msg: Messages.ButtplugMessage,
): Promise<Messages.ButtplugMessage> => { ): Promise<Messages.ButtplugMessage> => {
return await this.sendMessage(msg); return await this.sendMessage(msg);
}; };

View File

@@ -6,8 +6,8 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
import { ButtplugError } from '../core/Exceptions'; import { ButtplugError } from "../core/Exceptions";
import * as Messages from '../core/Messages'; import * as Messages from "../core/Messages";
export class ButtplugClientConnectorException extends ButtplugError { export class ButtplugClientConnectorException extends ButtplugError {
public constructor(message: string) { public constructor(message: string) {

View File

@@ -6,22 +6,17 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
'use strict'; "use strict";
import * as Messages from '../core/Messages'; import * as Messages from "../core/Messages";
import { import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } from "../core/Exceptions";
ButtplugDeviceError, import { EventEmitter } from "eventemitter3";
ButtplugError, import { ButtplugClientDeviceFeature } from "./ButtplugClientDeviceFeature";
ButtplugMessageError, import { DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
} from '../core/Exceptions';
import { EventEmitter } from 'eventemitter3';
import { ButtplugClientDeviceFeature } from './ButtplugClientDeviceFeature';
import { DeviceOutputCommand } from './ButtplugClientDeviceCommand';
/** /**
* Represents an abstract device, capable of taking certain kinds of messages. * Represents an abstract device, capable of taking certain kinds of messages.
*/ */
export class ButtplugClientDevice extends EventEmitter { export class ButtplugClientDevice extends EventEmitter {
private _features: Map<number, ButtplugClientDeviceFeature>; private _features: Map<number, ButtplugClientDeviceFeature>;
/** /**
@@ -58,9 +53,7 @@ export class ButtplugClientDevice extends EventEmitter {
public static fromMsg( public static fromMsg(
msg: Messages.DeviceInfo, msg: Messages.DeviceInfo,
sendClosure: ( sendClosure: (msg: Messages.ButtplugMessage) => Promise<Messages.ButtplugMessage>,
msg: Messages.ButtplugMessage
) => Promise<Messages.ButtplugMessage>
): ButtplugClientDevice { ): ButtplugClientDevice {
return new ButtplugClientDevice(msg, sendClosure); return new ButtplugClientDevice(msg, sendClosure);
} }
@@ -72,25 +65,29 @@ export class ButtplugClientDevice extends EventEmitter {
*/ */
private constructor( private constructor(
private _deviceInfo: Messages.DeviceInfo, private _deviceInfo: Messages.DeviceInfo,
private _sendClosure: ( private _sendClosure: (msg: Messages.ButtplugMessage) => Promise<Messages.ButtplugMessage>,
msg: Messages.ButtplugMessage
) => Promise<Messages.ButtplugMessage>
) { ) {
super(); super();
this._features = new Map(Object.entries(_deviceInfo.DeviceFeatures).map(([index, v]) => [parseInt(index), new ButtplugClientDeviceFeature(_deviceInfo.DeviceIndex, _deviceInfo.DeviceName, v, _sendClosure)])); this._features = new Map(
Object.entries(_deviceInfo.DeviceFeatures).map(([index, v]) => [
parseInt(index),
new ButtplugClientDeviceFeature(
_deviceInfo.DeviceIndex,
_deviceInfo.DeviceName,
v,
_sendClosure,
),
]),
);
} }
public async send( public async send(msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> {
msg: Messages.ButtplugMessage
): Promise<Messages.ButtplugMessage> {
// Assume we're getting the closure from ButtplugClient, which does all of // Assume we're getting the closure from ButtplugClient, which does all of
// the index/existence/connection/message checks for us. // the index/existence/connection/message checks for us.
return await this._sendClosure(msg); return await this._sendClosure(msg);
} }
protected sendMsgExpectOk = async ( protected sendMsgExpectOk = async (msg: Messages.ButtplugMessage): Promise<void> => {
msg: Messages.ButtplugMessage
): Promise<void> => {
const response = await this.send(msg); const response = await this.send(msg);
if (response.Ok !== undefined) { if (response.Ok !== undefined) {
return; return;
@@ -109,19 +106,36 @@ export class ButtplugClientDevice extends EventEmitter {
protected isOutputValid(featureIndex: number, type: Messages.OutputType) { protected isOutputValid(featureIndex: number, type: Messages.OutputType) {
if (!this._deviceInfo.DeviceFeatures.hasOwnProperty(featureIndex.toString())) { if (!this._deviceInfo.DeviceFeatures.hasOwnProperty(featureIndex.toString())) {
throw new ButtplugDeviceError(`Feature index ${featureIndex} does not exist for device ${this.name}`); throw new ButtplugDeviceError(
`Feature index ${featureIndex} does not exist for device ${this.name}`,
);
} }
if (this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs !== undefined && !this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs.hasOwnProperty(type)) { if (
throw new ButtplugDeviceError(`Feature index ${featureIndex} does not support type ${type} for device ${this.name}`); this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs !== undefined &&
!this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs.hasOwnProperty(type)
) {
throw new ButtplugDeviceError(
`Feature index ${featureIndex} does not support type ${type} for device ${this.name}`,
);
} }
} }
public hasOutput(type: Messages.OutputType): boolean { public hasOutput(type: Messages.OutputType): boolean {
return this._features.values().filter((f) => f.hasOutput(type)).toArray().length > 0; return (
this._features
.values()
.filter((f) => f.hasOutput(type))
.toArray().length > 0
);
} }
public hasInput(type: Messages.InputType): boolean { public hasInput(type: Messages.InputType): boolean {
return this._features.values().filter((f) => f.hasInput(type)).toArray().length > 0; return (
this._features
.values()
.filter((f) => f.hasInput(type))
.toArray().length > 0
);
} }
public async runOutput(cmd: DeviceOutputCommand): Promise<void> { public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
@@ -138,7 +152,15 @@ export class ButtplugClientDevice extends EventEmitter {
} }
public async stop(): Promise<void> { public async stop(): Promise<void> {
await this.sendMsgExpectOk({StopCmd: { Id: 1, DeviceIndex: this.index, FeatureIndex: undefined, Inputs: true, Outputs: true}}); await this.sendMsgExpectOk({
StopCmd: {
Id: 1,
DeviceIndex: this.index,
FeatureIndex: undefined,
Inputs: true,
Outputs: true,
},
});
} }
public async battery(): Promise<number> { public async battery(): Promise<number> {
@@ -160,6 +182,6 @@ export class ButtplugClientDevice extends EventEmitter {
} }
public emitDisconnected() { public emitDisconnected() {
this.emit('deviceremoved'); this.emit("deviceremoved");
} }
} }

View File

@@ -14,7 +14,7 @@ class PercentOrSteps {
} }
public static createSteps(s: number): PercentOrSteps { public static createSteps(s: number): PercentOrSteps {
let v = new PercentOrSteps; let v = new PercentOrSteps();
v._steps = s; v._steps = s;
return v; return v;
} }
@@ -24,7 +24,7 @@ class PercentOrSteps {
throw new ButtplugDeviceError(`Percent value ${p} is not in the range 0.0 <= x <= 1.0`); throw new ButtplugDeviceError(`Percent value ${p} is not in the range 0.0 <= x <= 1.0`);
} }
let v = new PercentOrSteps; let v = new PercentOrSteps();
v._percent = p; v._percent = p;
return v; return v;
} }
@@ -35,8 +35,7 @@ export class DeviceOutputCommand {
private _outputType: OutputType, private _outputType: OutputType,
private _value: PercentOrSteps, private _value: PercentOrSteps,
private _duration?: number, private _duration?: number,
) ) {}
{}
public get outputType() { public get outputType() {
return this._outputType; return this._outputType;
@@ -52,26 +51,36 @@ export class DeviceOutputCommand {
} }
export class DeviceOutputValueConstructor { export class DeviceOutputValueConstructor {
public constructor( public constructor(private _outputType: OutputType) {}
private _outputType: OutputType)
{}
public steps(steps: number): DeviceOutputCommand { public steps(steps: number): DeviceOutputCommand {
return new DeviceOutputCommand(this._outputType, PercentOrSteps.createSteps(steps), undefined); return new DeviceOutputCommand(this._outputType, PercentOrSteps.createSteps(steps), undefined);
} }
public percent(percent: number): DeviceOutputCommand { public percent(percent: number): DeviceOutputCommand {
return new DeviceOutputCommand(this._outputType, PercentOrSteps.createPercent(percent), undefined); return new DeviceOutputCommand(
this._outputType,
PercentOrSteps.createPercent(percent),
undefined,
);
} }
} }
export class DeviceOutputPositionWithDurationConstructor { export class DeviceOutputPositionWithDurationConstructor {
public steps(steps: number, duration: number): DeviceOutputCommand { public steps(steps: number, duration: number): DeviceOutputCommand {
return new DeviceOutputCommand(OutputType.Position, PercentOrSteps.createSteps(steps), duration); return new DeviceOutputCommand(
OutputType.Position,
PercentOrSteps.createSteps(steps),
duration,
);
} }
public percent(percent: number, duration: number): DeviceOutputCommand { public percent(percent: number, duration: number): DeviceOutputCommand {
return new DeviceOutputCommand(OutputType.HwPositionWithDuration, PercentOrSteps.createPercent(percent), duration); return new DeviceOutputCommand(
OutputType.HwPositionWithDuration,
PercentOrSteps.createPercent(percent),
duration,
);
} }
} }

View File

@@ -3,23 +3,18 @@ import * as Messages from "../core/Messages";
import { DeviceOutputCommand } from "./ButtplugClientDeviceCommand"; import { DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
export class ButtplugClientDeviceFeature { export class ButtplugClientDeviceFeature {
constructor( constructor(
private _deviceIndex: number, private _deviceIndex: number,
private _deviceName: string, private _deviceName: string,
private _feature: Messages.DeviceFeature, private _feature: Messages.DeviceFeature,
private _sendClosure: ( private _sendClosure: (msg: Messages.ButtplugMessage) => Promise<Messages.ButtplugMessage>,
msg: Messages.ButtplugMessage ) {}
) => Promise<Messages.ButtplugMessage>) {
}
protected send = async (msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> => { protected send = async (msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> => {
return await this._sendClosure(msg); return await this._sendClosure(msg);
} };
protected sendMsgExpectOk = async ( protected sendMsgExpectOk = async (msg: Messages.ButtplugMessage): Promise<void> => {
msg: Messages.ButtplugMessage
): Promise<void> => {
const response = await this.send(msg); const response = await this.send(msg);
if (response.Ok !== undefined) { if (response.Ok !== undefined) {
return; return;
@@ -32,13 +27,17 @@ export class ButtplugClientDeviceFeature {
protected isOutputValid(type: Messages.OutputType) { protected isOutputValid(type: Messages.OutputType) {
if (this._feature.Output !== undefined && !this._feature.Output.hasOwnProperty(type)) { if (this._feature.Output !== undefined && !this._feature.Output.hasOwnProperty(type)) {
throw new ButtplugDeviceError(`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`); throw new ButtplugDeviceError(
`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`,
);
} }
} }
protected isInputValid(type: Messages.InputType) { protected isInputValid(type: Messages.InputType) {
if (this._feature.Input !== undefined && !this._feature.Input.hasOwnProperty(type)) { if (this._feature.Input !== undefined && !this._feature.Input.hasOwnProperty(type)) {
throw new ButtplugDeviceError(`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`); throw new ButtplugDeviceError(
`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`,
);
} }
} }
@@ -74,8 +73,8 @@ export class ButtplugClientDeviceFeature {
Id: 1, Id: 1,
DeviceIndex: this._deviceIndex, DeviceIndex: this._deviceIndex,
FeatureIndex: this._feature.FeatureIndex, FeatureIndex: this._feature.FeatureIndex,
Command: outCommand Command: outCommand,
} },
}; };
await this.sendMsgExpectOk(cmd); await this.sendMsgExpectOk(cmd);
} }
@@ -124,20 +123,29 @@ export class ButtplugClientDeviceFeature {
return false; return false;
} }
public async runOutput(cmd: DeviceOutputCommand): Promise<void> { public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
if (this._feature.Output !== undefined && this._feature.Output.hasOwnProperty(cmd.outputType.toString())) { if (
this._feature.Output !== undefined &&
this._feature.Output.hasOwnProperty(cmd.outputType.toString())
) {
return this.sendOutputCmd(cmd); return this.sendOutputCmd(cmd);
} }
throw new ButtplugDeviceError(`Output type ${cmd.outputType} not supported by feature.`); throw new ButtplugDeviceError(`Output type ${cmd.outputType} not supported by feature.`);
} }
public async runInput(inputType: Messages.InputType, inputCommand: Messages.InputCommandType): Promise<Messages.InputReading | undefined> { public async runInput(
inputType: Messages.InputType,
inputCommand: Messages.InputCommandType,
): Promise<Messages.InputReading | undefined> {
// Make sure the requested feature is valid // Make sure the requested feature is valid
this.isInputValid(inputType); this.isInputValid(inputType);
let inputAttributes = this._feature.Input[inputType]; let inputAttributes = this._feature.Input[inputType];
console.log(this._feature.Input); console.log(this._feature.Input);
if ((inputCommand === Messages.InputCommandType.Unsubscribe && !inputAttributes.Command.includes(Messages.InputCommandType.Subscribe)) && !inputAttributes.Command.includes(inputCommand)) { if (
inputCommand === Messages.InputCommandType.Unsubscribe &&
!inputAttributes.Command.includes(Messages.InputCommandType.Subscribe) &&
!inputAttributes.Command.includes(inputCommand)
) {
throw new ButtplugDeviceError(`${inputType} does not support command ${inputCommand}`); throw new ButtplugDeviceError(`${inputType} does not support command ${inputCommand}`);
} }
@@ -148,7 +156,7 @@ export class ButtplugClientDeviceFeature {
FeatureIndex: this._feature.FeatureIndex, FeatureIndex: this._feature.FeatureIndex,
Type: inputType, Type: inputType,
Command: inputCommand, Command: inputCommand,
} },
}; };
if (inputCommand == Messages.InputCommandType.Read) { if (inputCommand == Messages.InputCommandType.Read) {
const response = await this.send(cmd); const response = await this.send(cmd);

View File

@@ -6,12 +6,11 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
'use strict'; "use strict";
import { ButtplugBrowserWebsocketClientConnector } from './ButtplugBrowserWebsocketClientConnector'; import { ButtplugBrowserWebsocketClientConnector } from "./ButtplugBrowserWebsocketClientConnector";
import { WebSocket as NodeWebSocket } from 'ws'; import { WebSocket as NodeWebSocket } from "ws";
export class ButtplugNodeWebsocketClientConnector extends ButtplugBrowserWebsocketClientConnector { export class ButtplugNodeWebsocketClientConnector extends ButtplugBrowserWebsocketClientConnector {
protected _websocketConstructor = protected _websocketConstructor = NodeWebSocket as unknown as typeof WebSocket;
NodeWebSocket as unknown as typeof WebSocket;
} }

View File

@@ -6,8 +6,8 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
import { ButtplugMessage } from '../core/Messages'; import { ButtplugMessage } from "../core/Messages";
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from "eventemitter3";
export interface IButtplugClientConnector extends EventEmitter { export interface IButtplugClientConnector extends EventEmitter {
connect: () => Promise<void>; connect: () => Promise<void>;

View File

@@ -6,8 +6,8 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
import * as Messages from './Messages'; import * as Messages from "./Messages";
import { ButtplugLogger } from './Logging'; import { ButtplugLogger } from "./Logging";
export class ButtplugError extends Error { export class ButtplugError extends Error {
public get ErrorClass(): Messages.ErrorClass { public get ErrorClass(): Messages.ErrorClass {
@@ -27,16 +27,16 @@ export class ButtplugError extends Error {
Error: { Error: {
Id: this.Id, Id: this.Id,
ErrorCode: this.ErrorClass, ErrorCode: this.ErrorClass,
ErrorMessage: this.message ErrorMessage: this.message,
} },
} };
} }
public static LogAndError<T extends ButtplugError>( public static LogAndError<T extends ButtplugError>(
constructor: new (str: string, num: number) => T, constructor: new (str: string, num: number) => T,
logger: ButtplugLogger, logger: ButtplugLogger,
message: string, message: string,
id: number = Messages.SYSTEM_MESSAGE_ID id: number = Messages.SYSTEM_MESSAGE_ID,
): T { ): T {
logger.Error(message); logger.Error(message);
return new constructor(message, id); return new constructor(message, id);
@@ -67,7 +67,7 @@ export class ButtplugError extends Error {
message: string, message: string,
errorClass: Messages.ErrorClass, errorClass: Messages.ErrorClass,
id: number = Messages.SYSTEM_MESSAGE_ID, id: number = Messages.SYSTEM_MESSAGE_ID,
inner?: Error inner?: Error,
) { ) {
super(message); super(message);
this.errorClass = errorClass; this.errorClass = errorClass;

View File

@@ -6,7 +6,7 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from "eventemitter3";
export enum ButtplugLogLevel { export enum ButtplugLogLevel {
Off, Off,
@@ -69,9 +69,7 @@ export class LogMessage {
* Returns a formatted string with timestamp, level, and message. * Returns a formatted string with timestamp, level, and message.
*/ */
public get FormattedMessage() { public get FormattedMessage() {
return `${ButtplugLogLevel[this.logLevel]} : ${this.timestamp} : ${ return `${ButtplugLogLevel[this.logLevel]} : ${this.timestamp} : ${this.logMessage}`;
this.logMessage
}`;
} }
} }
@@ -176,10 +174,7 @@ export class ButtplugLogger extends EventEmitter {
*/ */
protected AddLogMessage(msg: string, level: ButtplugLogLevel) { protected AddLogMessage(msg: string, level: ButtplugLogLevel) {
// If nothing wants the log message we have, ignore it. // If nothing wants the log message we have, ignore it.
if ( if (level > this.maximumEventLogLevel && level > this.maximumConsoleLogLevel) {
level > this.maximumEventLogLevel &&
level > this.maximumConsoleLogLevel
) {
return; return;
} }
const logMsg = new LogMessage(msg, level); const logMsg = new LogMessage(msg, level);
@@ -191,7 +186,7 @@ export class ButtplugLogger extends EventEmitter {
console.log(logMsg.FormattedMessage); console.log(logMsg.FormattedMessage);
} }
if (level <= this.maximumEventLogLevel) { if (level <= this.maximumEventLogLevel) {
this.emit('log', logMsg); this.emit("log", logMsg);
} }
} }
} }

View File

@@ -7,9 +7,9 @@
*/ */
// tslint:disable:max-classes-per-file // tslint:disable:max-classes-per-file
'use strict'; "use strict";
import { ButtplugMessageError } from './Exceptions'; import { ButtplugMessageError } from "./Exceptions";
export const SYSTEM_MESSAGE_ID = 0; export const SYSTEM_MESSAGE_ID = 0;
export const DEFAULT_MESSAGE_ID = 1; export const DEFAULT_MESSAGE_ID = 1;
@@ -132,34 +132,34 @@ export interface DeviceList {
} }
export enum OutputType { export enum OutputType {
Unknown = 'Unknown', Unknown = "Unknown",
Vibrate = 'Vibrate', Vibrate = "Vibrate",
Rotate = 'Rotate', Rotate = "Rotate",
Oscillate = 'Oscillate', Oscillate = "Oscillate",
Constrict = 'Constrict', Constrict = "Constrict",
Inflate = 'Inflate', Inflate = "Inflate",
Position = 'Position', Position = "Position",
HwPositionWithDuration = 'HwPositionWithDuration', HwPositionWithDuration = "HwPositionWithDuration",
Temperature = 'Temperature', Temperature = "Temperature",
Spray = 'Spray', Spray = "Spray",
Led = 'Led', Led = "Led",
} }
export enum InputType { export enum InputType {
Unknown = 'Unknown', Unknown = "Unknown",
Battery = 'Battery', Battery = "Battery",
RSSI = 'RSSI', RSSI = "RSSI",
Button = 'Button', Button = "Button",
Pressure = 'Pressure', Pressure = "Pressure",
// Temperature, // Temperature,
// Accelerometer, // Accelerometer,
// Gyro, // Gyro,
} }
export enum InputCommandType { export enum InputCommandType {
Read = 'Read', Read = "Read",
Subscribe = 'Subscribe', Subscribe = "Subscribe",
Unsubscribe = 'Unsubscribe', Unsubscribe = "Unsubscribe",
} }
export interface DeviceFeatureInput { export interface DeviceFeatureInput {

View File

@@ -1,4 +1,4 @@
declare module "*.json" { declare module "*.json" {
const content: string; const content: string;
export default content; export default content;
} }

View File

@@ -6,27 +6,24 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
import { ButtplugMessage } from './core/Messages'; import { ButtplugMessage } from "./core/Messages";
import { IButtplugClientConnector } from './client/IButtplugClientConnector'; import { IButtplugClientConnector } from "./client/IButtplugClientConnector";
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from "eventemitter3";
export * from './client/ButtplugClient'; export * from "./client/ButtplugClient";
export * from './client/ButtplugClientDevice'; export * from "./client/ButtplugClientDevice";
export * from './client/ButtplugBrowserWebsocketClientConnector'; export * from "./client/ButtplugBrowserWebsocketClientConnector";
export * from './client/ButtplugNodeWebsocketClientConnector'; export * from "./client/ButtplugNodeWebsocketClientConnector";
export * from './client/ButtplugClientConnectorException'; export * from "./client/ButtplugClientConnectorException";
export * from './utils/ButtplugMessageSorter'; export * from "./utils/ButtplugMessageSorter";
export * from './client/ButtplugClientDeviceCommand'; export * from "./client/ButtplugClientDeviceCommand";
export * from './client/ButtplugClientDeviceFeature'; export * from "./client/ButtplugClientDeviceFeature";
export * from './client/IButtplugClientConnector'; export * from "./client/IButtplugClientConnector";
export * from './core/Messages'; export * from "./core/Messages";
export * from './core/Logging'; export * from "./core/Logging";
export * from './core/Exceptions'; export * from "./core/Exceptions";
export class ButtplugWasmClientConnector export class ButtplugWasmClientConnector extends EventEmitter implements IButtplugClientConnector {
extends EventEmitter
implements IButtplugClientConnector
{
private static _loggingActivated = false; private static _loggingActivated = false;
private static wasmInstance; private static wasmInstance;
private _connected: boolean = false; private _connected: boolean = false;
@@ -43,35 +40,30 @@ export class ButtplugWasmClientConnector
private static maybeLoadWasm = async () => { private static maybeLoadWasm = async () => {
if (ButtplugWasmClientConnector.wasmInstance == undefined) { if (ButtplugWasmClientConnector.wasmInstance == undefined) {
ButtplugWasmClientConnector.wasmInstance = await import( ButtplugWasmClientConnector.wasmInstance = await import("../wasm/index.js");
'../wasm/index.js'
);
} }
}; };
public static activateLogging = async (logLevel: string = 'debug') => { public static activateLogging = async (logLevel: string = "debug") => {
await ButtplugWasmClientConnector.maybeLoadWasm(); await ButtplugWasmClientConnector.maybeLoadWasm();
if (this._loggingActivated) { if (this._loggingActivated) {
console.log('Logging already activated, ignoring.'); console.log("Logging already activated, ignoring.");
return; return;
} }
console.log('Turning on logging.'); console.log("Turning on logging.");
ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger( ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger(logLevel);
logLevel,
);
}; };
public initialize = async (): Promise<void> => {}; public initialize = async (): Promise<void> => {};
public connect = async (): Promise<void> => { public connect = async (): Promise<void> => {
await ButtplugWasmClientConnector.maybeLoadWasm(); await ButtplugWasmClientConnector.maybeLoadWasm();
this.client = this.client = ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server(
ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server( (msgs) => {
(msgs) => { this.emitMessage(msgs);
this.emitMessage(msgs); },
}, this.serverPtr,
this.serverPtr, );
);
this._connected = true; this._connected = true;
}; };
@@ -80,7 +72,7 @@ export class ButtplugWasmClientConnector
public send = (msg: ButtplugMessage): void => { public send = (msg: ButtplugMessage): void => {
ButtplugWasmClientConnector.wasmInstance.buttplug_client_send_json_message( ButtplugWasmClientConnector.wasmInstance.buttplug_client_send_json_message(
this.client, this.client,
new TextEncoder().encode('[' + JSON.stringify(msg) + ']'), new TextEncoder().encode("[" + JSON.stringify(msg) + "]"),
(output) => { (output) => {
this.emitMessage(output); this.emitMessage(output);
}, },
@@ -90,6 +82,6 @@ export class ButtplugWasmClientConnector
private emitMessage = (msg: Uint8Array) => { private emitMessage = (msg: Uint8Array) => {
const str = new TextDecoder().decode(msg); const str = new TextDecoder().decode(msg);
const msgs: ButtplugMessage[] = JSON.parse(str); const msgs: ButtplugMessage[] = JSON.parse(str);
this.emit('message', msgs); this.emit("message", msgs);
}; };
} }

View File

@@ -6,10 +6,10 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
'use strict'; "use strict";
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from "eventemitter3";
import { ButtplugMessage } from '../core/Messages'; import { ButtplugMessage } from "../core/Messages";
export class ButtplugBrowserWebsocketConnector extends EventEmitter { export class ButtplugBrowserWebsocketConnector extends EventEmitter {
protected _ws: WebSocket | undefined; protected _ws: WebSocket | undefined;
@@ -26,18 +26,20 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
public connect = async (): Promise<void> => { public connect = async (): Promise<void> => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const ws = new (this._websocketConstructor ?? WebSocket)(this._url); const ws = new (this._websocketConstructor ?? WebSocket)(this._url);
const onErrorCallback = (event: Event) => {reject(event)} const onErrorCallback = (event: Event) => {
const onCloseCallback = (event: CloseEvent) => reject(event.reason) reject(event);
ws.addEventListener('open', async () => { };
const onCloseCallback = (event: CloseEvent) => reject(event.reason);
ws.addEventListener("open", async () => {
this._ws = ws; this._ws = ws;
try { try {
await this.initialize(); await this.initialize();
this._ws.addEventListener('message', (msg) => { this._ws.addEventListener("message", (msg) => {
this.parseIncomingMessage(msg); this.parseIncomingMessage(msg);
}); });
this._ws.removeEventListener('close', onCloseCallback); this._ws.removeEventListener("close", onCloseCallback);
this._ws.removeEventListener('error', onErrorCallback); this._ws.removeEventListener("error", onErrorCallback);
this._ws.addEventListener('close', this.disconnect); this._ws.addEventListener("close", this.disconnect);
resolve(); resolve();
} catch (e) { } catch (e) {
reject(e); reject(e);
@@ -47,8 +49,8 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
// browsers usually only throw Error Code 1006. It's up to those using this // browsers usually only throw Error Code 1006. It's up to those using this
// library to state what the problem might be. // library to state what the problem might be.
ws.addEventListener('error', onErrorCallback) ws.addEventListener("error", onErrorCallback);
ws.addEventListener('close', onCloseCallback); ws.addEventListener("close", onCloseCallback);
}); });
}; };
@@ -58,14 +60,14 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
} }
this._ws!.close(); this._ws!.close();
this._ws = undefined; this._ws = undefined;
this.emit('disconnect'); this.emit("disconnect");
}; };
public sendMessage(msg: ButtplugMessage) { public sendMessage(msg: ButtplugMessage) {
if (!this.Connected) { if (!this.Connected) {
throw new Error('ButtplugBrowserWebsocketConnector not connected'); throw new Error("ButtplugBrowserWebsocketConnector not connected");
} }
this._ws!.send('[' + JSON.stringify(msg) + ']'); this._ws!.send("[" + JSON.stringify(msg) + "]");
} }
public initialize = async (): Promise<void> => { public initialize = async (): Promise<void> => {
@@ -73,9 +75,9 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
}; };
protected parseIncomingMessage(event: MessageEvent) { protected parseIncomingMessage(event: MessageEvent) {
if (typeof event.data === 'string') { if (typeof event.data === "string") {
const msgs: ButtplugMessage[] = JSON.parse(event.data); const msgs: ButtplugMessage[] = JSON.parse(event.data);
this.emit('message', msgs); this.emit("message", msgs);
} else if (event.data instanceof Blob) { } else if (event.data instanceof Blob) {
// No-op, we only use text message types. // No-op, we only use text message types.
} }
@@ -83,6 +85,6 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
protected onReaderLoad(event: Event) { protected onReaderLoad(event: Event) {
const msgs: ButtplugMessage[] = JSON.parse((event.target as FileReader).result as string); const msgs: ButtplugMessage[] = JSON.parse((event.target as FileReader).result as string);
this.emit('message', msgs); this.emit("message", msgs);
} }
} }

View File

@@ -6,8 +6,8 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
import * as Messages from '../core/Messages'; import * as Messages from "../core/Messages";
import { ButtplugError } from '../core/Exceptions'; import { ButtplugError } from "../core/Exceptions";
export class ButtplugMessageSorter { export class ButtplugMessageSorter {
protected _counter = 1; protected _counter = 1;
@@ -21,9 +21,7 @@ export class ButtplugMessageSorter {
// One of the places we should actually return a promise, as we need to store // One of the places we should actually return a promise, as we need to store
// them while waiting for them to return across the line. // them while waiting for them to return across the line.
// tslint:disable:promise-function-async // tslint:disable:promise-function-async
public PrepareOutgoingMessage( public PrepareOutgoingMessage(msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> {
msg: Messages.ButtplugMessage
): Promise<Messages.ButtplugMessage> {
if (this._useCounter) { if (this._useCounter) {
Messages.setMsgId(msg, this._counter); Messages.setMsgId(msg, this._counter);
// Always increment last, otherwise we might lose sync // Always increment last, otherwise we might lose sync
@@ -31,19 +29,15 @@ export class ButtplugMessageSorter {
} }
let res; let res;
let rej; let rej;
const msgPromise = new Promise<Messages.ButtplugMessage>( const msgPromise = new Promise<Messages.ButtplugMessage>((resolve, reject) => {
(resolve, reject) => { res = resolve;
res = resolve; rej = reject;
rej = reject; });
}
);
this._waitingMsgs.set(Messages.msgId(msg), [res, rej]); this._waitingMsgs.set(Messages.msgId(msg), [res, rej]);
return msgPromise; return msgPromise;
} }
public ParseIncomingMessages( public ParseIncomingMessages(msgs: Messages.ButtplugMessage[]): Messages.ButtplugMessage[] {
msgs: Messages.ButtplugMessage[]
): Messages.ButtplugMessage[] {
const noMatch: Messages.ButtplugMessage[] = []; const noMatch: Messages.ButtplugMessage[] = [];
for (const x of msgs) { for (const x of msgs) {
let id = Messages.msgId(x); let id = Messages.msgId(x);

View File

@@ -1,3 +1,3 @@
export function getRandomInt(max: number) { export function getRandomInt(max: number) {
return Math.floor(Math.random() * Math.floor(max)); return Math.floor(Math.random() * Math.floor(max));
} }

View File

@@ -1,11 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "esnext", "target": "esnext",
"module": "esnext", "module": "esnext",
"outDir": "dist", "outDir": "dist",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -3,19 +3,19 @@ import path from "path";
import wasm from "vite-plugin-wasm"; import wasm from "vite-plugin-wasm";
export default defineConfig({ export default defineConfig({
plugins: [wasm()], // include wasm plugin plugins: [wasm()], // include wasm plugin
build: { build: {
lib: { lib: {
entry: path.resolve(__dirname, "src/index.ts"), entry: path.resolve(__dirname, "src/index.ts"),
name: "buttplug", name: "buttplug",
fileName: "index", fileName: "index",
formats: ["es"], // this is important formats: ["es"], // this is important
}, },
minify: false, // for demo purposes minify: false, // for demo purposes
target: "esnext", // this is important as well target: "esnext", // this is important as well
outDir: "dist", outDir: "dist",
rollupOptions: { rollupOptions: {
external: [/\.\/wasm\//, /\.\.\/wasm\//], external: [/\.\/wasm\//, /\.\.\/wasm\//],
}, },
}, },
}); });

View File

@@ -1,16 +1,16 @@
{ {
"$schema": "https://shadcn-svelte.com/schema.json", "$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": { "tailwind": {
"css": "src/app.css", "css": "src/app.css",
"baseColor": "slate" "baseColor": "slate"
}, },
"aliases": { "aliases": {
"components": "$lib/components", "components": "$lib/components",
"utils": "$lib/utils", "utils": "$lib/utils",
"ui": "$lib/components/ui", "ui": "$lib/components/ui",
"hooks": "$lib/hooks", "hooks": "$lib/hooks",
"lib": "$lib" "lib": "$lib"
}, },
"typescript": true, "typescript": true,
"registry": "https://shadcn-svelte.com/registry" "registry": "https://shadcn-svelte.com/registry"
} }

View File

@@ -1,16 +1,16 @@
{ {
"$schema": "https://unpkg.com/jsrepo@2.4.9/schemas/project-config.json", "$schema": "https://unpkg.com/jsrepo@2.4.9/schemas/project-config.json",
"repos": ["@ieedan/shadcn-svelte-extras"], "repos": ["@ieedan/shadcn-svelte-extras"],
"includeTests": false, "includeTests": false,
"includeDocs": false, "includeDocs": false,
"watermark": true, "watermark": true,
"formatter": "prettier", "formatter": "prettier",
"configFiles": {}, "configFiles": {},
"paths": { "paths": {
"*": "$lib/blocks", "*": "$lib/blocks",
"ui": "$lib/components/ui", "ui": "$lib/components/ui",
"actions": "$lib/actions", "actions": "$lib/actions",
"hooks": "$lib/hooks", "hooks": "$lib/hooks",
"utils": "$lib/utils" "utils": "$lib/utils"
} }
} }

View File

@@ -8,82 +8,82 @@
@custom-variant hover (&:hover); @custom-variant hover (&:hover);
@theme { @theme {
--animate-vibrate: vibrate 0.3s linear infinite; --animate-vibrate: vibrate 0.3s linear infinite;
--animate-fade-in: fadeIn 0.3s ease-out; --animate-fade-in: fadeIn 0.3s ease-out;
--animate-slide-up: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1); --animate-slide-up: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--animate-zoom-in: zoomIn 0.4s cubic-bezier(0.4, 0, 0.2, 1); --animate-zoom-in: zoomIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--animate-pulse-glow: pulseGlow 2s infinite; --animate-pulse-glow: pulseGlow 2s infinite;
@keyframes vibrate { @keyframes vibrate {
0% { 0% {
transform: translate(0); transform: translate(0);
} }
20% { 20% {
transform: translate(-2px, 2px); transform: translate(-2px, 2px);
} }
40% { 40% {
transform: translate(-2px, -2px); transform: translate(-2px, -2px);
} }
60% { 60% {
transform: translate(2px, 2px); transform: translate(2px, 2px);
} }
80% { 80% {
transform: translate(2px, -2px); transform: translate(2px, -2px);
} }
100% { 100% {
transform: translate(0); transform: translate(0);
} }
} }
@keyframes fadeIn { @keyframes fadeIn {
0% { 0% {
opacity: 0; opacity: 0;
} }
100% { 100% {
opacity: 1; opacity: 1;
} }
} }
@keyframes slideUp { @keyframes slideUp {
0% { 0% {
opacity: 0; opacity: 0;
transform: translateY(30px) scale(0.95); transform: translateY(30px) scale(0.95);
} }
100% { 100% {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }
} }
@keyframes zoomIn { @keyframes zoomIn {
0% { 0% {
opacity: 0; opacity: 0;
transform: scale(0.9); transform: scale(0.9);
} }
100% { 100% {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
} }
} }
@keyframes pulseGlow { @keyframes pulseGlow {
0%, 0%,
100% { 100% {
boxShadow: 0 0 20px rgba(183, 0, 217, 0.3); boxshadow: 0 0 20px rgba(183, 0, 217, 0.3);
} }
50% { 50% {
boxShadow: 0 0 40px rgba(183, 0, 217, 0.6); boxshadow: 0 0 40px rgba(183, 0, 217, 0.6);
} }
} }
} }
/* /*
@@ -95,134 +95,134 @@
color utility to any element that depends on these defaults. color utility to any element that depends on these defaults.
*/ */
@layer base { @layer base {
* { * {
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
outline-color: color-mix(in oklab, var(--ring) 50%, transparent); outline-color: color-mix(in oklab, var(--ring) 50%, transparent);
} }
} }
* { * {
border-color: var(--border); border-color: var(--border);
outline-color: var(--ring); outline-color: var(--ring);
} }
.prose h2 { .prose h2 {
@apply text-2xl font-bold mt-8 mb-4 text-foreground; @apply text-2xl font-bold mt-8 mb-4 text-foreground;
} }
.prose h3 { .prose h3 {
@apply text-xl font-semibold mt-6 mb-3 text-foreground; @apply text-xl font-semibold mt-6 mb-3 text-foreground;
} }
.prose p { .prose p {
@apply mb-4 leading-relaxed; @apply mb-4 leading-relaxed;
} }
.prose ul { .prose ul {
@apply mb-4 pl-6; @apply mb-4 pl-6;
} }
.prose li { .prose li {
@apply mb-2; @apply mb-2;
} }
} }
:root { :root {
--default-font-family: "Noto Sans", sans-serif; --default-font-family: "Noto Sans", sans-serif;
--background: oklch(0.98 0.01 320); --background: oklch(0.98 0.01 320);
--foreground: oklch(0.08 0.02 280); --foreground: oklch(0.08 0.02 280);
--muted: oklch(0.95 0.01 280); --muted: oklch(0.95 0.01 280);
--muted-foreground: oklch(0.4 0.02 280); --muted-foreground: oklch(0.4 0.02 280);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--card: oklch(0.99 0.005 320); --card: oklch(0.99 0.005 320);
--card-foreground: oklch(0.08 0.02 280); --card-foreground: oklch(0.08 0.02 280);
--border: oklch(0.85 0.02 280); --border: oklch(0.85 0.02 280);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--primary: oklch(56.971% 0.27455 319.257); --primary: oklch(56.971% 0.27455 319.257);
--primary-foreground: oklch(0.98 0.01 320); --primary-foreground: oklch(0.98 0.01 320);
--secondary: oklch(0.92 0.02 260); --secondary: oklch(0.92 0.02 260);
--secondary-foreground: oklch(0.15 0.05 260); --secondary-foreground: oklch(0.15 0.05 260);
--accent: oklch(0.45 0.35 280); --accent: oklch(0.45 0.35 280);
--accent-foreground: oklch(0.98 0.01 280); --accent-foreground: oklch(0.98 0.01 280);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.985 0 0);
--ring: oklch(0.55 0.3 320); --ring: oklch(0.55 0.3 320);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
} }
.dark { .dark {
--background: oklch(0.08 0.02 280); --background: oklch(0.08 0.02 280);
--foreground: oklch(0.98 0.01 280); --foreground: oklch(0.98 0.01 280);
--muted: oklch(0.12 0.03 280); --muted: oklch(0.12 0.03 280);
--muted-foreground: oklch(0.6 0.02 280); --muted-foreground: oklch(0.6 0.02 280);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--card: oklch(0.1 0.02 280); --card: oklch(0.1 0.02 280);
--card-foreground: oklch(0.95 0.01 280); --card-foreground: oklch(0.95 0.01 280);
--border: oklch(0.2 0.05 280); --border: oklch(0.2 0.05 280);
--input: oklch(1 0 0 / 0.15); --input: oklch(1 0 0 / 0.15);
--primary: oklch(0.65 0.25 320); --primary: oklch(0.65 0.25 320);
--primary-foreground: oklch(0.98 0.01 320); --primary-foreground: oklch(0.98 0.01 320);
--secondary: oklch(0.15 0.05 260); --secondary: oklch(0.15 0.05 260);
--secondary-foreground: oklch(0.9 0.02 260); --secondary-foreground: oklch(0.9 0.02 260);
--accent: oklch(0.55 0.3 280); --accent: oklch(0.55 0.3 280);
--accent-foreground: oklch(0.98 0.01 280); --accent-foreground: oklch(0.98 0.01 280);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.985 0 0);
--ring: oklch(0.65 0.25 320); --ring: oklch(0.65 0.25 320);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 0.1); --sidebar-border: oklch(1 0 0 / 0.1);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-mono); --font-mono: var(--font-mono);
--font-serif: var(--font-serif); --font-serif: var(--font-serif);
} }

View File

@@ -4,22 +4,22 @@ import type { AuthStatus } from "$lib/types";
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
authStatus: AuthStatus; authStatus: AuthStatus;
requestId: string; requestId: string;
} }
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
} }
interface Window { interface Window {
sidebar: { sidebar: {
addPanel: () => void; addPanel: () => void;
}; };
opera: object; opera: object;
} }
} }
export {}; export {};

View File

@@ -1,24 +1,23 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400..700&family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet"> <link
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400..700&family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet"
/>
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover" class="dark"> <body data-sveltekit-preload-data="hover" class="dark">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html>
</html>

View File

@@ -6,88 +6,88 @@ import type { Handle } from "@sveltejs/kit";
logger.startup(); logger.startup();
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const { cookies, locals, url, request } = event; const { cookies, locals, url, request } = event;
const startTime = Date.now(); const startTime = Date.now();
// Generate unique request ID // Generate unique request ID
const requestId = generateRequestId(); const requestId = generateRequestId();
// Add request ID to locals for access in other handlers // Add request ID to locals for access in other handlers
locals.requestId = requestId; locals.requestId = requestId;
// Log incoming request // Log incoming request
logger.request(request.method, url.pathname, { logger.request(request.method, url.pathname, {
requestId, requestId,
context: { context: {
userAgent: request.headers.get('user-agent')?.substring(0, 100), userAgent: request.headers.get("user-agent")?.substring(0, 100),
referer: request.headers.get('referer'), referer: request.headers.get("referer"),
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'), ip: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"),
}, },
}); });
// Handle authentication // Handle authentication
const token = cookies.get("session_token"); const token = cookies.get("session_token");
if (token) { if (token) {
try { try {
locals.authStatus = await isAuthenticated(token); locals.authStatus = await isAuthenticated(token);
if (locals.authStatus.authenticated) { if (locals.authStatus.authenticated) {
logger.auth('Token validated', true, { logger.auth("Token validated", true, {
requestId, requestId,
userId: locals.authStatus.user?.id, userId: locals.authStatus.user?.id,
context: { context: {
email: locals.authStatus.user?.email, email: locals.authStatus.user?.email,
role: locals.authStatus.user?.role, role: locals.authStatus.user?.role,
}, },
}); });
} else { } else {
logger.auth('Token invalid', false, { requestId }); logger.auth("Token invalid", false, { requestId });
} }
} catch (error) { } catch (error) {
logger.error('Authentication check failed', { logger.error("Authentication check failed", {
requestId, requestId,
error: error instanceof Error ? error : new Error(String(error)), error: error instanceof Error ? error : new Error(String(error)),
}); });
locals.authStatus = { authenticated: false }; locals.authStatus = { authenticated: false };
} }
} else { } else {
logger.debug('No session token found', { requestId }); logger.debug("No session token found", { requestId });
locals.authStatus = { authenticated: false }; locals.authStatus = { authenticated: false };
} }
// Resolve the request // Resolve the request
let response: Response; let response: Response;
try { try {
response = await resolve(event, { response = await resolve(event, {
filterSerializedResponseHeaders: (key) => { filterSerializedResponseHeaders: (key) => {
return key.toLowerCase() === "content-type"; return key.toLowerCase() === "content-type";
}, },
}); });
} catch (error) { } catch (error) {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
logger.error('Request handler error', { logger.error("Request handler error", {
requestId, requestId,
method: request.method, method: request.method,
path: url.pathname, path: url.pathname,
duration, duration,
error: error instanceof Error ? error : new Error(String(error)), error: error instanceof Error ? error : new Error(String(error)),
}); });
throw error; throw error;
} }
// Log response // Log response
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
logger.response(request.method, url.pathname, response.status, duration, { logger.response(request.method, url.pathname, response.status, duration, {
requestId, requestId,
userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined, userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined,
context: { context: {
cached: response.headers.get('x-sveltekit-page') === 'true', cached: response.headers.get("x-sveltekit-page") === "true",
}, },
}); });
// Add request ID to response headers (useful for debugging) // Add request ID to response headers (useful for debugging)
response.headers.set('x-request-id', requestId); response.headers.set("x-request-id", requestId);
return response; return response;
}; };

View File

@@ -1,77 +1,70 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "$lib/components/ui/dialog"; } from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import { onMount } from "svelte"; import { onMount } from "svelte";
const AGE_VERIFICATION_KEY = "age-verified"; const AGE_VERIFICATION_KEY = "age-verified";
let isOpen = true; let isOpen = true;
function handleAgeConfirmation() { function handleAgeConfirmation() {
localStorage.setItem(AGE_VERIFICATION_KEY, "true"); localStorage.setItem(AGE_VERIFICATION_KEY, "true");
isOpen = false; isOpen = false;
} }
onMount(() => { onMount(() => {
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY); const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
if (storedVerification === "true") { if (storedVerification === "true") {
isOpen = false; isOpen = false;
} }
}); });
</script> </script>
<Dialog bind:open={isOpen}> <Dialog bind:open={isOpen}>
<DialogContent <DialogContent
class="sm:max-w-md" class="sm:max-w-md"
onInteractOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()}
showCloseButton={false} showCloseButton={false}
> >
<DialogHeader class="space-y-4"> <DialogHeader class="space-y-4">
<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">
<div <div
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center" class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
> >
<span class="text-primary-foreground text-sm" <span class="text-primary-foreground text-sm">{$_("age_verification_dialog.age")}</span>
>{$_("age_verification_dialog.age")}</span </div>
> <div class="">
</div> <DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
<div class=""> >{$_("age_verification_dialog.title")}</DialogTitle
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
>{$_("age_verification_dialog.title")}</DialogTitle
>
<DialogDescription class="text-left text-sm">
{$_("age_verification_dialog.description")}
</DialogDescription>
</div>
</div>
</div>
</DialogHeader>
<Separator class="my-4" />
<!-- Close Button -->
<div class="flex justify-end gap-4">
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
{$_("age_verification_dialog.exit")}
</Button>
<Button
variant="default"
size="sm"
onclick={handleAgeConfirmation}
class="cursor-pointer"
> >
<span class="icon-[ri--check-line]"></span> <DialogDescription class="text-left text-sm">
{$_("age_verification_dialog.confirm")} {$_("age_verification_dialog.description")}
</Button> </DialogDescription>
</div>
</div> </div>
</DialogContent> </div>
</DialogHeader>
<Separator class="my-4" />
<!-- Close Button -->
<div class="flex justify-end gap-4">
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
{$_("age_verification_dialog.exit")}
</Button>
<Button variant="default" size="sm" onclick={handleAgeConfirmation} class="cursor-pointer">
<span class="icon-[ri--check-line]"></span>
{$_("age_verification_dialog.confirm")}
</Button>
</div>
</DialogContent>
</Dialog> </Dialog>

View File

@@ -1,55 +1,55 @@
<!-- Advanced Plasma Background --> <!-- Advanced Plasma Background -->
<div class="absolute inset-0 pointer-events-none"> <div class="absolute inset-0 pointer-events-none">
<!-- Primary gradient layers --> <!-- Primary gradient layers -->
<div <div
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60" class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
></div> ></div>
<div <div
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40" class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
></div> ></div>
<!-- Large floating orbs --> <!-- Large floating orbs -->
<!-- <div <!-- <div
class="absolute top-20 left-20 w-80 h-80 bg-gradient-to-br from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-slow" class="absolute top-20 left-20 w-80 h-80 bg-gradient-to-br from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-slow"
></div> ></div>
<div <div
class="absolute bottom-20 right-20 w-96 h-96 bg-gradient-to-tl from-accent/12 via-primary/18 to-accent/8 rounded-full blur-3xl animate-blob-slow animation-delay-6000" class="absolute bottom-20 right-20 w-96 h-96 bg-gradient-to-tl from-accent/12 via-primary/18 to-accent/8 rounded-full blur-3xl animate-blob-slow animation-delay-6000"
></div> --> ></div> -->
<!-- Medium morphing elements --> <!-- Medium morphing elements -->
<!-- <div <!-- <div
class="absolute top-1/2 left-1/3 w-64 h-64 bg-gradient-to-r from-primary/10 via-accent/15 to-primary/8 rounded-full blur-2xl animate-blob-reverse animation-delay-3000" class="absolute top-1/2 left-1/3 w-64 h-64 bg-gradient-to-r from-primary/10 via-accent/15 to-primary/8 rounded-full blur-2xl animate-blob-reverse animation-delay-3000"
></div> ></div>
<div <div
class="absolute bottom-1/3 right-1/3 w-72 h-72 bg-gradient-to-l from-accent/10 via-primary/15 to-accent/8 rounded-full blur-2xl animate-blob-reverse animation-delay-9000" class="absolute bottom-1/3 right-1/3 w-72 h-72 bg-gradient-to-l from-accent/10 via-primary/15 to-accent/8 rounded-full blur-2xl animate-blob-reverse animation-delay-9000"
></div> --> ></div> -->
<!-- Soft particle effects --> <!-- Soft particle effects -->
<!-- <div <!-- <div
class="absolute top-1/4 right-1/4 w-48 h-48 bg-gradient-to-br from-primary/15 to-accent/12 rounded-full blur-xl animate-float animation-delay-2000" class="absolute top-1/4 right-1/4 w-48 h-48 bg-gradient-to-br from-primary/15 to-accent/12 rounded-full blur-xl animate-float animation-delay-2000"
></div> ></div>
<div <div
class="absolute bottom-1/4 left-1/4 w-56 h-56 bg-gradient-to-tl from-accent/15 to-primary/12 rounded-full blur-xl animate-float animation-delay-8000" class="absolute bottom-1/4 left-1/4 w-56 h-56 bg-gradient-to-tl from-accent/15 to-primary/12 rounded-full blur-xl animate-float animation-delay-8000"
></div> --> ></div> -->
<!-- Premium glassmorphism overlay --> <!-- Premium glassmorphism overlay -->
<!-- <div <!-- <div
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/3 backdrop-blur-[1px]" class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/3 backdrop-blur-[1px]"
></div> --> ></div> -->
<!-- Animated Plasma Background --> <!-- Animated Plasma Background -->
<div <div
class="absolute top-1/3 left-1/3 w-72 h-72 bg-gradient-to-r from-accent/20 via-primary/25 to-accent/15 rounded-full blur-2xl animate-blob" class="absolute top-1/3 left-1/3 w-72 h-72 bg-gradient-to-r from-accent/20 via-primary/25 to-accent/15 rounded-full blur-2xl animate-blob"
></div> ></div>
<div <div
class="absolute bottom-1/3 right-1/3 w-88 h-88 bg-gradient-to-r from-primary/20 via-accent/25 to-primary/15 rounded-full blur-3xl animate-blob-reverse animation-delay-3000" class="absolute bottom-1/3 right-1/3 w-88 h-88 bg-gradient-to-r from-primary/20 via-accent/25 to-primary/15 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
></div> ></div>
<div <div
class="absolute top-1/2 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/20 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1000" class="absolute top-1/2 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/20 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1000"
></div> ></div>
<!-- Global Plasma Background --> <!-- Global Plasma Background -->
<!-- <div <!-- <div
class="absolute top-32 right-32 w-72 h-72 bg-gradient-to-r from-accent/18 via-primary/22 to-accent/12 rounded-full blur-3xl animate-blob" class="absolute top-32 right-32 w-72 h-72 bg-gradient-to-r from-accent/18 via-primary/22 to-accent/12 rounded-full blur-3xl animate-blob"
></div> ></div>
<div <div

View File

@@ -1,12 +1,8 @@
<script lang="ts"> <script lang="ts">
const { isMobileMenuOpen = $bindable(), label, onclick } = $props(); const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
</script> </script>
<button <button class="block rounded-full cursor-pointer" {onclick} aria-label={label}>
class="block rounded-full cursor-pointer"
onclick={onclick}
aria-label={label}
>
<div <div
class="relative flex overflow-hidden items-center justify-center rounded-full w-[50px] h-[50px] transform transition-all duration-200 shadow-md opacity-90 translate-x-3" class="relative flex overflow-hidden items-center justify-center rounded-full w-[50px] h-[50px] transform transition-all duration-200 shadow-md opacity-90 translate-x-3"
> >
@@ -14,23 +10,23 @@ const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden" class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
> >
<div <div
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? 'translate-x-10' : ''}`} class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? "translate-x-10" : ""}`}
></div> ></div>
<div <div
class={`bg-white h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? 'translate-x-10' : ''}`} class={`bg-white h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
></div> ></div>
<div <div
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? 'translate-x-10' : ''}`} class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
></div> ></div>
<div <div
class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? 'translate-x-0 w-12' : ''}`} class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? "translate-x-0 w-12" : ""}`}
> >
<div <div
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? 'rotate-45' : ''}`} class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? "rotate-45" : ""}`}
></div> ></div>
<div <div
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? '-rotate-45' : ''}`} class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? "-rotate-45" : ""}`}
></div> ></div>
</div> </div>
</div> </div>

View File

@@ -1,99 +1,92 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import { Slider } from "$lib/components/ui/slider"; import { Slider } from "$lib/components/ui/slider";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card"; import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import type { BluetoothDevice } from "$lib/types"; import type { BluetoothDevice } from "$lib/types";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
interface Props { interface Props {
device: BluetoothDevice; device: BluetoothDevice;
onChange: (scalarIndex: number, val: number) => void; onChange: (scalarIndex: number, val: number) => void;
onStop: () => void; onStop: () => void;
} }
let { device, onChange, onStop }: Props = $props(); let { device, onChange, onStop }: Props = $props();
function getBatteryColor(level: number) { function getBatteryColor(level: number) {
if (!device.hasBattery) { if (!device.hasBattery) {
return "text-gray-400"; return "text-gray-400";
} }
if (level > 60) return "text-green-400"; if (level > 60) return "text-green-400";
if (level > 30) return "text-yellow-400"; if (level > 30) return "text-yellow-400";
return "text-red-400"; return "text-red-400";
} }
function getBatteryBgColor(level: number) { function getBatteryBgColor(level: number) {
if (!device.hasBattery) { if (!device.hasBattery) {
return "bg-gray-400/20"; return "bg-gray-400/20";
} }
if (level > 60) return "bg-green-400/20"; if (level > 60) return "bg-green-400/20";
if (level > 30) return "bg-yellow-400/20"; if (level > 30) return "bg-yellow-400/20";
return "bg-red-400/20"; return "bg-red-400/20";
} }
function getScalarAnimations() { function getScalarAnimations() {
return device.actuators return device.actuators
.filter((a) => a.value > 0) .filter((a) => a.value > 0)
.map((a) => `animate-${a.outputType.toLowerCase()}`); .map((a) => `animate-${a.outputType.toLowerCase()}`);
} }
function isActive() { function isActive() {
return device.actuators.some((a) => a.value > 0); return device.actuators.some((a) => a.value > 0);
} }
</script> </script>
<Card <Card
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm" class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
> >
<CardHeader class="pb-3"> <CardHeader class="pb-3">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div <div
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0" class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
> >
<span class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}></span> <span
</div> class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}
<div> ></span>
<h3 </div>
class="font-semibold text-card-foreground group-hover:text-primary transition-colors" <div>
> <h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
{device.name} {device.name}
</h3> </h3>
<!-- <p class="text-sm text-muted-foreground"> <!-- <p class="text-sm text-muted-foreground">
{device.deviceType} {device.deviceType}
</p> --> </p> -->
</div>
</div>
<button class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`} onclick={() => isActive() && onStop()}>
<div class="relative">
<div
class="w-2 h-2 rounded-full {isActive()
? 'bg-green-400'
: 'bg-red-400'}"
></div>
{#if isActive()}
<div
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
></div>
{/if}
</div>
<span
class="text-xs font-medium {isActive()
? 'text-green-400'
: 'text-red-400'}"
>
{isActive()
? $_("device_card.active")
: $_("device_card.paused")}
</span>
</button>
</div> </div>
</CardHeader> </div>
<button
class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`}
onclick={() => isActive() && onStop()}
>
<div class="relative">
<div class="w-2 h-2 rounded-full {isActive() ? 'bg-green-400' : 'bg-red-400'}"></div>
{#if isActive()}
<div
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
></div>
{/if}
</div>
<span class="text-xs font-medium {isActive() ? 'text-green-400' : 'text-red-400'}">
{isActive() ? $_("device_card.active") : $_("device_card.paused")}
</span>
</button>
</div>
</CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<!-- Current Value --> <!-- Current Value -->
<!-- <div <!-- <div
class="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/30" class="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/30"
> >
<span class="text-sm text-muted-foreground" <span class="text-sm text-muted-foreground"
@@ -103,58 +96,54 @@ function isActive() {
> >
</div> --> </div> -->
<!-- Battery Level --> <!-- Battery Level -->
<div class="space-y-2"> <div class="space-y-2">
<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">
<span <span
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor( class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(device.batteryLevel)}"
device.batteryLevel, ></span>
)}" <span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
></span>
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
</div>
{#if device.hasBattery}
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
{device.batteryLevel}%
</span>
{/if}
</div>
<div class="w-full bg-muted/50 rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 {getBatteryBgColor(
device.batteryLevel,
)} bg-gradient-to-r from-current to-current/80"
style="width: {device.batteryLevel}%"
></div>
</div>
</div> </div>
{#if device.hasBattery}
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
{device.batteryLevel}%
</span>
{/if}
</div>
<div class="w-full bg-muted/50 rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 {getBatteryBgColor(
device.batteryLevel,
)} bg-gradient-to-r from-current to-current/80"
style="width: {device.batteryLevel}%"
></div>
</div>
</div>
<!-- Last Seen --> <!-- Last Seen -->
<!-- <div <!-- <div
class="flex items-center justify-between text-xs text-muted-foreground" class="flex items-center justify-between text-xs text-muted-foreground"
> >
<span>{$_("device_card.last_seen")}</span> <span>{$_("device_card.last_seen")}</span>
<span>{device.lastSeen.toLocaleTimeString()}</span> <span>{device.lastSeen.toLocaleTimeString()}</span>
</div> --> </div> -->
<!-- Action Button --> <!-- Action Button -->
{#each device.actuators as actuator, idx (idx)} {#each device.actuators as actuator, idx (idx)}
<div class="space-y-2"> <div class="space-y-2">
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`} <Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
>{$_( >{$_(`device_card.actuator_types.${actuator.outputType.toLowerCase()}`)}</Label
`device_card.actuator_types.${actuator.outputType.toLowerCase()}`, >
)}</Label <Slider
> id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
<Slider type="single"
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`} value={actuator.value}
type="single" onValueChange={(val) => onChange(idx, val)}
value={actuator.value} max={actuator.maxSteps}
onValueChange={(val) => onChange(idx, val)} step={1}
max={actuator.maxSteps} />
step={1} </div>
/> {/each}
</div> </CardContent>
{/each}
</CardContent>
</Card> </Card>

View File

@@ -1,120 +1,120 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import Logo from "../logo/logo.svelte"; import Logo from "../logo/logo.svelte";
</script> </script>
<footer <footer
class="bg-gradient-to-t from-card/95 to-card/85 backdrop-blur-xl mt-20 shadow-2xl shadow-primary/10" class="bg-gradient-to-t from-card/95 to-card/85 backdrop-blur-xl mt-20 shadow-2xl shadow-primary/10"
> >
<div class="container mx-auto px-4 py-12"> <div class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8"> <div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand --> <!-- Brand -->
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center gap-3 text-xl font-bold"> <div class="flex items-center gap-3 text-xl font-bold">
<Logo /> <Logo />
</div>
<p class="text-sm text-muted-foreground">{$_("brand.description")}</p>
<div class="flex gap-3">
<a
aria-label="Email"
href="mailto:{$_('footer.contact.email')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--mail-line] w-4 h-4 text-primary"></span>
</a>
<a
aria-label="X"
href="https://www.x.com/{$_('footer.contact.x')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--twitter-x-line] w-4 h-4 text-primary"></span>
</a>
<a
aria-label="YouTube"
href="https://www.youtube.com/@{$_('footer.contact.youtube')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--youtube-line] w-4 h-4 text-primary"></span>
</a>
</div>
</div>
<!-- Quick Links -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">
{$_("footer.quick_links")}
</h3>
<div class="space-y-2">
<a
href="/models"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.models")}</a
>
<a
href="/videos"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.videos")}</a
>
<a
href="/magazine"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.magazine")}</a
>
<a
href="/about"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.about")}</a
>
</div>
</div>
<!-- Support -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">{$_("footer.support")}</h3>
<div class="space-y-2">
<a
href="mailto:{$_('footer.contact_support_email')}"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.contact_support")}</a
>
<a
href="mailto:{$_('footer.model_applications_email')}"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.model_applications")}</a
>
<a
href="/faq"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.faq")}</a
>
</div>
</div>
<!-- Legal -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">{$_("footer.legal")}</h3>
<div class="space-y-2">
<a
href="/legal"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.privacy_policy")}</a
>
<a
href="/legal"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.terms_of_service")}</a
>
<a
href="/imprint"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.imprint")}</a
>
</div>
</div>
</div> </div>
<p class="text-sm text-muted-foreground">{$_("brand.description")}</p>
<div class="border-t border-border/50 mt-8 pt-8 text-center"> <div class="flex gap-3">
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p> <a
aria-label="Email"
href="mailto:{$_('footer.contact.email')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--mail-line] w-4 h-4 text-primary"></span>
</a>
<a
aria-label="X"
href="https://www.x.com/{$_('footer.contact.x')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--twitter-x-line] w-4 h-4 text-primary"></span>
</a>
<a
aria-label="YouTube"
href="https://www.youtube.com/@{$_('footer.contact.youtube')}"
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
>
<span class="icon-[ri--youtube-line] w-4 h-4 text-primary"></span>
</a>
</div> </div>
</div>
<!-- Quick Links -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">
{$_("footer.quick_links")}
</h3>
<div class="space-y-2">
<a
href="/models"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.models")}</a
>
<a
href="/videos"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.videos")}</a
>
<a
href="/magazine"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.magazine")}</a
>
<a
href="/about"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.about")}</a
>
</div>
</div>
<!-- Support -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">{$_("footer.support")}</h3>
<div class="space-y-2">
<a
href="mailto:{$_('footer.contact_support_email')}"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.contact_support")}</a
>
<a
href="mailto:{$_('footer.model_applications_email')}"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.model_applications")}</a
>
<a
href="/faq"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.faq")}</a
>
</div>
</div>
<!-- Legal -->
<div class="space-y-4">
<h3 class="font-semibold text-foreground">{$_("footer.legal")}</h3>
<div class="space-y-2">
<a
href="/legal"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.privacy_policy")}</a
>
<a
href="/legal"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.terms_of_service")}</a
>
<a
href="/imprint"
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
>{$_("footer.imprint")}</a
>
</div>
</div>
</div> </div>
<div class="border-t border-border/50 mt-8 pt-8 text-center">
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p>
</div>
</div>
</footer> </footer>

View File

@@ -7,9 +7,7 @@
stroke="#ce47eb" stroke="#ce47eb"
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
> >
<metadata> <metadata> Created by potrace 1.15, written by Peter Selinger 2001-2017 </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)"> <g transform="translate(0.000000,904.000000) scale(0.100000,-0.100000)">
<path <path
d="M7930 7043 c-73 -10 -95 -18 -134 -51 -25 -20 -66 -53 -91 -72 -26 d="M7930 7043 c-73 -10 -95 -18 -134 -51 -25 -20 -66 -53 -91 -72 -26
@@ -117,4 +115,4 @@ m-3487 -790 c-17 -35 -55 -110 -84 -168 -29 -58 -72 -163 -96 -235 -45 -134
/> />
</g> </g>
</svg> </svg>
</div> </div>

View File

@@ -1,51 +1,51 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { page } from "$app/state"; import { page } from "$app/state";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
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/directus";
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 Girls from "../girls/girls.svelte";
import Logo from "../logo/logo.svelte"; import Logo from "../logo/logo.svelte";
interface Props { interface Props {
authStatus: AuthStatus; authStatus: AuthStatus;
} }
let { authStatus }: Props = $props(); let { authStatus }: Props = $props();
let isMobileMenuOpen = $state(false); let isMobileMenuOpen = $state(false);
const navLinks = [ const navLinks = [
{ name: $_("header.home"), href: "/" }, { name: $_("header.home"), href: "/" },
{ name: $_("header.models"), href: "/models" }, { name: $_("header.models"), href: "/models" },
{ name: $_("header.videos"), href: "/videos" }, { name: $_("header.videos"), href: "/videos" },
{ name: $_("header.magazine"), href: "/magazine" }, { name: $_("header.magazine"), href: "/magazine" },
{ name: $_("header.about"), href: "/about" }, { name: $_("header.about"), href: "/about" },
]; ];
async function handleLogout() { async function handleLogout() {
closeMenu(); closeMenu();
await logout(); await logout();
goto("/login", { invalidateAll: true }); goto("/login", { invalidateAll: true });
} }
function closeMenu() { function closeMenu() {
isMobileMenuOpen = false; isMobileMenuOpen = false;
} }
function isActiveLink(link: any) { function isActiveLink(link: any) {
return ( return (
(page.url.pathname === "/" && link === navLinks[0]) || (page.url.pathname === "/" && link === navLinks[0]) ||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0]) (page.url.pathname.startsWith(link.href) && link !== navLinks[0])
); );
} }
</script> </script>
<header <header
@@ -58,7 +58,7 @@ function isActiveLink(link: any) {
href="/" href="/"
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300" class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
> >
<Logo hideName={true} /> <Logo hideName={true} />
</a> </a>
<!-- Desktop Navigation --> <!-- Desktop Navigation -->
@@ -67,12 +67,12 @@ function isActiveLink(link: any) {
<a <a
href={link.href} href={link.href}
class={`text-sm hover:text-foreground transition-colors duration-200 font-medium relative group ${ class={`text-sm hover:text-foreground transition-colors duration-200 font-medium relative group ${
isActiveLink(link) ? 'text-foreground' : 'text-foreground/85' isActiveLink(link) ? "text-foreground" : "text-foreground/85"
}`} }`}
> >
{link.name} {link.name}
<span <span
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink(link) ? 'w-full' : 'group-hover:w-full'}`} class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink(link) ? "w-full" : "group-hover:w-full"}`}
></span> ></span>
</a> </a>
{/each} {/each}
@@ -95,29 +95,29 @@ function isActiveLink(link: any) {
<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={`hidden sm:flex 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")}
> >
<span class="icon-[ri--dashboard-2-line] h-4 w-4"></span> <span class="icon-[ri--dashboard-2-line] h-4 w-4"></span>
<span <span
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: '/me' }) ? 'w-full' : 'group-hover:w-full'}`} class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/me" }) ? "w-full" : "group-hover:w-full"}`}
></span> ></span>
<span class="sr-only">{$_('header.dashboard')}</span> <span class="sr-only">{$_("header.dashboard")}</span>
</Button> </Button>
<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={`hidden sm:flex 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")}
> >
<span class="icon-[ri--rocket-line] h-4 w-4"></span> <span class="icon-[ri--rocket-line] h-4 w-4"></span>
<span <span
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: '/play' }) ? 'w-full' : 'group-hover:w-full'}`} class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/play" }) ? "w-full" : "group-hover:w-full"}`}
></span> ></span>
<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="hidden md:flex mx-1 h-6 bg-border/50" />
@@ -126,9 +126,10 @@ function isActiveLink(link: any) {
<LogoutButton <LogoutButton
user={{ user={{
name: authStatus.user!.artist_name || authStatus.user!.email.split('@')[0] || 'User', name:
avatar: getAssetUrl(authStatus.user!.avatar?.id, 'mini')!, authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
email: authStatus.user!.email avatar: getAssetUrl(authStatus.user!.avatar?.id, "mini")!,
email: authStatus.user!.email,
}} }}
onLogout={handleLogout} onLogout={handleLogout}
/> />
@@ -136,18 +137,16 @@ function isActiveLink(link: any) {
</div> </div>
{:else} {:else}
<div class="flex w-full items-center justify-end gap-4"> <div class="flex w-full items-center justify-end gap-4">
<Button variant="outline" class="font-medium" href="/login" <Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button>
>{$_('header.login')}</Button
>
<Button <Button
href="/signup" href="/signup"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium" class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
>{$_('header.signup')}</Button >{$_("header.signup")}</Button
> >
</div> </div>
{/if} {/if}
<BurgerMenuButton <BurgerMenuButton
label={$_('header.navigation')} label={$_("header.navigation")}
bind:isMobileMenuOpen bind:isMobileMenuOpen
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)} onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
/> />
@@ -155,26 +154,24 @@ function isActiveLink(link: any) {
</div> </div>
<!-- Mobile Navigation --> <!-- Mobile Navigation -->
<div <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'}`} 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} {#if isMobileMenuOpen}
<div class="container mx-auto grid grid-cols-1 lg:grid-cols-3"> <div class="container mx-auto grid grid-cols-1 lg:grid-cols-3">
<div class="hidden lg:flex col-span-2"> <div class="hidden lg:flex col-span-2">
<Girls /> <Girls />
</div> </div>
<div class="py-6 px-4 space-y-6 lg:col-start-3 border-t border-border/20 bg-background/95 "> <div class="py-6 px-4 space-y-6 lg:col-start-3 border-t border-border/20 bg-background/95">
<!-- 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 backdrop-blur-sm"
> >
<div <div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"></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-4">
<Avatar class="h-14 w-14 ring-2 ring-primary/30"> <Avatar class="h-14 w-14 ring-2 ring-primary/30">
<AvatarImage <AvatarImage
src={getAssetUrl(authStatus.user!.avatar?.id, 'mini')} src={getAssetUrl(authStatus.user!.avatar?.id, "mini")}
alt={authStatus.user!.artist_name} alt={authStatus.user!.artist_name}
/> />
<AvatarFallback <AvatarFallback
@@ -212,17 +209,15 @@ function isActiveLink(link: any) {
{/if} {/if}
<!-- Navigation Cards --> <!-- Navigation Cards -->
<div class="space-y-3"> <div class="space-y-3">
<h3 <h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
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-2">
{#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 border-border/50 bg-card/50 p-4 backdrop-blur-sm transition-all hover:bg-card hover:border-primary/20 {isActiveLink(
link link,
) )
? 'border-primary/30 bg-primary/5' ? 'border-primary/30 bg-primary/5'
: ''}" : ''}"
@@ -233,8 +228,7 @@ function isActiveLink(link: any) {
<!-- {#if isActiveLink(link)} <!-- {#if isActiveLink(link)}
<div class="h-2 w-2 rounded-full bg-primary"></div> <div class="h-2 w-2 rounded-full bg-primary"></div>
{/if} --> {/if} -->
<span <span class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground"
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground"
></span> ></span>
</div> </div>
</a> </a>
@@ -244,16 +238,14 @@ function isActiveLink(link: any) {
<!-- Account Actions --> <!-- Account Actions -->
<div class="space-y-3"> <div class="space-y-3">
<h3 <h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider" {$_("header.account")}
>
{$_('header.account')}
</h3> </h3>
<div class="grid gap-2"> <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-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" : ""}`}
href="/me" href="/me"
onclick={closeMenu} onclick={closeMenu}
> >
@@ -266,13 +258,9 @@ function isActiveLink(link: any) {
</div> </div>
<div class="flex flex-1 flex-col gap-1"> <div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium text-foreground" <span class="font-medium text-foreground">{$_("header.dashboard")}</span>
>{$_('header.dashboard')}</span
>
</div> </div>
<span class="text-sm text-muted-foreground" <span class="text-sm text-muted-foreground">{$_("header.dashboard_hint")}</span>
>{$_('header.dashboard_hint')}</span
>
</div> </div>
<span <span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all" class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
@@ -280,7 +268,7 @@ function isActiveLink(link: any) {
</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-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" : ""}`}
href="/play" href="/play"
onclick={closeMenu} onclick={closeMenu}
> >
@@ -293,13 +281,9 @@ function isActiveLink(link: any) {
</div> </div>
<div class="flex flex-1 flex-col gap-1"> <div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium text-foreground" <span class="font-medium text-foreground">{$_("header.play")}</span>
>{$_('header.play')}</span
>
</div> </div>
<span class="text-sm text-muted-foreground" <span class="text-sm text-muted-foreground">{$_("header.play_hint")}</span>
>{$_('header.play_hint')}</span
>
</div> </div>
<span <span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all" class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
@@ -307,7 +291,7 @@ function isActiveLink(link: any) {
</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-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" : ""}`}
href="/login" href="/login"
onclick={closeMenu} onclick={closeMenu}
> >
@@ -320,13 +304,9 @@ function isActiveLink(link: any) {
</div> </div>
<div class="flex flex-1 flex-col gap-1"> <div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium text-foreground" <span class="font-medium text-foreground">{$_("header.login")}</span>
>{$_('header.login')}</span
>
</div> </div>
<span class="text-sm text-muted-foreground" <span class="text-sm text-muted-foreground">{$_("header.login_hint")}</span>
>{$_('header.login_hint')}</span
>
</div> </div>
<span <span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all" class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
@@ -334,7 +314,7 @@ function isActiveLink(link: any) {
</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-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" : ""}`}
href="/signup" href="/signup"
onclick={closeMenu} onclick={closeMenu}
> >
@@ -347,13 +327,9 @@ function isActiveLink(link: any) {
</div> </div>
<div class="flex flex-1 flex-col gap-1"> <div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium text-foreground" <span class="font-medium text-foreground">{$_("header.signup")}</span>
>{$_('header.signup')}</span
>
</div> </div>
<span class="text-sm text-muted-foreground" <span class="text-sm text-muted-foreground">{$_("header.signup_hint")}</span>
>{$_('header.signup_hint')}</span
>
</div> </div>
<span <span
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all" class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
@@ -372,17 +348,11 @@ function isActiveLink(link: any) {
<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-10 w-10 items-center justify-center rounded-xl bg-destructive/10 group-hover:bg-destructive/20 transition-all"
> >
<span <span class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"></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-1">
<span class="font-medium text-foreground" <span class="font-medium text-foreground">{$_("header.logout")}</span>
>{$_('header.logout')}</span <span class="text-sm text-muted-foreground">{$_("header.logout_hint")}</span>
>
<span class="text-sm text-muted-foreground"
>{$_('header.logout_hint')}</span
>
</div> </div>
</button> </button>
{/if} {/if}

View File

@@ -1,25 +1,24 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
class?: string; class?: string;
size?: string | number; size?: string | number;
} }
let { class: className = "", size = "24" }: Props = $props(); let { class: className = "", size = "24" }: Props = $props();
</script> </script>
<svg <svg
width={size} width={size}
height={size} height={size}
viewBox="0 0 512 512" viewBox="0 0 512 512"
class={className} class={className}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<g class="" transform="translate(0,0)" style="" <g class="" transform="translate(0,0)" style=""
><path ><path
d="M418.813 30.625c-21.178 26.27-49.712 50.982-84.125 70.844-36.778 21.225-75.064 33.62-110.313 38.06a310.317 310.317 0 0 0 6.813 18.25c16.01.277 29.366-.434 36.406-1.5l9.47-1.53 8.436-1.28.22 10.186a307.48 307.48 0 0 1-1.095 18.72l56.625 8.843c.86-.095 1.713-.15 2.563-.157 11.188-.114 21.44 7.29 24.468 18.593.657 2.448.922 4.903.845 7.313 5.972-2.075 11.753-4.305 17.28-6.72l9.595-4.188 2.313 10.22a340.211 340.211 0 0 1 7.375 48.062C438.29 247.836 468.438 225.71 493 197.5c-3.22-36.73-16.154-78.04-39.125-117.813a290.509 290.509 0 0 0-2.22-3.78l-27.56 71.374c5.154.762 10.123 3.158 14.092 7.126 9.81 9.807 9.813 25.69 0 35.5-9.812 9.81-25.722 9.807-35.53 0-8.86-8.858-9.69-22.68-2.532-32.5l38.938-100.844a322.02 322.02 0 0 0-20.25-25.937zM51.842 118.72c-8.46 17.373-15.76 36.198-21.187 56.436-14.108 52.617-13.96 103.682-2.812 143.438 13.3-2.605 26.442-3.96 39.312-4.03 1.855-.012 3.688.02 5.53.06 20.857.48 40.98 4.332 59.97 11.5a355.064 355.064 0 0 1-1.656-34.218c0-27.8 3.135-54.377 9-78.937l2.47-10.407 9.655 4.562c29.467 13.98 66.194 23.424 106.28 25.22 5.136-20.05 8.19-39.78 9.408-58.75-35.198 4.83-75.387 2.766-116.407-8.22-38.363-10.272-72.314-26.78-99.562-46.656zm230.594 82.218c-1.535 10.452-3.615 21.03-6.218 31.687a312.754 312.754 0 0 0 46-3.97 24.98 24.98 0 0 1-1.532-21.748l-38.25-5.97zM105 201.375l4.156 18.22-21.594 4.905c8.75 5.174 13.353 15.703 10.594 26-3.32 12.394-16.045 19.758-28.437 16.438-12.394-3.32-19.76-16.075-16.44-28.47a23.235 23.235 0 0 1 3.126-6.874l-21.062 4.78-4.125-18.218 73.78-16.78zm388.594 22.813c-25.53 25.46-55.306 45.445-86.906 60.5.05 2.397.093 4.8.093 7.218 0 9.188-.354 18.232-1.03 27.125 16.635 1.33 32.045-1.7 45.344-9.374 25.925-14.962 40.608-45.694 42.5-85.47zm-338.844 3c-4.03 19.993-6.33 41.31-6.406 63.593l.125-.342c30.568 10.174 62.622 17.572 95.25 21.375l7.5.875.718 7.5 5.687 60.125-18.625 1.75-2.53-26.75a23.117 23.117 0 0 1-14.845.968c-12.393-3.32-19.76-16.042-16.438-28.436.285-1.06.647-2.08 1.063-3.063a496.627 496.627 0 0 1-57.406-14.53c2.69 49.62 16.154 94.04 36.094 126.656 22.366 36.588 52.13 57.78 83.968 57.78 31.838.003 61.602-21.19 83.97-57.78 19.536-31.96 32.846-75.244 35.905-123.656a499.132 499.132 0 0 1-48.25 11.656c1.914 4.57 2.415 9.78 1.033 14.938-3.322 12.394-16.045 19.758-28.438 16.437a23.01 23.01 0 0 1-2.125-.686l-2.5 26.47-18.594-1.752 5.688-60.125.72-7.5 7.498-.875c29.245-3.407 57.995-9.717 85.657-18.312v-1.594c0-21.573-2.27-42.23-6.064-61.75C351.132 242.653 313.092 250 272.312 250c-43.59 0-83.986-8.658-117.562-22.813zm-87.5 105.968c-10.87.102-21.995 1.22-33.375 3.313 12.695 31.62 33.117 53.07 59 60 16.9 4.523 34.896 2.536 52.813-5.25-4.382-13.89-7.874-28.606-10.344-43.97-21.115-9.623-43.934-14.32-68.094-14.094zm137.5 80.22h130.813c-40.082 44.594-92.623 42.844-130.813 0z" d="M418.813 30.625c-21.178 26.27-49.712 50.982-84.125 70.844-36.778 21.225-75.064 33.62-110.313 38.06a310.317 310.317 0 0 0 6.813 18.25c16.01.277 29.366-.434 36.406-1.5l9.47-1.53 8.436-1.28.22 10.186a307.48 307.48 0 0 1-1.095 18.72l56.625 8.843c.86-.095 1.713-.15 2.563-.157 11.188-.114 21.44 7.29 24.468 18.593.657 2.448.922 4.903.845 7.313 5.972-2.075 11.753-4.305 17.28-6.72l9.595-4.188 2.313 10.22a340.211 340.211 0 0 1 7.375 48.062C438.29 247.836 468.438 225.71 493 197.5c-3.22-36.73-16.154-78.04-39.125-117.813a290.509 290.509 0 0 0-2.22-3.78l-27.56 71.374c5.154.762 10.123 3.158 14.092 7.126 9.81 9.807 9.813 25.69 0 35.5-9.812 9.81-25.722 9.807-35.53 0-8.86-8.858-9.69-22.68-2.532-32.5l38.938-100.844a322.02 322.02 0 0 0-20.25-25.937zM51.842 118.72c-8.46 17.373-15.76 36.198-21.187 56.436-14.108 52.617-13.96 103.682-2.812 143.438 13.3-2.605 26.442-3.96 39.312-4.03 1.855-.012 3.688.02 5.53.06 20.857.48 40.98 4.332 59.97 11.5a355.064 355.064 0 0 1-1.656-34.218c0-27.8 3.135-54.377 9-78.937l2.47-10.407 9.655 4.562c29.467 13.98 66.194 23.424 106.28 25.22 5.136-20.05 8.19-39.78 9.408-58.75-35.198 4.83-75.387 2.766-116.407-8.22-38.363-10.272-72.314-26.78-99.562-46.656zm230.594 82.218c-1.535 10.452-3.615 21.03-6.218 31.687a312.754 312.754 0 0 0 46-3.97 24.98 24.98 0 0 1-1.532-21.748l-38.25-5.97zM105 201.375l4.156 18.22-21.594 4.905c8.75 5.174 13.353 15.703 10.594 26-3.32 12.394-16.045 19.758-28.437 16.438-12.394-3.32-19.76-16.075-16.44-28.47a23.235 23.235 0 0 1 3.126-6.874l-21.062 4.78-4.125-18.218 73.78-16.78zm388.594 22.813c-25.53 25.46-55.306 45.445-86.906 60.5.05 2.397.093 4.8.093 7.218 0 9.188-.354 18.232-1.03 27.125 16.635 1.33 32.045-1.7 45.344-9.374 25.925-14.962 40.608-45.694 42.5-85.47zm-338.844 3c-4.03 19.993-6.33 41.31-6.406 63.593l.125-.342c30.568 10.174 62.622 17.572 95.25 21.375l7.5.875.718 7.5 5.687 60.125-18.625 1.75-2.53-26.75a23.117 23.117 0 0 1-14.845.968c-12.393-3.32-19.76-16.042-16.438-28.436.285-1.06.647-2.08 1.063-3.063a496.627 496.627 0 0 1-57.406-14.53c2.69 49.62 16.154 94.04 36.094 126.656 22.366 36.588 52.13 57.78 83.968 57.78 31.838.003 61.602-21.19 83.97-57.78 19.536-31.96 32.846-75.244 35.905-123.656a499.132 499.132 0 0 1-48.25 11.656c1.914 4.57 2.415 9.78 1.033 14.938-3.322 12.394-16.045 19.758-28.438 16.437a23.01 23.01 0 0 1-2.125-.686l-2.5 26.47-18.594-1.752 5.688-60.125.72-7.5 7.498-.875c29.245-3.407 57.995-9.717 85.657-18.312v-1.594c0-21.573-2.27-42.23-6.064-61.75C351.132 242.653 313.092 250 272.312 250c-43.59 0-83.986-8.658-117.562-22.813zm-87.5 105.968c-10.87.102-21.995 1.22-33.375 3.313 12.695 31.62 33.117 53.07 59 60 16.9 4.523 34.896 2.536 52.813-5.25-4.382-13.89-7.874-28.606-10.344-43.97-21.115-9.623-43.934-14.32-68.094-14.094zm137.5 80.22h130.813c-40.082 44.594-92.623 42.844-130.813 0z"
fill-opacity="1" fill-opacity="1"
style="fill: currentColor; stroke: #ce47eb; stroke-width: 10px;" style="fill: currentColor; stroke: #ce47eb; stroke-width: 10px;"
></path></g
></path></g ></svg
></svg
> >

View File

@@ -1,109 +1,107 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import Button from "../ui/button/button.svelte"; import Button from "../ui/button/button.svelte";
const { images = [] } = $props(); const { images = [] } = $props();
let isViewerOpen = $state(false); let isViewerOpen = $state(false);
let currentImageIndex = $state(0); let currentImageIndex = $state(0);
let imageLoading = $state(false); let imageLoading = $state(false);
let currentImage = $derived(images[currentImageIndex]); let currentImage = $derived(images[currentImageIndex]);
let canGoPrev = $derived(currentImageIndex > 0); let canGoPrev = $derived(currentImageIndex > 0);
let canGoNext = $derived(currentImageIndex < images.length - 1); let canGoNext = $derived(currentImageIndex < images.length - 1);
function openViewer(index) { function openViewer(index) {
currentImageIndex = index; currentImageIndex = index;
isViewerOpen = true; isViewerOpen = true;
imageLoading = true; imageLoading = true;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
} }
function closeViewer() { function closeViewer() {
isViewerOpen = false; isViewerOpen = false;
document.body.style.overflow = ""; document.body.style.overflow = "";
} }
function navigatePrev() { function navigatePrev() {
if (canGoPrev) { if (canGoPrev) {
currentImageIndex--; currentImageIndex--;
imageLoading = true; imageLoading = true;
} }
} }
function navigateNext() { function navigateNext() {
if (canGoNext) { if (canGoNext) {
currentImageIndex++; currentImageIndex++;
imageLoading = true; imageLoading = true;
} }
} }
function downloadImage() { function downloadImage() {
const link = document.createElement("a"); const link = document.createElement("a");
link.href = currentImage.url; link.href = currentImage.url;
link.download = currentImage.title.replace(/\\s+/g, "_") + ".jpg"; link.download = currentImage.title.replace(/\\s+/g, "_") + ".jpg";
link.target = "_blank"; link.target = "_blank";
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
} }
function handleKeydown(event) { function handleKeydown(event) {
if (!isViewerOpen) return; if (!isViewerOpen) return;
switch (event.key) { switch (event.key) {
case "ArrowLeft": case "ArrowLeft":
event.preventDefault(); event.preventDefault();
navigatePrev(); navigatePrev();
break; break;
case "ArrowRight": case "ArrowRight":
event.preventDefault(); event.preventDefault();
navigateNext(); navigateNext();
break; break;
case "Escape": case "Escape":
event.preventDefault(); event.preventDefault();
closeViewer(); closeViewer();
break; break;
case "d": case "d":
case "D": case "D":
event.preventDefault(); event.preventDefault();
downloadImage(); downloadImage();
break; break;
} }
} }
function handleImageLoad() { function handleImageLoad() {
imageLoading = false; imageLoading = false;
} }
onMount(() => { onMount(() => {
if (!browser) { if (!browser) {
return; return;
} }
window.addEventListener("keydown", handleKeydown); window.addEventListener("keydown", handleKeydown);
// Preload images // Preload images
images.forEach((img) => { images.forEach((img) => {
const preload = new Image(); const preload = new Image();
preload.src = img.url; preload.src = img.url;
}); });
}); });
onDestroy(() => { onDestroy(() => {
if (!browser) { if (!browser) {
return; return;
} }
window.removeEventListener("keydown", handleKeydown); window.removeEventListener("keydown", handleKeydown);
document.body.style.overflow = ""; document.body.style.overflow = "";
}); });
</script> </script>
<!-- Gallery Grid --> <!-- Gallery Grid -->
<div class="w-full mx-auto"> <div class="w-full mx-auto">
<div <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 animate-fade-in">
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 animate-fade-in"
>
{#each images as image, index (index)} {#each images as image, index (index)}
<button <button
onclick={() => openViewer(index)} onclick={() => openViewer(index)}
@@ -145,14 +143,9 @@ onDestroy(() => {
<!-- Image Viewer Modal --> <!-- Image Viewer Modal -->
{#if isViewerOpen} {#if isViewerOpen}
<div <div class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in"
>
<!-- Backdrop --> <!-- Backdrop -->
<div <div class="absolute inset-0 bg-black/95 backdrop-blur-xl" onclick={closeViewer}></div>
class="absolute inset-0 bg-black/95 backdrop-blur-xl"
onclick={closeViewer}
></div>
<!-- Viewer Content --> <!-- Viewer Content -->
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up"> <div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">
@@ -166,9 +159,9 @@ onDestroy(() => {
<div class="text-primary font-medium mb-3"> <div class="text-primary font-medium mb-3">
{$_("image_viewer.index", { {$_("image_viewer.index", {
values: { values: {
index: currentImageIndex + 1, index: currentImageIndex + 1,
size: images.length size: images.length,
} },
})} })}
</div> </div>
<p class="text-zinc-400 max-w-2xl"> <p class="text-zinc-400 max-w-2xl">

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import PeonyIcon from "../icon/peony-icon.svelte"; import PeonyIcon from "../icon/peony-icon.svelte";
const { hideName = false } = $props(); const { hideName = false } = $props();
</script> </script>
<div class="relative"> <div class="relative">
@@ -11,11 +11,11 @@ const { hideName = false } = $props();
<span <span
class={`logo text-3xl text-foreground opacity-90 tracking-wide font-extrabold drop-shadow-x ${hideName ? "hidden sm:inline-block" : ""}`} class={`logo text-3xl text-foreground opacity-90 tracking-wide font-extrabold drop-shadow-x ${hideName ? "hidden sm:inline-block" : ""}`}
> >
{$_('brand.name')} {$_("brand.name")}
</span> </span>
<style> <style>
.logo { .logo {
font-family: 'Dancing Script', cursive; font-family: "Dancing Script", cursive;
} }
</style> </style>

View File

@@ -1,148 +1,184 @@
<script lang="ts"> <script lang="ts">
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";
interface User { interface User {
name?: string; name?: string;
email: string; email: string;
avatar?: string; avatar?: string;
} }
interface Props { interface Props {
user: User; user: User;
onLogout: () => void; onLogout: () => void;
} }
let { user, onLogout }: Props = $props(); let { user, onLogout }: Props = $props();
let isDragging = $state(false); let isDragging = $state(false);
let slidePosition = $state(0); let slidePosition = $state(0);
let startX = 0; let startX = 0;
let currentX = 0; let currentX = 0;
let maxSlide = 117; // Maximum slide distance let maxSlide = 117; // Maximum slide distance
let threshold = 0.75; // 70% threshold to trigger logout let threshold = 0.75; // 70% threshold to trigger logout
// Calculate slide progress (0 to 1) // Calculate slide progress (0 to 1)
const slideProgress = $derived(Math.min(slidePosition / maxSlide, 1)); const slideProgress = $derived(Math.min(slidePosition / maxSlide, 1));
const isNearThreshold = $derived(slideProgress > threshold); const isNearThreshold = $derived(slideProgress > threshold);
const handleStart = (clientX: number) => { const handleStart = (clientX: number) => {
isDragging = true; isDragging = true;
startX = clientX; startX = clientX;
currentX = clientX; currentX = clientX;
}; };
const handleMove = (clientX: number) => { const handleMove = (clientX: number) => {
if (!isDragging) return; if (!isDragging) return;
currentX = clientX; currentX = clientX;
const deltaX = currentX - startX; const deltaX = currentX - startX;
slidePosition = Math.max(0, Math.min(deltaX, maxSlide)); slidePosition = Math.max(0, Math.min(deltaX, maxSlide));
}; };
const handleEnd = () => { const handleEnd = () => {
if (!isDragging) return; if (!isDragging) return;
isDragging = false; isDragging = false;
if (slideProgress >= threshold) { if (slideProgress >= threshold) {
// Trigger logout // Trigger logout
slidePosition = maxSlide; slidePosition = maxSlide;
onLogout(); onLogout();
} else { } else {
// Snap back // Snap back
slidePosition = 0; slidePosition = 0;
} }
}; };
// Mouse events // Mouse events
const handleMouseDown = (e: MouseEvent) => { const handleMouseDown = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
handleStart(e.clientX); handleStart(e.clientX);
}; };
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
handleMove(e.clientX); handleMove(e.clientX);
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
handleEnd(); handleEnd();
}; };
// Touch events // Touch events
const handleTouchStart = (e: TouchEvent) => { const handleTouchStart = (e: TouchEvent) => {
handleStart(e.touches[0].clientX); handleStart(e.touches[0].clientX);
}; };
const handleTouchMove = (e: TouchEvent) => { const handleTouchMove = (e: TouchEvent) => {
e.preventDefault(); e.preventDefault();
handleMove(e.touches[0].clientX); handleMove(e.touches[0].clientX);
}; };
const handleTouchEnd = () => { const handleTouchEnd = () => {
handleEnd(); handleEnd();
}; };
// Add global event listeners when dragging // Add global event listeners when dragging
$effect(() => { $effect(() => {
if (isDragging) { if (isDragging) {
document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp); document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("touchmove", handleTouchMove, { passive: false }); document.addEventListener("touchmove", handleTouchMove, { passive: false });
document.addEventListener("touchend", handleTouchEnd); document.addEventListener("touchend", handleTouchEnd);
return () => { return () => {
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("touchmove", handleTouchMove); document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd); document.removeEventListener("touchend", handleTouchEnd);
}; };
} }
}); });
</script> </script>
<div <div
class="relative h-10 w-40 rounded-full bg-muted/30 overflow-hidden select-none transition-all duration-300 bg-muted/40 shadow-lg shadow-accent/10 {isDragging ? 'cursor-grabbing' : ''}" class="relative h-10 w-40 rounded-full bg-muted/30 overflow-hidden select-none transition-all duration-300 bg-muted/40 shadow-lg shadow-accent/10 {isDragging
style="background: linear-gradient(90deg, ? 'cursor-grabbing'
: ''}"
style="background: linear-gradient(90deg,
oklch(var(--primary) / 0.3) 0%, oklch(var(--primary) / 0.3) 0%,
oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%, oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%,
oklch(var(--accent) / {0.1 + slideProgress * 0.2}) {(1 - slideProgress) * 100}%, oklch(var(--accent) / {0.1 + slideProgress * 0.2}) {(1 - slideProgress) * 100}%,
oklch(var(--accent) / {0.2 + slideProgress * 0.3}) 100% oklch(var(--accent) / {0.2 + slideProgress * 0.3}) 100%
)" )"
> >
<!-- Background slide indicator --> <!-- Background slide indicator -->
<div <div
class="absolute inset-0 rounded-full transition-all duration-200" class="absolute inset-0 rounded-full transition-all duration-200"
style="background: linear-gradient(90deg, style="background: linear-gradient(90deg,
transparent 0%, transparent 0%,
transparent {Math.max(0, slideProgress * 100 - 20)}%, transparent {Math.max(0, slideProgress * 100 - 20)}%,
oklch(var(--accent) / {slideProgress * 0.1}) {slideProgress * 100}%, oklch(var(--accent) / {slideProgress * 0.1}) {slideProgress * 100}%,
oklch(var(--accent) / {slideProgress * 0.2}) 100% oklch(var(--accent) / {slideProgress * 0.2}) 100%
)" )"
></div> ></div>
<!-- Sliding user info --> <!-- Sliding user info -->
<button class="cursor-grab absolute left-0 top-0 h-full flex items-center gap-3 px-2 transition-all duration-200 ease-out rounded-full bg-background/80 backdrop-blur-sm border border-border/50 bg-background/90 border-primary/20 {isDragging ? '' : 'transition-all duration-300 ease-out'}" style="transform: translateX({slidePosition}px); width: calc(100% - {slidePosition}px);" onmousedown={handleMouseDown} ontouchstart={handleTouchStart}> <button
<Avatar class="h-7 w-7 ring-2 ring-accent/20 transition-all duration-200 {isNearThreshold ? 'ring-destructive/40' : ''}" style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}"> class="cursor-grab absolute left-0 top-0 h-full flex items-center gap-3 px-2 transition-all duration-200 ease-out rounded-full bg-background/80 backdrop-blur-sm border border-border/50 bg-background/90 border-primary/20 {isDragging
<AvatarImage src={user.avatar} alt={user.name || user.email} /> ? ''
<AvatarFallback class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200 {isNearThreshold ? 'from-destructive to-destructive/80' : ''}"> : 'transition-all duration-300 ease-out'}"
{getUserInitials(user.name || user.email)} style="transform: translateX({slidePosition}px); width: calc(100% - {slidePosition}px);"
</AvatarFallback> onmousedown={handleMouseDown}
</Avatar> ontouchstart={handleTouchStart}
<div class="text-left flex flex-col min-w-0 flex-1"> >
<span class="text-sm font-medium text-foreground leading-none truncate transition-all duration-200 {isNearThreshold ? 'text-destructive' : ''}" style="opacity: {Math.max(0.15, 1 - slideProgress * 1.5)}">{user?.name ? user.name.split(" ")[0] : "User"}</span> <Avatar
<span class="text-xs text-muted-foreground leading-none transition-all duration-200 {isNearThreshold ? 'text-destructive/70' : ''}" style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}"> class="h-7 w-7 ring-2 ring-accent/20 transition-all duration-200 {isNearThreshold
{slideProgress > 0.3 ? "Logout" : "Online"} ? 'ring-destructive/40'
</span> : ''}"
</div> style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}"
</button> >
<AvatarImage src={user.avatar} alt={user.name || user.email} />
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200 {isNearThreshold
? 'from-destructive to-destructive/80'
: ''}"
>
{getUserInitials(user.name || user.email)}
</AvatarFallback>
</Avatar>
<div class="text-left flex flex-col min-w-0 flex-1">
<span
class="text-sm font-medium text-foreground leading-none truncate transition-all duration-200 {isNearThreshold
? 'text-destructive'
: ''}"
style="opacity: {Math.max(0.15, 1 - slideProgress * 1.5)}"
>{user?.name ? user.name.split(" ")[0] : "User"}</span
>
<span
class="text-xs text-muted-foreground leading-none transition-all duration-200 {isNearThreshold
? 'text-destructive/70'
: ''}"
style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}"
>
{slideProgress > 0.3 ? "Logout" : "Online"}
</span>
</div>
</button>
<!-- Logout icon area --> <!-- Logout icon area -->
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 {isNearThreshold ? 'bg-destructive text-destructive-foreground scale-110' : 'bg-transparent text-foreground'}"> <div
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 transition-transform duration-200 {isNearThreshold ? 'scale-110' : ''}" ></span> class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 {isNearThreshold
</div> ? 'bg-destructive text-destructive-foreground scale-110'
: 'bg-transparent text-foreground'}"
>
<span
class="icon-[ri--logout-circle-r-line] h-4 w-4 transition-transform duration-200 {isNearThreshold
? 'scale-110'
: ''}"
></span>
</div>
<!-- Progress indicator -->
<!-- Progress indicator --> <!-- <div class="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-200 rounded-full" style="width: {slideProgress * 100}%"></div> -->
<!-- <div class="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-200 rounded-full" style="width: {slideProgress * 100}%"></div> -->
</div> </div>

View File

@@ -1,27 +1,24 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { env } from "$env/dynamic/public"; import { env } from "$env/dynamic/public";
interface Props { interface Props {
title: string; title: string;
description: string; description: string;
image?: string; image?: string;
} }
let { let {
title, title,
description, description,
image = `${env.PUBLIC_URL || "http://localhost:3000"}/img/kamasutra.jpg`, image = `${env.PUBLIC_URL || "http://localhost:3000"}/img/kamasutra.jpg`,
}: Props = $props(); }: Props = $props();
</script> </script>
<svelte:head> <svelte:head>
<title>{$_("head.title", { values: { title } })}</title> <title>{$_("head.title", { values: { title } })}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
<meta <meta property="og:title" content={$_("head.title", { values: { title } })} />
property="og:title" <meta property="og:description" content={description} />
content={$_("head.title", { values: { title } })} <meta property="og:image" content={image} />
/>
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
</svelte:head> </svelte:head>

View File

@@ -1,180 +1,163 @@
<script lang="ts"> <script lang="ts">
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 } from "$lib/types";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
interface Props { interface Props {
recording: Recording; recording: Recording;
onPlay?: (id: string) => void; onPlay?: (id: string) => void;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
} }
let { recording, onPlay, onDelete }: Props = $props(); let { recording, onPlay, onDelete }: Props = $props();
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000); const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60); const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60; const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`; return `${minutes}:${seconds.toString().padStart(2, "0")}`;
} }
function getStatusColor(status: string): string { function getStatusColor(status: string): string {
switch (status) { switch (status) {
case "published": case "published":
return "text-green-400 bg-green-400/20"; return "text-green-400 bg-green-400/20";
case "draft": case "draft":
return "text-yellow-400 bg-yellow-400/20"; return "text-yellow-400 bg-yellow-400/20";
case "archived": case "archived":
return "text-red-400 bg-red-400/20"; return "text-red-400 bg-red-400/20";
default: default:
return "text-gray-400 bg-gray-400/20"; return "text-gray-400 bg-gray-400/20";
} }
} }
</script> </script>
<Card <Card
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm" class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
> >
<CardHeader class="pb-3"> <CardHeader class="pb-3">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<h3 <h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
class="font-semibold text-card-foreground group-hover:text-primary transition-colors" {recording.title}
> </h3>
{recording.title} <span class={cn("text-xs px-2 py-0.5 rounded-full", getStatusColor(recording.status))}>
</h3> {$_(`recording_card.status_${recording.status}`)}
<span </span>
class={cn( </div>
"text-xs px-2 py-0.5 rounded-full", {#if recording.description}
getStatusColor(recording.status), <p class="text-sm text-muted-foreground line-clamp-2">
)} {recording.description}
> </p>
{$_(`recording_card.status_${recording.status}`)} {/if}
</span> </div>
</div> </div>
{#if recording.description} </CardHeader>
<p class="text-sm text-muted-foreground line-clamp-2">
{recording.description}
</p>
{/if}
</div>
</div>
</CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<!-- Stats Grid --> <!-- Stats Grid -->
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<div <div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30" <span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span>
> <span class="text-xs text-muted-foreground">{$_("recording_card.duration")}</span>
<span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span> <span class="font-medium text-sm">{formatDuration(recording.duration)}</span>
<span class="text-xs text-muted-foreground" </div>
>{$_("recording_card.duration")}</span <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="font-medium text-sm">{formatDuration(recording.duration)}</span> <span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span>
</div> <span class="font-medium text-sm">{recording.events.length}</span>
<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--pulse-line] w-4 h-4 text-accent mb-1"></span> <span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
<span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span> <span class="font-medium text-sm">{recording.device_info.length}</span>
<span class="font-medium text-sm">{recording.events.length}</span> </div>
</div> </div>
<div
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
>
<span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
<span class="font-medium text-sm">{recording.device_info.length}</span>
</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.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"
> >
<span class="icon-[ri--rocket-line] w-3 h-3"></span> <span class="icon-[ri--rocket-line] w-3 h-3"></span>
<span>{device.name}</span> <span>{device.name}</span>
<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 > 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 - +{recording.device_info.length - 2} more device{recording.device_info.length - 2 > 1
2 > ? "s"
1 : ""}
? "s" </div>
: ""} {/if}
</div> </div>
{/if}
</div>
<!-- Tags --> <!-- Tags -->
{#if recording.tags && recording.tags.length > 0} {#if recording.tags && recording.tags.length > 0}
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each recording.tags as tag (tag)} {#each recording.tags as tag (tag)}
<span <span
class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20" class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"
> >
{tag} {tag}
</span> </span>
{/each} {/each}
</div> </div>
{/if} {/if}
<!-- Metadata --> <!-- Metadata -->
<div class="flex items-center justify-between text-xs text-muted-foreground pt-2"> <div class="flex items-center justify-between text-xs text-muted-foreground pt-2">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span> <span>
{new Date(recording.date_created).toLocaleDateString()} {new Date(recording.date_created).toLocaleDateString()}
</span> </span>
{#if recording.public} {#if recording.public}
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<span class="icon-[ri--global-line] w-3 h-3"></span> <span class="icon-[ri--global-line] w-3 h-3"></span>
{$_("recording_card.public")} {$_("recording_card.public")}
</span> </span>
{:else} {:else}
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<span class="icon-[ri--lock-line] w-3 h-3"></span> <span class="icon-[ri--lock-line] w-3 h-3"></span>
{$_("recording_card.private")} {$_("recording_card.private")}
</span> </span>
{/if} {/if}
</div> </div>
{#if recording.linked_video} {#if recording.linked_video}
<span class="flex items-center gap-1 text-accent"> <span class="flex items-center gap-1 text-accent">
<span class="icon-[ri--video-line] w-3 h-3"></span> <span class="icon-[ri--video-line] w-3 h-3"></span>
{$_("recording_card.linked_video")} {$_("recording_card.linked_video")}
</span> </span>
{/if} {/if}
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
{#if onPlay} {#if onPlay}
<Button <Button
size="sm" size="sm"
onclick={() => onPlay?.(recording.id)} onclick={() => onPlay?.(recording.id)}
class="flex-1 cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90" class="flex-1 cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
> >
<span class="icon-[ri--play-fill] w-4 h-4 mr-1"></span> <span class="icon-[ri--play-fill] w-4 h-4 mr-1"></span>
{$_("recording_card.play")} {$_("recording_card.play")}
</Button> </Button>
{/if} {/if}
{#if onDelete} {#if onDelete}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onclick={() => onDelete?.(recording.id)} onclick={() => onDelete?.(recording.id)}
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive" class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
> >
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span> <span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
</Button> </Button>
{/if} {/if}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
onclick: () => void; onclick: () => void;
icon: string; icon: string;
label: string; label: string;
} }
let { onclick, icon, label }: Props = $props(); let { onclick, icon, label }: Props = $props();
</script> </script>
<button <button
{onclick} {onclick}
aria-label={label} aria-label={label}
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors cursor-pointer" class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors cursor-pointer"
> >
<span class={icon + " w-4 h-4 text-primary"}></span> <span class={icon + " w-4 h-4 text-primary"}></span>
</button> </button>

View File

@@ -1,110 +1,110 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import ShareButton from "./share-button.svelte"; import ShareButton from "./share-button.svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import type { ShareContent } from "$lib/types"; import type { ShareContent } from "$lib/types";
interface Props { interface Props {
content: ShareContent; content: ShareContent;
} }
let { content }: Props = $props(); let { content }: Props = $props();
// Share handlers // Share handlers
const shareToX = () => { const shareToX = () => {
const text = `${content.title} - ${content.description}`; const text = `${content.title} - ${content.description}`;
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(content.url)}`; const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(content.url)}`;
window.open(url, "_blank", "width=600,height=400"); window.open(url, "_blank", "width=600,height=400");
toast.success($_("sharing_popup.success.x")); toast.success($_("sharing_popup.success.x"));
}; };
const shareToFacebook = () => { const shareToFacebook = () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(content.url)}&quote=${encodeURIComponent(content.title)}`; const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(content.url)}&quote=${encodeURIComponent(content.title)}`;
window.open(url, "_blank", "width=600,height=400"); window.open(url, "_blank", "width=600,height=400");
toast.success($_("sharing_popup.success.facebook")); toast.success($_("sharing_popup.success.facebook"));
}; };
const shareViaEmail = () => { const shareViaEmail = () => {
const subject = encodeURIComponent(content.title); const subject = encodeURIComponent(content.title);
const body = encodeURIComponent(`${content.description}\n\n${content.url}`); const body = encodeURIComponent(`${content.description}\n\n${content.url}`);
const url = `mailto:?subject=${subject}&body=${body}`; const url = `mailto:?subject=${subject}&body=${body}`;
window.location.href = url; window.location.href = url;
toast.success($_("sharing_popup.success.email")); toast.success($_("sharing_popup.success.email"));
}; };
const shareToWhatsApp = () => { const shareToWhatsApp = () => {
const text = `${content.title}\n\n${content.description}\n\n${content.url}`; const text = `${content.title}\n\n${content.description}\n\n${content.url}`;
const url = `https://wa.me/?text=${encodeURIComponent(text)}`; const url = `https://wa.me/?text=${encodeURIComponent(text)}`;
window.open(url, "_blank"); window.open(url, "_blank");
toast.success($_("sharing_popup.success.whatsapp")); toast.success($_("sharing_popup.success.whatsapp"));
}; };
const shareToTelegram = () => { const shareToTelegram = () => {
const text = `${content.title}\n\n${content.description}`; const text = `${content.title}\n\n${content.description}`;
const url = `https://t.me/share/url?url=${encodeURIComponent(content.url)}&text=${encodeURIComponent(text)}`; const url = `https://t.me/share/url?url=${encodeURIComponent(content.url)}&text=${encodeURIComponent(text)}`;
window.open(url, "_blank"); window.open(url, "_blank");
toast.success($_("sharing_popup.success.telegram")); toast.success($_("sharing_popup.success.telegram"));
}; };
const copyLink = async () => { const copyLink = async () => {
try { try {
await navigator.clipboard.writeText(content.url); await navigator.clipboard.writeText(content.url);
toast.success($_("sharing_popup.success.copy")); toast.success($_("sharing_popup.success.copy"));
} catch { } catch {
// Fallback for older browsers // Fallback for older browsers
const textArea = document.createElement("textarea"); const textArea = document.createElement("textarea");
textArea.value = content.url; textArea.value = content.url;
document.body.appendChild(textArea); document.body.appendChild(textArea);
textArea.select(); textArea.select();
document.execCommand("copy"); document.execCommand("copy");
document.body.removeChild(textArea); document.body.removeChild(textArea);
toast.success($_("sharing_popup.success.copy")); toast.success($_("sharing_popup.success.copy"));
} }
}; };
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
<div class="text-center space-y-4"> <div class="text-center space-y-4">
<h4 class="text-sm font-medium text-muted-foreground"> <h4 class="text-sm font-medium text-muted-foreground">
{$_("sharing_popup.subtitle")} {$_("sharing_popup.subtitle")}
</h4> </h4>
<div class="flex justify-center gap-3 flex-wrap"> <div class="flex justify-center gap-3 flex-wrap">
<ShareButton <ShareButton
onclick={shareToX} onclick={shareToX}
icon="icon-[ri--twitter-x-line]" icon="icon-[ri--twitter-x-line]"
label={$_("sharing_popup.share.x")} label={$_("sharing_popup.share.x")}
/> />
<ShareButton <ShareButton
onclick={shareToFacebook} onclick={shareToFacebook}
icon="icon-[ri--facebook-line]" icon="icon-[ri--facebook-line]"
label={$_("sharing_popup.share.facebook")} label={$_("sharing_popup.share.facebook")}
/> />
<ShareButton <ShareButton
onclick={shareViaEmail} onclick={shareViaEmail}
icon="icon-[ri--mail-line]" icon="icon-[ri--mail-line]"
label={$_("sharing_popup.share.email")} label={$_("sharing_popup.share.email")}
/> />
<ShareButton <ShareButton
onclick={shareToWhatsApp} onclick={shareToWhatsApp}
icon="icon-[ri--whatsapp-line]" icon="icon-[ri--whatsapp-line]"
label={$_("sharing_popup.share.whatsapp")} label={$_("sharing_popup.share.whatsapp")}
/> />
<ShareButton <ShareButton
onclick={shareToTelegram} onclick={shareToTelegram}
icon="icon-[ri--telegram-2-line]" icon="icon-[ri--telegram-2-line]"
label={$_("sharing_popup.share.telegram")} label={$_("sharing_popup.share.telegram")}
/> />
<ShareButton <ShareButton
onclick={copyLink} onclick={copyLink}
icon="icon-[ri--file-copy-line]" icon="icon-[ri--file-copy-line]"
label={$_("sharing_popup.share.copy")} label={$_("sharing_popup.share.copy")}
/> />
</div>
</div> </div>
</div>
</div> </div>

View File

@@ -1,10 +1,10 @@
<script> <script>
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import SharingPopup from "./sharing-popup.svelte"; import SharingPopup from "./sharing-popup.svelte";
import Button from "../ui/button/button.svelte"; import Button from "../ui/button/button.svelte";
const { content } = $props(); const { content } = $props();
let isPopupOpen = $state(false); let isPopupOpen = $state(false);
</script> </script>
<Button <Button
@@ -13,6 +13,6 @@ let isPopupOpen = $state(false);
class="flex items-center gap-2 border-primary/20 hover:bg-primary/10 cursor-pointer" class="flex items-center gap-2 border-primary/20 hover:bg-primary/10 cursor-pointer"
> >
<span class="icon-[ri--share-2-line] w-4 h-4"></span> <span class="icon-[ri--share-2-line] w-4 h-4"></span>
{$_('sharing_popup_button.share')} {$_("sharing_popup_button.share")}
</Button> </Button>
<SharingPopup bind:open={isPopupOpen} {content} /> <SharingPopup bind:open={isPopupOpen} {content} />

View File

@@ -1,89 +1,89 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "$lib/components/ui/dialog"; } from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import ShareServices from "./share-services.svelte"; import ShareServices from "./share-services.svelte";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
interface ShareContent { interface ShareContent {
title: string; title: string;
description: string; description: string;
url: string; url: string;
type: "video" | "model" | "article" | "link"; type: "video" | "model" | "article" | "link";
} }
interface Props { interface Props {
open: boolean; open: boolean;
content: ShareContent; content: ShareContent;
children?: Snippet; children?: Snippet;
} }
let { open = $bindable(), content }: Props = $props(); let { open = $bindable(), content }: Props = $props();
</script> </script>
<Dialog bind:open> <Dialog bind:open>
<DialogContent class="sm:max-w-md"> <DialogContent class="sm:max-w-md">
<DialogHeader class="space-y-4"> <DialogHeader class="space-y-4">
<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">
<div <div
class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center shrink-0 grow-0" class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center shrink-0 grow-0"
> >
<span class="icon-[ri--share-2-line] text-primary-foreground"></span> <span class="icon-[ri--share-2-line] text-primary-foreground"></span>
</div> </div>
<div class=""> <div class="">
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground" <DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
>{$_("sharing_popup.title")}</DialogTitle >{$_("sharing_popup.title")}</DialogTitle
>
<DialogDescription class="text-left text-sm">
{$_("sharing_popup.description", {
values: { type: content.type },
})}
</DialogDescription>
</div>
</div>
</div>
<!-- Content Preview -->
<div class="text-left bg-muted/60 rounded-lg p-4 space-y-2">
<h4 class="font-medium text-sm text-primary-foreground">
{content.title}
</h4>
<p class="text-xs text-muted-foreground">{content.description}</p>
<div class="flex items-center gap-2 text-xs">
<span class="px-2 py-1 bg-primary/10 text-primary rounded-full capitalize">
{content.type}
</span>
<span class="text-muted-foreground text-clip">{content.url}</span>
</div>
</div>
</DialogHeader>
<Separator class="my-4" />
<!-- Share Services -->
<ShareServices {content} />
<Separator class="my-4" />
<!-- Close Button -->
<div class="flex justify-end">
<Button
variant="ghost"
size="sm"
onclick={() => (open = false)}
class="text-muted-foreground hover:text-foreground cursor-pointer"
> >
<span class="icon-[ri--close-large-line]"></span> <DialogDescription class="text-left text-sm">
{$_("sharing_popup.close")} {$_("sharing_popup.description", {
</Button> values: { type: content.type },
})}
</DialogDescription>
</div>
</div> </div>
</DialogContent> </div>
<!-- Content Preview -->
<div class="text-left bg-muted/60 rounded-lg p-4 space-y-2">
<h4 class="font-medium text-sm text-primary-foreground">
{content.title}
</h4>
<p class="text-xs text-muted-foreground">{content.description}</p>
<div class="flex items-center gap-2 text-xs">
<span class="px-2 py-1 bg-primary/10 text-primary rounded-full capitalize">
{content.type}
</span>
<span class="text-muted-foreground text-clip">{content.url}</span>
</div>
</div>
</DialogHeader>
<Separator class="my-4" />
<!-- Share Services -->
<ShareServices {content} />
<Separator class="my-4" />
<!-- Close Button -->
<div class="flex justify-end">
<Button
variant="ghost"
size="sm"
onclick={() => (open = false)}
class="text-muted-foreground hover:text-foreground cursor-pointer"
>
<span class="icon-[ri--close-large-line]"></span>
{$_("sharing_popup.close")}
</Button>
</div>
</DialogContent>
</Dialog> </Dialog>

View File

@@ -1,23 +1,23 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="alert-description" data-slot="alert-description"
class={cn( class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className, className,
)} )}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="alert-title" data-slot="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)} class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -1,44 +1,44 @@
<script lang="ts" module> <script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants"; import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({ export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: { variants: {
variant: { variant: {
default: "bg-card text-card-foreground", default: "bg-card text-card-foreground",
destructive: destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current", "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
}); });
export type AlertVariant = VariantProps<typeof alertVariants>["variant"]; export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script> </script>
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
variant = "default", variant = "default",
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant; variant?: AlertVariant;
} = $props(); } = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="alert" data-slot="alert"
class={cn(alertVariants({ variant }), className)} class={cn(alertVariants({ variant }), className)}
{...restProps} {...restProps}
role="alert" role="alert"
> >
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -4,11 +4,11 @@ import Title from "./alert-title.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte"; export { alertVariants, type AlertVariant } from "./alert.svelte";
export { export {
Root, Root,
Description, Description,
Title, Title,
// //
Root as Alert, Root as Alert,
Description as AlertDescription, Description as AlertDescription,
Title as AlertTitle, Title as AlertTitle,
}; };

View File

@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: AvatarPrimitive.FallbackProps = $props(); }: AvatarPrimitive.FallbackProps = $props();
</script> </script>
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
bind:ref bind:ref
data-slot="avatar-fallback" data-slot="avatar-fallback"
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)} class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
{...restProps} {...restProps}
/> />

View File

@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: AvatarPrimitive.ImageProps = $props(); }: AvatarPrimitive.ImageProps = $props();
</script> </script>
<AvatarPrimitive.Image <AvatarPrimitive.Image
bind:ref bind:ref
data-slot="avatar-image" data-slot="avatar-image"
class={cn("aspect-square size-full", className)} class={cn("aspect-square size-full", className)}
{...restProps} {...restProps}
/> />

View File

@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
loadingStatus = $bindable("loading"), loadingStatus = $bindable("loading"),
class: className, class: className,
...restProps ...restProps
}: AvatarPrimitive.RootProps = $props(); }: AvatarPrimitive.RootProps = $props();
</script> </script>
<AvatarPrimitive.Root <AvatarPrimitive.Root
bind:ref bind:ref
bind:loadingStatus bind:loadingStatus
data-slot="avatar" data-slot="avatar"
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)} class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...restProps} {...restProps}
/> />

View File

@@ -3,11 +3,11 @@ import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte"; import Fallback from "./avatar-fallback.svelte";
export { export {
Root, Root,
Image, Image,
Fallback, Fallback,
// //
Root as Avatar, Root as Avatar,
Image as AvatarImage, Image as AvatarImage,
Fallback as AvatarFallback, Fallback as AvatarFallback,
}; };

View File

@@ -1,86 +1,80 @@
<script lang="ts" module> <script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
import type { import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
HTMLAnchorAttributes, import { type VariantProps, tv } from "tailwind-variants";
HTMLButtonAttributes,
} from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({ export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: { variants: {
variant: { variant: {
default: default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive:
destructive: "bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white", outline:
outline: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
secondary: ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", link: "text-primary underline-offset-4 hover:underline",
ghost: },
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", size: {
link: "text-primary underline-offset-4 hover:underline", default: "h-9 px-4 py-2 has-[>svg]:px-3",
}, sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
size: { lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
default: "h-9 px-4 py-2 has-[>svg]:px-3", icon: "size-9",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", },
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", },
icon: "size-9", defaultVariants: {
}, variant: "default",
}, size: "default",
defaultVariants: { },
variant: "default", });
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"]; export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"]; export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> & export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & { WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ButtonSize; size?: ButtonSize;
}; };
</script> </script>
<script lang="ts"> <script lang="ts">
let { let {
class: className, class: className,
variant = "default", variant = "default",
size = "default", size = "default",
ref = $bindable(null), ref = $bindable(null),
href = undefined, href = undefined,
type = "button", type = "button",
disabled, disabled,
children, children,
...restProps ...restProps
}: ButtonProps = $props(); }: ButtonProps = $props();
</script> </script>
{#if href} {#if href}
<a <a
bind:this={ref} bind:this={ref}
data-slot="button" data-slot="button"
class={cn(buttonVariants({ variant, size }), className)} class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href} href={disabled ? undefined : href}
aria-disabled={disabled} aria-disabled={disabled}
role={disabled ? "link" : undefined} role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined} tabindex={disabled ? -1 : undefined}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</a> </a>
{:else} {:else}
<button <button
bind:this={ref} bind:this={ref}
data-slot="button" data-slot="button"
class={cn(buttonVariants({ variant, size }), className)} class={cn(buttonVariants({ variant, size }), className)}
{type} {type}
{disabled} {disabled}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</button> </button>
{/if} {/if}

View File

@@ -1,17 +1,17 @@
import Root, { import Root, {
type ButtonProps, type ButtonProps,
type ButtonSize, type ButtonSize,
type ButtonVariant, type ButtonVariant,
buttonVariants, buttonVariants,
} from "./button.svelte"; } from "./button.svelte";
export { export {
Root, Root,
type ButtonProps as Props, type ButtonProps as Props,
// //
Root as Button, Root as Button,
buttonVariants, buttonVariants,
type ButtonProps, type ButtonProps,
type ButtonSize, type ButtonSize,
type ButtonVariant, type ButtonVariant,
}; };

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-action" data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}> <div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script> </script>
<p <p
bind:this={ref} bind:this={ref}
data-slot="card-description" data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)} class={cn("text-muted-foreground text-sm", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</p> </p>

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-footer" data-slot="card-footer"
class={cn("[.border-t]:pt-6 flex items-center px-6", className)} class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -1,23 +1,23 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-header" data-slot="card-header"
class={cn( class={cn(
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6", "@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
className, className,
)} )}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-title" data-slot="card-title"
class={cn("font-semibold leading-none", className)} class={cn("font-semibold leading-none", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -1,23 +1,23 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card" data-slot="card"
class={cn( class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className, className,
)} )}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -7,19 +7,19 @@ import Title from "./card-title.svelte";
import Action from "./card-action.svelte"; import Action from "./card-action.svelte";
export { export {
Root, Root,
Content, Content,
Description, Description,
Footer, Footer,
Header, Header,
Title, Title,
Action, Action,
// //
Root as Card, Root as Card,
Content as CardContent, Content as CardContent,
Description as CardDescription, Description as CardDescription,
Footer as CardFooter, Footer as CardFooter,
Header as CardHeader, Header as CardHeader,
Title as CardTitle, Title as CardTitle,
Action as CardAction, Action as CardAction,
}; };

View File

@@ -1,36 +1,36 @@
<script lang="ts"> <script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui"; import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check"; import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus"; import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
checked = $bindable(false), checked = $bindable(false),
indeterminate = $bindable(false), indeterminate = $bindable(false),
class: className, class: className,
...restProps ...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props(); }: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script> </script>
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
bind:ref bind:ref
data-slot="checkbox" data-slot="checkbox"
class={cn( class={cn(
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
bind:checked bind:checked
bind:indeterminate bind:indeterminate
{...restProps} {...restProps}
> >
{#snippet children({ checked, indeterminate })} {#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none"> <div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked} {#if checked}
<CheckIcon class="size-3.5" /> <CheckIcon class="size-3.5" />
{:else if indeterminate} {:else if indeterminate}
<MinusIcon class="size-3.5" /> <MinusIcon class="size-3.5" />
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>

View File

@@ -1,6 +1,6 @@
import Root from "./checkbox.svelte"; import Root from "./checkbox.svelte";
export { export {
Root, Root,
// //
Root as Checkbox, Root as Checkbox,
}; };

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
$props();
</script> </script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} /> <DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View File

@@ -1,43 +1,43 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x"; import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import * as Dialog from "./index.js"; import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
portalProps, portalProps,
children, children,
showCloseButton = true, showCloseButton = true,
...restProps ...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & { }: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps; portalProps?: DialogPrimitive.PortalProps;
children: Snippet; children: Snippet;
showCloseButton?: boolean; showCloseButton?: boolean;
} = $props(); } = $props();
</script> </script>
<Dialog.Portal {...portalProps}> <Dialog.Portal {...portalProps}>
<Dialog.Overlay /> <Dialog.Overlay />
<DialogPrimitive.Content <DialogPrimitive.Content
bind:ref bind:ref
data-slot="dialog-content" data-slot="dialog-content"
class={cn( class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className, className,
)} )}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
{#if showCloseButton} {#if showCloseButton}
<DialogPrimitive.Close <DialogPrimitive.Close
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
> >
<XIcon /> <XIcon />
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
{/if} {/if}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</Dialog.Portal> </Dialog.Portal>

View File

@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: DialogPrimitive.DescriptionProps = $props(); }: DialogPrimitive.DescriptionProps = $props();
</script> </script>
<DialogPrimitive.Description <DialogPrimitive.Description
bind:ref bind:ref
data-slot="dialog-description" data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)} class={cn("text-muted-foreground text-sm", className)}
{...restProps} {...restProps}
/> />

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="dialog-footer" data-slot="dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="dialog-header" data-slot="dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-left", className)} class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: DialogPrimitive.OverlayProps = $props(); }: DialogPrimitive.OverlayProps = $props();
</script> </script>
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
bind:ref bind:ref
data-slot="dialog-overlay" data-slot="dialog-overlay"
class={cn( class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className, className,
)} )}
{...restProps} {...restProps}
/> />

View File

@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: DialogPrimitive.TitleProps = $props(); }: DialogPrimitive.TitleProps = $props();
</script> </script>
<DialogPrimitive.Title <DialogPrimitive.Title
bind:ref bind:ref
data-slot="dialog-title" data-slot="dialog-title"
class={cn("text-lg font-semibold leading-none", className)} class={cn("text-lg font-semibold leading-none", className)}
{...restProps} {...restProps}
/> />

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
$props();
</script> </script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} /> <DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />

View File

@@ -13,25 +13,25 @@ const Root = DialogPrimitive.Root;
const Portal = DialogPrimitive.Portal; const Portal = DialogPrimitive.Portal;
export { export {
Root, Root,
Title, Title,
Portal, Portal,
Footer, Footer,
Header, Header,
Trigger, Trigger,
Overlay, Overlay,
Content, Content,
Description, Description,
Close, Close,
// //
Root as Dialog, Root as Dialog,
Title as DialogTitle, Title as DialogTitle,
Portal as DialogPortal, Portal as DialogPortal,
Footer as DialogFooter, Footer as DialogFooter,
Header as DialogHeader, Header as DialogHeader,
Trigger as DialogTrigger, Trigger as DialogTrigger,
Overlay as DialogOverlay, Overlay as DialogOverlay,
Content as DialogContent, Content as DialogContent,
Description as DialogDescription, Description as DialogDescription,
Close as DialogClose, Close as DialogClose,
}; };

View File

@@ -3,183 +3,174 @@
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils/utils"; import { cn } from "$lib/utils/utils";
import UploadIcon from "@lucide/svelte/icons/upload"; import UploadIcon from "@lucide/svelte/icons/upload";
import { displaySize } from "."; import { displaySize } from ".";
import { useId } from "bits-ui"; import { useId } from "bits-ui";
import type { FileDropZoneProps, FileRejectedReason } from "./types"; import type { FileDropZoneProps, FileRejectedReason } from "./types";
let { let {
id = useId(), id = useId(),
children, children,
maxFiles, maxFiles,
maxFileSize, maxFileSize,
fileCount, fileCount,
disabled = false, disabled = false,
onUpload, onUpload,
onFileRejected, onFileRejected,
accept, accept,
class: className, class: className,
...rest ...rest
}: FileDropZoneProps = $props(); }: FileDropZoneProps = $props();
if (maxFiles !== undefined && fileCount === undefined) { if (maxFiles !== undefined && fileCount === undefined) {
console.warn( console.warn(
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt", "Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
); );
} }
let uploading = $state(false); let uploading = $state(false);
const drop = async ( const drop = async (
e: DragEvent & { e: DragEvent & {
currentTarget: EventTarget & HTMLLabelElement; currentTarget: EventTarget & HTMLLabelElement;
}, },
) => { ) => {
if (disabled || !canUploadFiles) return; if (disabled || !canUploadFiles) return;
e.preventDefault(); e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer?.files ?? []); const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
await upload(droppedFiles); await upload(droppedFiles);
}; };
const change = async ( const change = async (
e: Event & { e: Event & {
currentTarget: EventTarget & HTMLInputElement; currentTarget: EventTarget & HTMLInputElement;
}, },
) => { ) => {
if (disabled) return; if (disabled) return;
const selectedFiles = e.currentTarget.files; const selectedFiles = e.currentTarget.files;
if (!selectedFiles) return; if (!selectedFiles) return;
await upload(Array.from(selectedFiles)); await upload(Array.from(selectedFiles));
// this if a file fails and we upload the same file again we still get feedback // this if a file fails and we upload the same file again we still get feedback
(e.target as HTMLInputElement).value = ""; (e.target as HTMLInputElement).value = "";
}; };
const shouldAcceptFile = ( const shouldAcceptFile = (file: File, fileNumber: number): FileRejectedReason | undefined => {
file: File, if (maxFileSize !== undefined && file.size > maxFileSize) return "Maximum file size exceeded";
fileNumber: number,
): FileRejectedReason | undefined => {
if (maxFileSize !== undefined && file.size > maxFileSize)
return "Maximum file size exceeded";
if (maxFiles !== undefined && fileNumber > maxFiles) if (maxFiles !== undefined && fileNumber > maxFiles) return "Maximum files uploaded";
return "Maximum files uploaded";
if (!accept) return undefined; if (!accept) return undefined;
const acceptedTypes = accept.split(",").map((a) => a.trim().toLowerCase()); const acceptedTypes = accept.split(",").map((a) => a.trim().toLowerCase());
const fileType = file.type.toLowerCase(); const fileType = file.type.toLowerCase();
const fileName = file.name.toLowerCase(); const fileName = file.name.toLowerCase();
const isAcceptable = acceptedTypes.some((pattern) => { const isAcceptable = acceptedTypes.some((pattern) => {
// check extension like .mp4 // check extension like .mp4
if (fileType.startsWith(".")) { if (fileType.startsWith(".")) {
return fileName.endsWith(pattern); return fileName.endsWith(pattern);
} }
// if pattern has wild card like video/* // if pattern has wild card like video/*
if (pattern.endsWith("/*")) { if (pattern.endsWith("/*")) {
const baseType = pattern.slice(0, pattern.indexOf("/*")); const baseType = pattern.slice(0, pattern.indexOf("/*"));
return fileType.startsWith(baseType + "/"); return fileType.startsWith(baseType + "/");
} }
// otherwise it must be a specific type like video/mp4 // otherwise it must be a specific type like video/mp4
return fileType === pattern; return fileType === pattern;
}); });
if (!isAcceptable) return "File type not allowed"; if (!isAcceptable) return "File type not allowed";
return undefined; return undefined;
}; };
const upload = async (uploadFiles: File[]) => { const upload = async (uploadFiles: File[]) => {
uploading = true; uploading = true;
const validFiles: File[] = []; const validFiles: File[] = [];
for (let i = 0; i < uploadFiles.length; i++) { for (let i = 0; i < uploadFiles.length; i++) {
const file = uploadFiles[i]; const file = uploadFiles[i];
const rejectedReason = shouldAcceptFile(file, (fileCount ?? 0) + i + 1); const rejectedReason = shouldAcceptFile(file, (fileCount ?? 0) + i + 1);
if (rejectedReason) { if (rejectedReason) {
onFileRejected?.({ file, reason: rejectedReason }); onFileRejected?.({ file, reason: rejectedReason });
continue; continue;
} }
validFiles.push(file); validFiles.push(file);
} }
await onUpload(validFiles); await onUpload(validFiles);
uploading = false; uploading = false;
}; };
const canUploadFiles = $derived( const canUploadFiles = $derived(
!disabled && !disabled &&
!uploading && !uploading &&
!( !(maxFiles !== undefined && fileCount !== undefined && fileCount >= maxFiles),
maxFiles !== undefined && );
fileCount !== undefined &&
fileCount >= maxFiles
),
);
</script> </script>
<label <label
ondragover={(e) => e.preventDefault()} ondragover={(e) => e.preventDefault()}
ondrop={drop} ondrop={drop}
for={id} for={id}
aria-disabled={!canUploadFiles} aria-disabled={!canUploadFiles}
class={cn( class={cn(
"border-border hover:bg-accent/25 flex h-48 w-full place-items-center justify-center rounded-lg border-2 border-dashed p-6 transition-all hover:cursor-pointer aria-disabled:opacity-50 aria-disabled:hover:cursor-not-allowed", "border-border hover:bg-accent/25 flex h-48 w-full place-items-center justify-center rounded-lg border-2 border-dashed p-6 transition-all hover:cursor-pointer aria-disabled:opacity-50 aria-disabled:hover:cursor-not-allowed",
className, className,
)} )}
> >
{#if children} {#if children}
{@render children()} {@render children()}
{:else} {:else}
<div class="flex flex-col place-items-center justify-center gap-2"> <div class="flex flex-col place-items-center justify-center gap-2">
<div <div
class="border-border text-muted-foreground flex size-14 place-items-center justify-center rounded-full border border-dashed" class="border-border text-muted-foreground flex size-14 place-items-center justify-center rounded-full border border-dashed"
> >
<UploadIcon class="size-7" /> <UploadIcon class="size-7" />
</div> </div>
<div class="flex flex-col gap-0.5 text-center"> <div class="flex flex-col gap-0.5 text-center">
<span class="text-muted-foreground font-medium"> <span class="text-muted-foreground font-medium">
Drag 'n' drop files here, or click to select files Drag 'n' drop files here, or click to select files
</span> </span>
{#if maxFiles || maxFileSize} {#if maxFiles || maxFileSize}
<span class="text-muted-foreground/75 text-sm"> <span class="text-muted-foreground/75 text-sm">
{#if maxFiles} {#if maxFiles}
<span>You can upload {maxFiles} files</span> <span>You can upload {maxFiles} files</span>
{/if} {/if}
{#if maxFiles && maxFileSize} {#if maxFiles && maxFileSize}
<span>(up to {displaySize(maxFileSize)} each)</span> <span>(up to {displaySize(maxFileSize)} each)</span>
{/if} {/if}
{#if maxFileSize && !maxFiles} {#if maxFileSize && !maxFiles}
<span>Maximum size {displaySize(maxFileSize)}</span> <span>Maximum size {displaySize(maxFileSize)}</span>
{/if} {/if}
</span> </span>
{/if} {/if}
</div> </div>
</div> </div>
{/if} {/if}
<input <input
{...rest} {...rest}
disabled={!canUploadFiles} disabled={!canUploadFiles}
{id} {id}
{accept} {accept}
multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1} multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1}
type="file" type="file"
onchange={change} onchange={change}
class="hidden" class="hidden"
/> />
</label> </label>

View File

@@ -6,13 +6,13 @@ import FileDropZone from "./file-drop-zone.svelte";
import { type FileRejectedReason, type FileDropZoneProps } from "./types"; import { type FileRejectedReason, type FileDropZoneProps } from "./types";
export const displaySize = (bytes: number): string => { export const displaySize = (bytes: number): string => {
if (bytes < KILOBYTE) return `${bytes.toFixed(0)} B`; if (bytes < KILOBYTE) return `${bytes.toFixed(0)} B`;
if (bytes < MEGABYTE) return `${(bytes / KILOBYTE).toFixed(0)} KB`; if (bytes < MEGABYTE) return `${(bytes / KILOBYTE).toFixed(0)} KB`;
if (bytes < GIGABYTE) return `${(bytes / MEGABYTE).toFixed(0)} MB`; if (bytes < GIGABYTE) return `${(bytes / MEGABYTE).toFixed(0)} MB`;
return `${(bytes / GIGABYTE).toFixed(0)} GB`; return `${(bytes / GIGABYTE).toFixed(0)} GB`;
}; };
// Utilities for working with file sizes // Utilities for working with file sizes

View File

@@ -6,46 +6,46 @@ import type { WithChildren } from "bits-ui";
import type { HTMLInputAttributes } from "svelte/elements"; import type { HTMLInputAttributes } from "svelte/elements";
export type FileRejectedReason = export type FileRejectedReason =
| "Maximum file size exceeded" | "Maximum file size exceeded"
| "File type not allowed" | "File type not allowed"
| "Maximum files uploaded"; | "Maximum files uploaded";
export type FileDropZonePropsWithoutHTML = WithChildren<{ export type FileDropZonePropsWithoutHTML = WithChildren<{
ref?: HTMLInputElement | null; ref?: HTMLInputElement | null;
/** Called with the uploaded files when the user drops or clicks and selects their files. /** Called with the uploaded files when the user drops or clicks and selects their files.
* *
* @param files * @param files
*/ */
onUpload: (files: File[]) => Promise<void>; onUpload: (files: File[]) => Promise<void>;
/** The maximum amount files allowed to be uploaded */ /** The maximum amount files allowed to be uploaded */
maxFiles?: number; maxFiles?: number;
fileCount?: number; fileCount?: number;
/** The maximum size of a file in bytes */ /** The maximum size of a file in bytes */
maxFileSize?: number; maxFileSize?: number;
/** Called when a file does not meet the upload criteria (size, or type) */ /** Called when a file does not meet the upload criteria (size, or type) */
onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void; onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void;
// just for extra documentation // just for extra documentation
/** Takes a comma separated list of one or more file types. /** Takes a comma separated list of one or more file types.
* *
* [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept) * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept)
* *
* ### Usage * ### Usage
* ```svelte * ```svelte
* <FileDropZone * <FileDropZone
* accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document" * accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
* /> * />
* ``` * ```
* *
* ### Common Values * ### Common Values
* ```svelte * ```svelte
* <FileDropZone accept="audio/*"/> * <FileDropZone accept="audio/*"/>
* <FileDropZone accept="image/*"/> * <FileDropZone accept="image/*"/>
* <FileDropZone accept="video/*"/> * <FileDropZone accept="video/*"/>
* ``` * ```
*/ */
accept?: string; accept?: string;
}>; }>;
export type FileDropZoneProps = FileDropZonePropsWithoutHTML & export type FileDropZoneProps = FileDropZonePropsWithoutHTML &
Omit<HTMLInputAttributes, "multiple" | "files">; Omit<HTMLInputAttributes, "multiple" | "files">;

View File

@@ -1,7 +1,7 @@
import Root from "./input.svelte"; import Root from "./input.svelte";
export { export {
Root, Root,
// //
Root as Input, Root as Input,
}; };

View File

@@ -1,57 +1,51 @@
<script lang="ts"> <script lang="ts">
import type { import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
HTMLInputAttributes, import { cn, type WithElementRef } from "$lib/utils.js";
HTMLInputTypeAttribute,
} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">; type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef< type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> & Omit<HTMLInputAttributes, "type"> &
( ({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
| { type: "file"; files?: FileList } >;
| { type?: InputType; files?: undefined }
)
>;
let { let {
ref = $bindable(null), ref = $bindable(null),
value = $bindable(), value = $bindable(),
type, type,
files = $bindable(), files = $bindable(),
class: className, class: className,
...restProps ...restProps
}: Props = $props(); }: Props = $props();
</script> </script>
{#if type === "file"} {#if type === "file"}
<input <input
bind:this={ref} bind:this={ref}
data-slot="input" data-slot="input"
class={cn( class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className, className,
)} )}
type="file" type="file"
bind:files bind:files
bind:value bind:value
{...restProps} {...restProps}
/> />
{:else} {:else}
<input <input
bind:this={ref} bind:this={ref}
data-slot="input" data-slot="input"
class={cn( class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className, className,
)} )}
{type} {type}
bind:value bind:value
{...restProps} {...restProps}
/> />
{/if} {/if}

View File

@@ -1,7 +1,7 @@
import Root from "./label.svelte"; import Root from "./label.svelte";
export { export {
Root, Root,
// //
Root as Label, Root as Label,
}; };

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { Label as LabelPrimitive } from "bits-ui"; import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: LabelPrimitive.RootProps = $props(); }: LabelPrimitive.RootProps = $props();
</script> </script>
<LabelPrimitive.Root <LabelPrimitive.Root
bind:ref bind:ref
data-slot="label" data-slot="label"
class={cn( class={cn(
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50", "flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
className, className,
)} )}
{...restProps} {...restProps}
/> />

Some files were not shown because too many files have changed in this diff Show More