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
- develop
tags:
- 'v*.*.*'
- "v*.*.*"
pull_request:
branches:
- main

View File

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

View File

@@ -4,7 +4,7 @@
![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 ✈️
---
@@ -104,10 +104,10 @@ docker compose up -d
**Prerequisites:**
1. Node.js 20.19.1 — *the foundation*
2. `corepack enable`*unlock the tools*
3. `pnpm install`*gather your ingredients*
4. PostgreSQL 16 + Redis — *the data lovers*
1. Node.js 20.19.1 — _the foundation_
2. `corepack enable`_unlock the tools_
3. `pnpm install`_gather your ingredients_
4. PostgreSQL 16 + Redis — _the data lovers_
**Start your pleasure journey:**
@@ -198,13 +198,13 @@ Every request:
Assets are transformed on first request and cached as WebP:
| Preset | Size | Fit | Use |
|--------|------|-----|-----|
| `mini` | 80×80 | cover | Avatars in lists |
| `thumbnail` | 300×300 | cover | Profile photos |
| `preview` | 800px wide | inside | Video teasers |
| `medium` | 1400px wide | inside | Full-size images |
| `banner` | 1600×480 | cover | Profile banners |
| Preset | Size | Fit | Use |
| ----------- | ----------- | ------ | ---------------- |
| `mini` | 80×80 | cover | Avatars in lists |
| `thumbnail` | 300×300 | cover | Profile photos |
| `preview` | 800px wide | inside | Video teasers |
| `medium` | 1400px wide | inside | Full-size images |
| `banner` | 1600×480 | cover | Profile banners |
---
@@ -276,33 +276,33 @@ graph LR
### Backend (required)
| Variable | Description |
|----------|-------------|
| `DATABASE_URL` | PostgreSQL connection string |
| `REDIS_URL` | Redis connection string |
| Variable | Description |
| --------------- | ----------------------------- |
| `DATABASE_URL` | PostgreSQL connection string |
| `REDIS_URL` | Redis connection string |
| `COOKIE_SECRET` | Session cookie signing secret |
| `CORS_ORIGIN` | Allowed frontend origin |
| `UPLOAD_DIR` | Path for uploaded files |
| `CORS_ORIGIN` | Allowed frontend origin |
| `UPLOAD_DIR` | Path for uploaded files |
### Backend (optional)
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `4000` | Backend listen port |
| `LOG_LEVEL` | `info` | Fastify log level |
| `SMTP_HOST` | — | Email server for auth flows |
| `SMTP_PORT` | `587` | Email server port |
| `EMAIL_FROM` | — | Sender address |
| `PUBLIC_URL` | — | Frontend URL (for email links) |
| Variable | Default | Description |
| ------------ | ------- | ------------------------------ |
| `PORT` | `4000` | Backend listen port |
| `LOG_LEVEL` | `info` | Fastify log level |
| `SMTP_HOST` | — | Email server for auth flows |
| `SMTP_PORT` | `587` | Email server port |
| `EMAIL_FROM` | — | Sender address |
| `PUBLIC_URL` | — | Frontend URL (for email links) |
### Frontend
| Variable | Description |
|----------|-------------|
| `PUBLIC_API_URL` | Backend URL (e.g. `http://sexy_backend:4000`) |
| `PUBLIC_URL` | Frontend public URL |
| `PUBLIC_UMAMI_ID` | Umami analytics site ID (optional) |
| `PUBLIC_UMAMI_SCRIPT` | Umami script URL (optional) |
| Variable | Description |
| --------------------- | --------------------------------------------- |
| `PUBLIC_API_URL` | Backend URL (e.g. `http://sexy_backend:4000`) |
| `PUBLIC_URL` | Frontend public URL |
| `PUBLIC_UMAMI_ID` | Umami analytics site ID (optional) |
| `PUBLIC_UMAMI_SCRIPT` | Umami script URL (optional) |
---
@@ -314,23 +314,23 @@ graph LR
**[Palina](https://sexy.pivoine.art) & [Valknar](https://sexy.pivoine.art)**
*Für die Mäuse...* 🐭💕
_Für die Mäuse..._ 🐭💕
---
### 🙏 Built With
| Technology | Purpose |
|------------|---------|
| [SvelteKit](https://kit.svelte.dev/) | Frontend framework |
| [Fastify](https://fastify.dev/) | HTTP server |
| [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server) | GraphQL server |
| [Pothos](https://pothos-graphql.dev/) | Code-first schema |
| [Drizzle ORM](https://orm.drizzle.team/) | Database |
| [Sharp](https://sharp.pixelplumbing.com/) | Image transforms |
| [Buttplug.io](https://buttplug.io/) | Hardware |
| [bits-ui](https://www.bits-ui.com/) | UI components |
| [Gitea](https://dev.pivoine.art) | Self-hosted VCS & CI |
| Technology | Purpose |
| --------------------------------------------------------- | -------------------- |
| [SvelteKit](https://kit.svelte.dev/) | Frontend framework |
| [Fastify](https://fastify.dev/) | HTTP server |
| [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server) | GraphQL server |
| [Pothos](https://pothos-graphql.dev/) | Code-first schema |
| [Drizzle ORM](https://orm.drizzle.team/) | Database |
| [Sharp](https://sharp.pixelplumbing.com/) | Image transforms |
| [Buttplug.io](https://buttplug.io/) | Hardware |
| [bits-ui](https://www.bits-ui.com/) | UI components |
| [Gitea](https://dev.pivoine.art) | Self-hosted VCS & CI |
---
@@ -339,7 +339,7 @@ graph LR
Pioneer of sexual liberation (1919-2001)
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)
@@ -381,7 +381,7 @@ Pilot, Entrepreneur, Freedom Fighter
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝
</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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,12 @@ builder.queryField("commentsForVideo", (t) =>
return Promise.all(
commentList.map(async (c: any) => {
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)
.where(eq(users.id, c.user_id))
.limit(1);
@@ -57,7 +62,12 @@ builder.mutationField("createCommentForVideo", (t) =>
await checkAchievements(ctx.db, ctx.currentUser.id, "social");
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)
.where(eq(users.id, ctx.currentUser.id))
.limit(1);

View File

@@ -1,6 +1,12 @@
import { builder } from "../builder";
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";
builder.queryField("leaderboard", (t) =>
@@ -73,7 +79,12 @@ builder.queryField("userGamification", (t) =>
})
.from(user_achievements)
.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));
const recentPoints = await ctx.db

View File

@@ -162,11 +162,13 @@ builder.mutationField("updateRecording", (t) =>
updates.title = 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.status !== null && args.status !== undefined) updates.status = args.status;
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
.update(recordings)
@@ -319,11 +321,20 @@ builder.mutationField("updateRecordingPlay", (t) =>
await ctx.db
.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));
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");
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -49,7 +49,12 @@ async function main() {
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,
context: buildContext,
graphqlEndpoint: "/graphql",
@@ -101,7 +106,12 @@ async function main() {
if (!existsSync(cacheFile)) {
const originalPath = path.join(UPLOAD_DIR, id, filename);
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 })
.toFile(cacheFile);
}

View File

@@ -4,10 +4,12 @@ const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || "localhost",
port: parseInt(process.env.SMTP_PORT || "587"),
secure: process.env.SMTP_SECURE === "true",
auth: process.env.SMTP_USER ? {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
} : undefined,
auth: process.env.SMTP_USER
? {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
}
: undefined,
});
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`
SELECT COUNT(*) as count FROM recording_plays
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");
} else {
@@ -135,11 +138,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
}
}
export async function checkAchievements(
db: DB,
userId: string,
category?: string,
): Promise<void> {
export async function checkAchievements(db: DB, userId: string, category?: string): Promise<void> {
let achievementsQuery = db
.select()
.from(achievements)
@@ -176,7 +175,7 @@ export async function checkAchievements(
.update(user_achievements)
.set({
progress,
date_unlocked: isUnlocked ? (existing[0].date_unlocked || new Date()) : null,
date_unlocked: isUnlocked ? existing[0].date_unlocked || new Date() : null,
})
.where(
and(

View File

@@ -128,7 +128,9 @@ async function migrateUsers() {
? 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(
`INSERT INTO users (id, email, password_hash, first_name, last_name, artist_name, slug,
@@ -279,9 +281,7 @@ async function migrateVideoModels() {
async function migrateVideoLikes() {
console.log("❤️ Migrating video likes...");
const { rows } = await query(
`SELECT id, video_id, user_id, date_created FROM sexy_video_likes`,
);
const { rows } = await query(`SELECT id, video_id, user_id, date_created FROM sexy_video_likes`);
let migrated = 0;
for (const row of rows) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,22 +6,17 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
'use strict';
import * as Messages from '../core/Messages';
import {
ButtplugDeviceError,
ButtplugError,
ButtplugMessageError,
} from '../core/Exceptions';
import { EventEmitter } from 'eventemitter3';
import { ButtplugClientDeviceFeature } from './ButtplugClientDeviceFeature';
import { DeviceOutputCommand } from './ButtplugClientDeviceCommand';
"use strict";
import * as Messages from "../core/Messages";
import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } 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.
*/
export class ButtplugClientDevice extends EventEmitter {
private _features: Map<number, ButtplugClientDeviceFeature>;
/**
@@ -58,9 +53,7 @@ export class ButtplugClientDevice extends EventEmitter {
public static fromMsg(
msg: Messages.DeviceInfo,
sendClosure: (
msg: Messages.ButtplugMessage
) => Promise<Messages.ButtplugMessage>
sendClosure: (msg: Messages.ButtplugMessage) => Promise<Messages.ButtplugMessage>,
): ButtplugClientDevice {
return new ButtplugClientDevice(msg, sendClosure);
}
@@ -72,25 +65,29 @@ export class ButtplugClientDevice extends EventEmitter {
*/
private constructor(
private _deviceInfo: Messages.DeviceInfo,
private _sendClosure: (
msg: Messages.ButtplugMessage
) => Promise<Messages.ButtplugMessage>
private _sendClosure: (msg: Messages.ButtplugMessage) => Promise<Messages.ButtplugMessage>,
) {
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(
msg: Messages.ButtplugMessage
): Promise<Messages.ButtplugMessage> {
public async send(msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> {
// Assume we're getting the closure from ButtplugClient, which does all of
// the index/existence/connection/message checks for us.
return await this._sendClosure(msg);
}
protected sendMsgExpectOk = async (
msg: Messages.ButtplugMessage
): Promise<void> => {
protected sendMsgExpectOk = async (msg: Messages.ButtplugMessage): Promise<void> => {
const response = await this.send(msg);
if (response.Ok !== undefined) {
return;
@@ -109,19 +106,36 @@ export class ButtplugClientDevice extends EventEmitter {
protected isOutputValid(featureIndex: number, type: Messages.OutputType) {
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)) {
throw new ButtplugDeviceError(`Feature index ${featureIndex} does not support type ${type} for device ${this.name}`);
if (
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 {
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 {
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> {
@@ -138,7 +152,15 @@ export class ButtplugClientDevice extends EventEmitter {
}
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> {
@@ -160,6 +182,6 @@ export class ButtplugClientDevice extends EventEmitter {
}
public emitDisconnected() {
this.emit('deviceremoved');
this.emit("deviceremoved");
}
}

View File

@@ -14,7 +14,7 @@ class PercentOrSteps {
}
public static createSteps(s: number): PercentOrSteps {
let v = new PercentOrSteps;
let v = new PercentOrSteps();
v._steps = s;
return v;
}
@@ -24,7 +24,7 @@ class PercentOrSteps {
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;
return v;
}
@@ -35,8 +35,7 @@ export class DeviceOutputCommand {
private _outputType: OutputType,
private _value: PercentOrSteps,
private _duration?: number,
)
{}
) {}
public get outputType() {
return this._outputType;
@@ -52,26 +51,36 @@ export class DeviceOutputCommand {
}
export class DeviceOutputValueConstructor {
public constructor(
private _outputType: OutputType)
{}
public constructor(private _outputType: OutputType) {}
public steps(steps: number): DeviceOutputCommand {
return new DeviceOutputCommand(this._outputType, PercentOrSteps.createSteps(steps), undefined);
}
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 {
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 {
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";
export class ButtplugClientDeviceFeature {
constructor(
private _deviceIndex: number,
private _deviceName: string,
private _feature: Messages.DeviceFeature,
private _sendClosure: (
msg: Messages.ButtplugMessage
) => Promise<Messages.ButtplugMessage>) {
}
private _sendClosure: (msg: Messages.ButtplugMessage) => Promise<Messages.ButtplugMessage>,
) {}
protected send = async (msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> => {
return await this._sendClosure(msg);
}
};
protected sendMsgExpectOk = async (
msg: Messages.ButtplugMessage
): Promise<void> => {
protected sendMsgExpectOk = async (msg: Messages.ButtplugMessage): Promise<void> => {
const response = await this.send(msg);
if (response.Ok !== undefined) {
return;
@@ -32,13 +27,17 @@ export class ButtplugClientDeviceFeature {
protected isOutputValid(type: Messages.OutputType) {
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) {
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,
DeviceIndex: this._deviceIndex,
FeatureIndex: this._feature.FeatureIndex,
Command: outCommand
}
Command: outCommand,
},
};
await this.sendMsgExpectOk(cmd);
}
@@ -124,20 +123,29 @@ export class ButtplugClientDeviceFeature {
return false;
}
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);
}
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
this.isInputValid(inputType);
let inputAttributes = this._feature.Input[inputType];
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}`);
}
@@ -148,7 +156,7 @@ export class ButtplugClientDeviceFeature {
FeatureIndex: this._feature.FeatureIndex,
Type: inputType,
Command: inputCommand,
}
},
};
if (inputCommand == Messages.InputCommandType.Read) {
const response = await this.send(cmd);

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,9 +7,9 @@
*/
// 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 DEFAULT_MESSAGE_ID = 1;
@@ -132,34 +132,34 @@ export interface DeviceList {
}
export enum OutputType {
Unknown = 'Unknown',
Vibrate = 'Vibrate',
Rotate = 'Rotate',
Oscillate = 'Oscillate',
Constrict = 'Constrict',
Inflate = 'Inflate',
Position = 'Position',
HwPositionWithDuration = 'HwPositionWithDuration',
Temperature = 'Temperature',
Spray = 'Spray',
Led = 'Led',
Unknown = "Unknown",
Vibrate = "Vibrate",
Rotate = "Rotate",
Oscillate = "Oscillate",
Constrict = "Constrict",
Inflate = "Inflate",
Position = "Position",
HwPositionWithDuration = "HwPositionWithDuration",
Temperature = "Temperature",
Spray = "Spray",
Led = "Led",
}
export enum InputType {
Unknown = 'Unknown',
Battery = 'Battery',
RSSI = 'RSSI',
Button = 'Button',
Pressure = 'Pressure',
Unknown = "Unknown",
Battery = "Battery",
RSSI = "RSSI",
Button = "Button",
Pressure = "Pressure",
// Temperature,
// Accelerometer,
// Gyro,
}
export enum InputCommandType {
Read = 'Read',
Subscribe = 'Subscribe',
Unsubscribe = 'Unsubscribe',
Read = "Read",
Subscribe = "Subscribe",
Unsubscribe = "Unsubscribe",
}
export interface DeviceFeatureInput {

View File

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

View File

@@ -6,27 +6,24 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
import { ButtplugMessage } from './core/Messages';
import { IButtplugClientConnector } from './client/IButtplugClientConnector';
import { EventEmitter } from 'eventemitter3';
import { ButtplugMessage } from "./core/Messages";
import { IButtplugClientConnector } from "./client/IButtplugClientConnector";
import { EventEmitter } from "eventemitter3";
export * from './client/ButtplugClient';
export * from './client/ButtplugClientDevice';
export * from './client/ButtplugBrowserWebsocketClientConnector';
export * from './client/ButtplugNodeWebsocketClientConnector';
export * from './client/ButtplugClientConnectorException';
export * from './utils/ButtplugMessageSorter';
export * from './client/ButtplugClientDeviceCommand';
export * from './client/ButtplugClientDeviceFeature';
export * from './client/IButtplugClientConnector';
export * from './core/Messages';
export * from './core/Logging';
export * from './core/Exceptions';
export * from "./client/ButtplugClient";
export * from "./client/ButtplugClientDevice";
export * from "./client/ButtplugBrowserWebsocketClientConnector";
export * from "./client/ButtplugNodeWebsocketClientConnector";
export * from "./client/ButtplugClientConnectorException";
export * from "./utils/ButtplugMessageSorter";
export * from "./client/ButtplugClientDeviceCommand";
export * from "./client/ButtplugClientDeviceFeature";
export * from "./client/IButtplugClientConnector";
export * from "./core/Messages";
export * from "./core/Logging";
export * from "./core/Exceptions";
export class ButtplugWasmClientConnector
extends EventEmitter
implements IButtplugClientConnector
{
export class ButtplugWasmClientConnector extends EventEmitter implements IButtplugClientConnector {
private static _loggingActivated = false;
private static wasmInstance;
private _connected: boolean = false;
@@ -43,35 +40,30 @@ export class ButtplugWasmClientConnector
private static maybeLoadWasm = async () => {
if (ButtplugWasmClientConnector.wasmInstance == undefined) {
ButtplugWasmClientConnector.wasmInstance = await import(
'../wasm/index.js'
);
ButtplugWasmClientConnector.wasmInstance = await import("../wasm/index.js");
}
};
public static activateLogging = async (logLevel: string = 'debug') => {
public static activateLogging = async (logLevel: string = "debug") => {
await ButtplugWasmClientConnector.maybeLoadWasm();
if (this._loggingActivated) {
console.log('Logging already activated, ignoring.');
console.log("Logging already activated, ignoring.");
return;
}
console.log('Turning on logging.');
ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger(
logLevel,
);
console.log("Turning on logging.");
ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger(logLevel);
};
public initialize = async (): Promise<void> => {};
public connect = async (): Promise<void> => {
await ButtplugWasmClientConnector.maybeLoadWasm();
this.client =
ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server(
(msgs) => {
this.emitMessage(msgs);
},
this.serverPtr,
);
this.client = ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server(
(msgs) => {
this.emitMessage(msgs);
},
this.serverPtr,
);
this._connected = true;
};
@@ -80,7 +72,7 @@ export class ButtplugWasmClientConnector
public send = (msg: ButtplugMessage): void => {
ButtplugWasmClientConnector.wasmInstance.buttplug_client_send_json_message(
this.client,
new TextEncoder().encode('[' + JSON.stringify(msg) + ']'),
new TextEncoder().encode("[" + JSON.stringify(msg) + "]"),
(output) => {
this.emitMessage(output);
},
@@ -90,6 +82,6 @@ export class ButtplugWasmClientConnector
private emitMessage = (msg: Uint8Array) => {
const str = new TextDecoder().decode(msg);
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.
*/
'use strict';
"use strict";
import { EventEmitter } from 'eventemitter3';
import { ButtplugMessage } from '../core/Messages';
import { EventEmitter } from "eventemitter3";
import { ButtplugMessage } from "../core/Messages";
export class ButtplugBrowserWebsocketConnector extends EventEmitter {
protected _ws: WebSocket | undefined;
@@ -26,18 +26,20 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
public connect = async (): Promise<void> => {
return new Promise<void>((resolve, reject) => {
const ws = new (this._websocketConstructor ?? WebSocket)(this._url);
const onErrorCallback = (event: Event) => {reject(event)}
const onCloseCallback = (event: CloseEvent) => reject(event.reason)
ws.addEventListener('open', async () => {
const onErrorCallback = (event: Event) => {
reject(event);
};
const onCloseCallback = (event: CloseEvent) => reject(event.reason);
ws.addEventListener("open", async () => {
this._ws = ws;
try {
await this.initialize();
this._ws.addEventListener('message', (msg) => {
this._ws.addEventListener("message", (msg) => {
this.parseIncomingMessage(msg);
});
this._ws.removeEventListener('close', onCloseCallback);
this._ws.removeEventListener('error', onErrorCallback);
this._ws.addEventListener('close', this.disconnect);
this._ws.removeEventListener("close", onCloseCallback);
this._ws.removeEventListener("error", onErrorCallback);
this._ws.addEventListener("close", this.disconnect);
resolve();
} catch (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
// library to state what the problem might be.
ws.addEventListener('error', onErrorCallback)
ws.addEventListener('close', onCloseCallback);
ws.addEventListener("error", onErrorCallback);
ws.addEventListener("close", onCloseCallback);
});
};
@@ -58,14 +60,14 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
}
this._ws!.close();
this._ws = undefined;
this.emit('disconnect');
this.emit("disconnect");
};
public sendMessage(msg: ButtplugMessage) {
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> => {
@@ -73,9 +75,9 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
};
protected parseIncomingMessage(event: MessageEvent) {
if (typeof event.data === 'string') {
if (typeof event.data === "string") {
const msgs: ButtplugMessage[] = JSON.parse(event.data);
this.emit('message', msgs);
this.emit("message", msgs);
} else if (event.data instanceof Blob) {
// No-op, we only use text message types.
}
@@ -83,6 +85,6 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
protected onReaderLoad(event: Event) {
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.
*/
import * as Messages from '../core/Messages';
import { ButtplugError } from '../core/Exceptions';
import * as Messages from "../core/Messages";
import { ButtplugError } from "../core/Exceptions";
export class ButtplugMessageSorter {
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
// them while waiting for them to return across the line.
// tslint:disable:promise-function-async
public PrepareOutgoingMessage(
msg: Messages.ButtplugMessage
): Promise<Messages.ButtplugMessage> {
public PrepareOutgoingMessage(msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> {
if (this._useCounter) {
Messages.setMsgId(msg, this._counter);
// Always increment last, otherwise we might lose sync
@@ -31,19 +29,15 @@ export class ButtplugMessageSorter {
}
let res;
let rej;
const msgPromise = new Promise<Messages.ButtplugMessage>(
(resolve, reject) => {
res = resolve;
rej = reject;
}
);
const msgPromise = new Promise<Messages.ButtplugMessage>((resolve, reject) => {
res = resolve;
rej = reject;
});
this._waitingMsgs.set(Messages.msgId(msg), [res, rej]);
return msgPromise;
}
public ParseIncomingMessages(
msgs: Messages.ButtplugMessage[]
): Messages.ButtplugMessage[] {
public ParseIncomingMessages(msgs: Messages.ButtplugMessage[]): Messages.ButtplugMessage[] {
const noMatch: Messages.ButtplugMessage[] = [];
for (const x of msgs) {
let id = Messages.msgId(x);

View File

@@ -1,3 +1,3 @@
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": {
"target": "esnext",
"module": "esnext",
"outDir": "dist",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"outDir": "dist",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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="16x16" href="/favicon-16x16.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<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 rel="preconnect" href="https://fonts.googleapis.com" />
<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 rel="manifest" href="/site.webmanifest" />
%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>
</body>
</body>
</html>

View File

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

View File

@@ -1,77 +1,70 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import { onMount } from "svelte";
import { _ } from "svelte-i18n";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import { onMount } from "svelte";
const AGE_VERIFICATION_KEY = "age-verified";
const AGE_VERIFICATION_KEY = "age-verified";
let isOpen = true;
let isOpen = true;
function handleAgeConfirmation() {
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
isOpen = false;
}
function handleAgeConfirmation() {
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
isOpen = false;
}
onMount(() => {
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
if (storedVerification === "true") {
isOpen = false;
}
});
onMount(() => {
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
if (storedVerification === "true") {
isOpen = false;
}
});
</script>
<Dialog bind:open={isOpen}>
<DialogContent
class="sm:max-w-md"
onInteractOutside={(e) => e.preventDefault()}
showCloseButton={false}
>
<DialogHeader class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<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"
>
<span class="text-primary-foreground text-sm"
>{$_("age_verification_dialog.age")}</span
>
</div>
<div class="">
<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"
<DialogContent
class="sm:max-w-md"
onInteractOutside={(e) => e.preventDefault()}
showCloseButton={false}
>
<DialogHeader class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<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"
>
<span class="text-primary-foreground text-sm">{$_("age_verification_dialog.age")}</span>
</div>
<div class="">
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
>{$_("age_verification_dialog.title")}</DialogTitle
>
<span class="icon-[ri--check-line]"></span>
{$_("age_verification_dialog.confirm")}
</Button>
<DialogDescription class="text-left text-sm">
{$_("age_verification_dialog.description")}
</DialogDescription>
</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>

View File

@@ -1,55 +1,55 @@
<!-- Advanced Plasma Background -->
<div class="absolute inset-0 pointer-events-none">
<!-- Primary gradient layers -->
<div
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
></div>
<div
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
></div>
<!-- Primary gradient layers -->
<div
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
></div>
<div
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
></div>
<!-- Large floating orbs -->
<!-- <div
<!-- Large floating orbs -->
<!-- <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"
></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"
></div> -->
<!-- Medium morphing elements -->
<!-- <div
<!-- Medium morphing elements -->
<!-- <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"
></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"
></div> -->
<!-- Soft particle effects -->
<!-- <div
<!-- Soft particle effects -->
<!-- <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"
></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"
></div> -->
<!-- Premium glassmorphism overlay -->
<!-- <div
<!-- Premium glassmorphism overlay -->
<!-- <div
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/3 backdrop-blur-[1px]"
></div> -->
<!-- Animated Plasma Background -->
<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"
></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"
></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"
></div>
<!-- Animated Plasma Background -->
<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"
></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"
></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"
></div>
<!-- Global Plasma Background -->
<!-- <div
<!-- Global Plasma Background -->
<!-- <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"
></div>
<div

View File

@@ -1,12 +1,8 @@
<script lang="ts">
const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
</script>
<button
class="block rounded-full cursor-pointer"
onclick={onclick}
aria-label={label}
>
<button class="block rounded-full cursor-pointer" {onclick} aria-label={label}>
<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"
>
@@ -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"
>
<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
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
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
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
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
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>

View File

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

View File

@@ -1,120 +1,120 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import Logo from "../logo/logo.svelte";
import { _ } from "svelte-i18n";
import Logo from "../logo/logo.svelte";
</script>
<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="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="space-y-4">
<div class="flex items-center gap-3 text-xl font-bold">
<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 class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="space-y-4">
<div class="flex items-center gap-3 text-xl font-bold">
<Logo />
</div>
<div class="border-t border-border/50 mt-8 pt-8 text-center">
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p>
<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 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>

View File

@@ -7,9 +7,7 @@
stroke="#ce47eb"
preserveAspectRatio="xMidYMid meet"
>
<metadata>
Created by potrace 1.15, written by Peter Selinger 2001-2017
</metadata>
<metadata> Created by potrace 1.15, written by Peter Selinger 2001-2017 </metadata>
<g transform="translate(0.000000,904.000000) scale(0.100000,-0.100000)">
<path
d="M7930 7043 c-73 -10 -95 -18 -134 -51 -25 -20 -66 -53 -91 -72 -26

View File

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

View File

@@ -1,25 +1,24 @@
<script lang="ts">
interface Props {
class?: string;
size?: string | number;
}
interface Props {
class?: string;
size?: string | number;
}
let { class: className = "", size = "24" }: Props = $props();
let { class: className = "", size = "24" }: Props = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 512 512"
class={className}
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 512 512"
class={className}
xmlns="http://www.w3.org/2000/svg"
>
<g class="" transform="translate(0,0)" style=""
><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"
fill-opacity="1"
style="fill: currentColor; stroke: #ce47eb; stroke-width: 10px;"
></path></g
></svg
<g class="" transform="translate(0,0)" style=""
><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"
fill-opacity="1"
style="fill: currentColor; stroke: #ce47eb; stroke-width: 10px;"
></path></g
></svg
>

View File

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

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import PeonyIcon from "../icon/peony-icon.svelte";
import { _ } from "svelte-i18n";
import PeonyIcon from "../icon/peony-icon.svelte";
const { hideName = false } = $props();
const { hideName = false } = $props();
</script>
<div class="relative">
@@ -11,11 +11,11 @@ const { hideName = false } = $props();
<span
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>
<style>
.logo {
font-family: 'Dancing Script', cursive;
font-family: "Dancing Script", cursive;
}
</style>

View File

@@ -1,148 +1,184 @@
<script lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
interface User {
name?: string;
email: string;
avatar?: string;
}
interface User {
name?: string;
email: string;
avatar?: string;
}
interface Props {
user: User;
onLogout: () => void;
}
interface Props {
user: User;
onLogout: () => void;
}
let { user, onLogout }: Props = $props();
let { user, onLogout }: Props = $props();
let isDragging = $state(false);
let slidePosition = $state(0);
let startX = 0;
let currentX = 0;
let maxSlide = 117; // Maximum slide distance
let threshold = 0.75; // 70% threshold to trigger logout
let isDragging = $state(false);
let slidePosition = $state(0);
let startX = 0;
let currentX = 0;
let maxSlide = 117; // Maximum slide distance
let threshold = 0.75; // 70% threshold to trigger logout
// Calculate slide progress (0 to 1)
const slideProgress = $derived(Math.min(slidePosition / maxSlide, 1));
const isNearThreshold = $derived(slideProgress > threshold);
// Calculate slide progress (0 to 1)
const slideProgress = $derived(Math.min(slidePosition / maxSlide, 1));
const isNearThreshold = $derived(slideProgress > threshold);
const handleStart = (clientX: number) => {
isDragging = true;
startX = clientX;
currentX = clientX;
};
const handleStart = (clientX: number) => {
isDragging = true;
startX = clientX;
currentX = clientX;
};
const handleMove = (clientX: number) => {
if (!isDragging) return;
const handleMove = (clientX: number) => {
if (!isDragging) return;
currentX = clientX;
const deltaX = currentX - startX;
slidePosition = Math.max(0, Math.min(deltaX, maxSlide));
};
currentX = clientX;
const deltaX = currentX - startX;
slidePosition = Math.max(0, Math.min(deltaX, maxSlide));
};
const handleEnd = () => {
if (!isDragging) return;
const handleEnd = () => {
if (!isDragging) return;
isDragging = false;
isDragging = false;
if (slideProgress >= threshold) {
// Trigger logout
slidePosition = maxSlide;
onLogout();
} else {
// Snap back
slidePosition = 0;
}
};
if (slideProgress >= threshold) {
// Trigger logout
slidePosition = maxSlide;
onLogout();
} else {
// Snap back
slidePosition = 0;
}
};
// Mouse events
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault();
handleStart(e.clientX);
};
// Mouse events
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault();
handleStart(e.clientX);
};
const handleMouseMove = (e: MouseEvent) => {
handleMove(e.clientX);
};
const handleMouseMove = (e: MouseEvent) => {
handleMove(e.clientX);
};
const handleMouseUp = () => {
handleEnd();
};
const handleMouseUp = () => {
handleEnd();
};
// Touch events
const handleTouchStart = (e: TouchEvent) => {
handleStart(e.touches[0].clientX);
};
// Touch events
const handleTouchStart = (e: TouchEvent) => {
handleStart(e.touches[0].clientX);
};
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault();
handleMove(e.touches[0].clientX);
};
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault();
handleMove(e.touches[0].clientX);
};
const handleTouchEnd = () => {
handleEnd();
};
const handleTouchEnd = () => {
handleEnd();
};
// Add global event listeners when dragging
$effect(() => {
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("touchmove", handleTouchMove, { passive: false });
document.addEventListener("touchend", handleTouchEnd);
// Add global event listeners when dragging
$effect(() => {
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("touchmove", handleTouchMove, { passive: false });
document.addEventListener("touchend", handleTouchEnd);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd);
};
}
});
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd);
};
}
});
</script>
<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' : ''}"
style="background: linear-gradient(90deg,
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'
: ''}"
style="background: linear-gradient(90deg,
oklch(var(--primary) / 0.3) 0%,
oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%,
oklch(var(--accent) / {0.1 + slideProgress * 0.2}) {(1 - slideProgress) * 100}%,
oklch(var(--accent) / {0.2 + slideProgress * 0.3}) 100%
)"
>
<!-- Background slide indicator -->
<div
class="absolute inset-0 rounded-full transition-all duration-200"
style="background: linear-gradient(90deg,
<!-- Background slide indicator -->
<div
class="absolute inset-0 rounded-full transition-all duration-200"
style="background: linear-gradient(90deg,
transparent 0%,
transparent {Math.max(0, slideProgress * 100 - 20)}%,
oklch(var(--accent) / {slideProgress * 0.1}) {slideProgress * 100}%,
oklch(var(--accent) / {slideProgress * 0.2}) 100%
)"
></div>
></div>
<!-- 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}>
<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)}">
<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>
<!-- 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}
>
<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)}"
>
<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 -->
<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'}">
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 transition-transform duration-200 {isNearThreshold ? 'scale-110' : ''}" ></span>
</div>
<!-- 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'}"
>
<span
class="icon-[ri--logout-circle-r-line] h-4 w-4 transition-transform duration-200 {isNearThreshold
? 'scale-110'
: ''}"
></span>
</div>
<!-- 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> -->
<!-- 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>

View File

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

View File

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

View File

@@ -1,17 +1,17 @@
<script lang="ts">
interface Props {
onclick: () => void;
icon: string;
label: string;
}
interface Props {
onclick: () => void;
icon: string;
label: string;
}
let { onclick, icon, label }: Props = $props();
let { onclick, icon, label }: Props = $props();
</script>
<button
{onclick}
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"
{onclick}
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"
>
<span class={icon + " w-4 h-4 text-primary"}></span>
<span class={icon + " w-4 h-4 text-primary"}></span>
</button>

View File

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

View File

@@ -1,10 +1,10 @@
<script>
import { _ } from "svelte-i18n";
import SharingPopup from "./sharing-popup.svelte";
import Button from "../ui/button/button.svelte";
import { _ } from "svelte-i18n";
import SharingPopup from "./sharing-popup.svelte";
import Button from "../ui/button/button.svelte";
const { content } = $props();
let isPopupOpen = $state(false);
const { content } = $props();
let isPopupOpen = $state(false);
</script>
<Button
@@ -13,6 +13,6 @@ let isPopupOpen = $state(false);
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>
{$_('sharing_popup_button.share')}
{$_("sharing_popup_button.share")}
</Button>
<SharingPopup bind:open={isPopupOpen} {content} />

View File

@@ -1,89 +1,89 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import ShareServices from "./share-services.svelte";
import type { Snippet } from "svelte";
import { _ } from "svelte-i18n";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import ShareServices from "./share-services.svelte";
import type { Snippet } from "svelte";
interface ShareContent {
title: string;
description: string;
url: string;
type: "video" | "model" | "article" | "link";
}
interface ShareContent {
title: string;
description: string;
url: string;
type: "video" | "model" | "article" | "link";
}
interface Props {
open: boolean;
content: ShareContent;
children?: Snippet;
}
interface Props {
open: boolean;
content: ShareContent;
children?: Snippet;
}
let { open = $bindable(), content }: Props = $props();
let { open = $bindable(), content }: Props = $props();
</script>
<Dialog bind:open>
<DialogContent class="sm:max-w-md">
<DialogHeader class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<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"
>
<span class="icon-[ri--share-2-line] text-primary-foreground"></span>
</div>
<div class="">
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
>{$_("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"
<DialogContent class="sm:max-w-md">
<DialogHeader class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<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"
>
<span class="icon-[ri--share-2-line] text-primary-foreground"></span>
</div>
<div class="">
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
>{$_("sharing_popup.title")}</DialogTitle
>
<span class="icon-[ri--close-large-line]"></span>
{$_("sharing_popup.close")}
</Button>
<DialogDescription class="text-left text-sm">
{$_("sharing_popup.description", {
values: { type: content.type },
})}
</DialogDescription>
</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>

View File

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

View File

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

View File

@@ -1,44 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
import { type VariantProps, tv } from "tailwind-variants";
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",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
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",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
{@render children?.()}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,86 +1,80 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type {
HTMLAnchorAttributes,
HTMLButtonAttributes,
} from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
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",
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
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",
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",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
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",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
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",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
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",
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",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
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",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
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",
className,
)}
bind:checked
bind:indeterminate
{...restProps}
bind:ref
data-slot="checkbox"
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",
className,
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
{/if}
</div>
{/snippet}
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>

View File

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

View File

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

View File

@@ -1,43 +1,43 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import { Dialog as DialogPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
showCloseButton?: boolean;
} = $props();
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
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",
className,
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<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"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
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",
className,
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<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"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</Dialog.Portal>

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
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",
className,
)}
{...restProps}
bind:ref
data-slot="dialog-overlay"
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",
className,
)}
{...restProps}
/>

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,13 +6,13 @@ import FileDropZone from "./file-drop-zone.svelte";
import { type FileRejectedReason, type FileDropZoneProps } from "./types";
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

View File

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

View File

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

View File

@@ -1,57 +1,51 @@
<script lang="ts">
import type {
HTMLInputAttributes,
HTMLInputTypeAttribute,
} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLInputAttributes, 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<
Omit<HTMLInputAttributes, "type"> &
(
| { type: "file"; files?: FileList }
| { type?: InputType; files?: undefined }
)
>;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot="input"
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",
"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",
className,
)}
type="file"
bind:files
bind:value
{...restProps}
/>
<input
bind:this={ref}
data-slot="input"
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",
"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",
className,
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot="input"
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",
"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",
className,
)}
{type}
bind:value
{...restProps}
/>
<input
bind:this={ref}
data-slot="input"
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",
"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",
className,
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

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

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
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",
className,
)}
{...restProps}
bind:ref
data-slot="label"
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",
className,
)}
{...restProps}
/>

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