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

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,7 @@
"eslint-plugin-svelte": "^3.15.0", "eslint-plugin-svelte": "^3.15.0",
"globals": "^17.4.0", "globals": "^17.4.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.1",
"typescript-eslint": "^8.56.1" "typescript-eslint": "^8.56.1"
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { builder } from "../builder"; import { builder } from "../builder";
// File type // File type
export const FileType = builder.objectRef<{ export const FileType = builder
.objectRef<{
id: string; id: string;
title: string | null; title: string | null;
description: string | null; description: string | null;
@@ -11,7 +12,8 @@ export const FileType = builder.objectRef<{
duration: number | null; duration: number | null;
uploaded_by: string | null; uploaded_by: string | null;
date_created: Date; date_created: Date;
}>("File").implement({ }>("File")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
title: t.exposeString("title", { nullable: true }), title: t.exposeString("title", { nullable: true }),
@@ -23,10 +25,11 @@ export const FileType = builder.objectRef<{
uploaded_by: t.exposeString("uploaded_by", { nullable: true }), uploaded_by: t.exposeString("uploaded_by", { nullable: true }),
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
}), }),
}); });
// User type // User type
export const UserType = builder.objectRef<{ export const UserType = builder
.objectRef<{
id: string; id: string;
email: string; email: string;
first_name: string | null; first_name: string | null;
@@ -40,7 +43,8 @@ export const UserType = builder.objectRef<{
banner: string | null; banner: string | null;
email_verified: boolean; email_verified: boolean;
date_created: Date; date_created: Date;
}>("User").implement({ }>("User")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
email: t.exposeString("email"), email: t.exposeString("email"),
@@ -56,10 +60,11 @@ export const UserType = builder.objectRef<{
email_verified: t.exposeBoolean("email_verified"), email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
}), }),
}); });
// CurrentUser type (same shape, used for auth context) // CurrentUser type (same shape, used for auth context)
export const CurrentUserType = builder.objectRef<{ export const CurrentUserType = builder
.objectRef<{
id: string; id: string;
email: string; email: string;
first_name: string | null; first_name: string | null;
@@ -73,7 +78,8 @@ export const CurrentUserType = builder.objectRef<{
banner: string | null; banner: string | null;
email_verified: boolean; email_verified: boolean;
date_created: Date; date_created: Date;
}>("CurrentUser").implement({ }>("CurrentUser")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
email: t.exposeString("email"), email: t.exposeString("email"),
@@ -89,10 +95,11 @@ export const CurrentUserType = builder.objectRef<{
email_verified: t.exposeBoolean("email_verified"), email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
}), }),
}); });
// Video type // Video type
export const VideoType = builder.objectRef<{ export const VideoType = builder
.objectRef<{
id: string; id: string;
slug: string; slug: string;
title: string; title: string;
@@ -105,9 +112,20 @@ export const VideoType = builder.objectRef<{
featured: boolean | null; featured: boolean | null;
likes_count: number | null; likes_count: number | null;
plays_count: number | null; plays_count: number | null;
models?: { id: string; artist_name: string | null; slug: string | null; avatar: string | null }[]; models?: {
movie_file?: { id: string; filename: string; mime_type: string | null; duration: number | null } | null; id: string;
}>("Video").implement({ artist_name: string | null;
slug: string | null;
avatar: string | null;
}[];
movie_file?: {
id: string;
filename: string;
mime_type: string | null;
duration: number | null;
} | null;
}>("Video")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
slug: t.exposeString("slug"), slug: t.exposeString("slug"),
@@ -124,38 +142,43 @@ export const VideoType = builder.objectRef<{
models: t.expose("models", { type: [VideoModelType], nullable: true }), models: t.expose("models", { type: [VideoModelType], nullable: true }),
movie_file: t.expose("movie_file", { type: VideoFileType, nullable: true }), movie_file: t.expose("movie_file", { type: VideoFileType, nullable: true }),
}), }),
}); });
export const VideoModelType = builder.objectRef<{ export const VideoModelType = builder
.objectRef<{
id: string; id: string;
artist_name: string | null; artist_name: string | null;
slug: string | null; slug: string | null;
avatar: string | null; avatar: string | null;
}>("VideoModel").implement({ }>("VideoModel")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
artist_name: t.exposeString("artist_name", { nullable: true }), artist_name: t.exposeString("artist_name", { nullable: true }),
slug: t.exposeString("slug", { nullable: true }), slug: t.exposeString("slug", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
}), }),
}); });
export const VideoFileType = builder.objectRef<{ export const VideoFileType = builder
.objectRef<{
id: string; id: string;
filename: string; filename: string;
mime_type: string | null; mime_type: string | null;
duration: number | null; duration: number | null;
}>("VideoFile").implement({ }>("VideoFile")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
filename: t.exposeString("filename"), filename: t.exposeString("filename"),
mime_type: t.exposeString("mime_type", { nullable: true }), mime_type: t.exposeString("mime_type", { nullable: true }),
duration: t.exposeInt("duration", { nullable: true }), duration: t.exposeInt("duration", { nullable: true }),
}), }),
}); });
// Model type (model profile, enriched user) // Model type (model profile, enriched user)
export const ModelType = builder.objectRef<{ export const ModelType = builder
.objectRef<{
id: string; id: string;
slug: string | null; slug: string | null;
artist_name: string | null; artist_name: string | null;
@@ -165,7 +188,8 @@ export const ModelType = builder.objectRef<{
tags: string[] | null; tags: string[] | null;
date_created: Date; date_created: Date;
photos?: { id: string; filename: string }[]; photos?: { id: string; filename: string }[];
}>("Model").implement({ }>("Model")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
slug: t.exposeString("slug", { nullable: true }), slug: t.exposeString("slug", { nullable: true }),
@@ -177,20 +201,23 @@ export const ModelType = builder.objectRef<{
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }), photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }),
}), }),
}); });
export const ModelPhotoType = builder.objectRef<{ export const ModelPhotoType = builder
.objectRef<{
id: string; id: string;
filename: string; filename: string;
}>("ModelPhoto").implement({ }>("ModelPhoto")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
filename: t.exposeString("filename"), filename: t.exposeString("filename"),
}), }),
}); });
// Article type // Article type
export const ArticleType = builder.objectRef<{ export const ArticleType = builder
.objectRef<{
id: string; id: string;
slug: string; slug: string;
title: string; title: string;
@@ -201,8 +228,14 @@ export const ArticleType = builder.objectRef<{
publish_date: Date; publish_date: Date;
category: string | null; category: string | null;
featured: boolean | null; featured: boolean | null;
author?: { first_name: string | null; last_name: string | null; avatar: string | null; description: string | null } | null; author?: {
}>("Article").implement({ first_name: string | null;
last_name: string | null;
avatar: string | null;
description: string | null;
} | null;
}>("Article")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
slug: t.exposeString("slug"), slug: t.exposeString("slug"),
@@ -216,24 +249,27 @@ export const ArticleType = builder.objectRef<{
featured: t.exposeBoolean("featured", { nullable: true }), featured: t.exposeBoolean("featured", { nullable: true }),
author: t.expose("author", { type: ArticleAuthorType, nullable: true }), author: t.expose("author", { type: ArticleAuthorType, nullable: true }),
}), }),
}); });
export const ArticleAuthorType = builder.objectRef<{ export const ArticleAuthorType = builder
.objectRef<{
first_name: string | null; first_name: string | null;
last_name: string | null; last_name: string | null;
avatar: string | null; avatar: string | null;
description: string | null; description: string | null;
}>("ArticleAuthor").implement({ }>("ArticleAuthor")
.implement({
fields: (t) => ({ fields: (t) => ({
first_name: t.exposeString("first_name", { nullable: true }), first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }), last_name: t.exposeString("last_name", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
description: t.exposeString("description", { nullable: true }), description: t.exposeString("description", { nullable: true }),
}), }),
}); });
// Recording type // Recording type
export const RecordingType = builder.objectRef<{ export const RecordingType = builder
.objectRef<{
id: string; id: string;
title: string; title: string;
description: string | null; description: string | null;
@@ -249,7 +285,8 @@ export const RecordingType = builder.objectRef<{
public: boolean | null; public: boolean | null;
date_created: Date; date_created: Date;
date_updated: Date | null; date_updated: Date | null;
}>("Recording").implement({ }>("Recording")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
title: t.exposeString("title"), title: t.exposeString("title"),
@@ -267,18 +304,25 @@ export const RecordingType = builder.objectRef<{
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
date_updated: t.expose("date_updated", { type: "DateTime", nullable: true }), date_updated: t.expose("date_updated", { type: "DateTime", nullable: true }),
}), }),
}); });
// Comment type // Comment type
export const CommentType = builder.objectRef<{ export const CommentType = builder
.objectRef<{
id: number; id: number;
collection: string; collection: string;
item_id: string; item_id: string;
comment: string; comment: string;
user_id: string; user_id: string;
date_created: Date; date_created: Date;
user?: { id: string; first_name: string | null; last_name: string | null; avatar: string | null } | null; user?: {
}>("Comment").implement({ id: string;
first_name: string | null;
last_name: string | null;
avatar: string | null;
} | null;
}>("Comment")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeInt("id"), id: t.exposeInt("id"),
collection: t.exposeString("collection"), collection: t.exposeString("collection"),
@@ -288,37 +332,42 @@ export const CommentType = builder.objectRef<{
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
user: t.expose("user", { type: CommentUserType, nullable: true }), user: t.expose("user", { type: CommentUserType, nullable: true }),
}), }),
}); });
export const CommentUserType = builder.objectRef<{ export const CommentUserType = builder
.objectRef<{
id: string; id: string;
first_name: string | null; first_name: string | null;
last_name: string | null; last_name: string | null;
avatar: string | null; avatar: string | null;
}>("CommentUser").implement({ }>("CommentUser")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
first_name: t.exposeString("first_name", { nullable: true }), first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }), last_name: t.exposeString("last_name", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
}), }),
}); });
// Stats type // Stats type
export const StatsType = builder.objectRef<{ export const StatsType = builder
.objectRef<{
videos_count: number; videos_count: number;
models_count: number; models_count: number;
viewers_count: number; viewers_count: number;
}>("Stats").implement({ }>("Stats")
.implement({
fields: (t) => ({ fields: (t) => ({
videos_count: t.exposeInt("videos_count"), videos_count: t.exposeInt("videos_count"),
models_count: t.exposeInt("models_count"), models_count: t.exposeInt("models_count"),
viewers_count: t.exposeInt("viewers_count"), viewers_count: t.exposeInt("viewers_count"),
}), }),
}); });
// Gamification types // Gamification types
export const LeaderboardEntryType = builder.objectRef<{ export const LeaderboardEntryType = builder
.objectRef<{
user_id: string; user_id: string;
display_name: string | null; display_name: string | null;
avatar: string | null; avatar: string | null;
@@ -328,7 +377,8 @@ export const LeaderboardEntryType = builder.objectRef<{
playbacks_count: number | null; playbacks_count: number | null;
achievements_count: number | null; achievements_count: number | null;
rank: number; rank: number;
}>("LeaderboardEntry").implement({ }>("LeaderboardEntry")
.implement({
fields: (t) => ({ fields: (t) => ({
user_id: t.exposeString("user_id"), user_id: t.exposeString("user_id"),
display_name: t.exposeString("display_name", { nullable: true }), display_name: t.exposeString("display_name", { nullable: true }),
@@ -340,9 +390,10 @@ export const LeaderboardEntryType = builder.objectRef<{
achievements_count: t.exposeInt("achievements_count", { nullable: true }), achievements_count: t.exposeInt("achievements_count", { nullable: true }),
rank: t.exposeInt("rank"), rank: t.exposeInt("rank"),
}), }),
}); });
export const AchievementType = builder.objectRef<{ export const AchievementType = builder
.objectRef<{
id: string; id: string;
code: string; code: string;
name: string; name: string;
@@ -351,7 +402,8 @@ export const AchievementType = builder.objectRef<{
category: string | null; category: string | null;
required_count: number; required_count: number;
points_reward: number; points_reward: number;
}>("Achievement").implement({ }>("Achievement")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
code: t.exposeString("code"), code: t.exposeString("code"),
@@ -362,9 +414,10 @@ export const AchievementType = builder.objectRef<{
required_count: t.exposeInt("required_count"), required_count: t.exposeInt("required_count"),
points_reward: t.exposeInt("points_reward"), points_reward: t.exposeInt("points_reward"),
}), }),
}); });
export const UserGamificationType = builder.objectRef<{ export const UserGamificationType = builder
.objectRef<{
stats: { stats: {
user_id: string; user_id: string;
total_raw_points: number | null; total_raw_points: number | null;
@@ -392,15 +445,17 @@ export const UserGamificationType = builder.objectRef<{
date_created: Date; date_created: Date;
recording_id: string | null; recording_id: string | null;
}[]; }[];
}>("UserGamification").implement({ }>("UserGamification")
.implement({
fields: (t) => ({ fields: (t) => ({
stats: t.expose("stats", { type: UserStatsType, nullable: true }), stats: t.expose("stats", { type: UserStatsType, nullable: true }),
achievements: t.expose("achievements", { type: [UserAchievementType] }), achievements: t.expose("achievements", { type: [UserAchievementType] }),
recent_points: t.expose("recent_points", { type: [RecentPointType] }), recent_points: t.expose("recent_points", { type: [RecentPointType] }),
}), }),
}); });
export const UserStatsType = builder.objectRef<{ export const UserStatsType = builder
.objectRef<{
user_id: string; user_id: string;
total_raw_points: number | null; total_raw_points: number | null;
total_weighted_points: number | null; total_weighted_points: number | null;
@@ -409,7 +464,8 @@ export const UserStatsType = builder.objectRef<{
comments_count: number | null; comments_count: number | null;
achievements_count: number | null; achievements_count: number | null;
rank: number; rank: number;
}>("UserStats").implement({ }>("UserStats")
.implement({
fields: (t) => ({ fields: (t) => ({
user_id: t.exposeString("user_id"), user_id: t.exposeString("user_id"),
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }), total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
@@ -420,9 +476,10 @@ export const UserStatsType = builder.objectRef<{
achievements_count: t.exposeInt("achievements_count", { nullable: true }), achievements_count: t.exposeInt("achievements_count", { nullable: true }),
rank: t.exposeInt("rank"), rank: t.exposeInt("rank"),
}), }),
}); });
export const UserAchievementType = builder.objectRef<{ export const UserAchievementType = builder
.objectRef<{
id: string; id: string;
code: string; code: string;
name: string; name: string;
@@ -432,7 +489,8 @@ export const UserAchievementType = builder.objectRef<{
date_unlocked: Date; date_unlocked: Date;
progress: number | null; progress: number | null;
required_count: number; required_count: number;
}>("UserAchievement").implement({ }>("UserAchievement")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
code: t.exposeString("code"), code: t.exposeString("code"),
@@ -444,24 +502,27 @@ export const UserAchievementType = builder.objectRef<{
progress: t.exposeInt("progress", { nullable: true }), progress: t.exposeInt("progress", { nullable: true }),
required_count: t.exposeInt("required_count"), required_count: t.exposeInt("required_count"),
}), }),
}); });
export const RecentPointType = builder.objectRef<{ export const RecentPointType = builder
.objectRef<{
action: string; action: string;
points: number; points: number;
date_created: Date; date_created: Date;
recording_id: string | null; recording_id: string | null;
}>("RecentPoint").implement({ }>("RecentPoint")
.implement({
fields: (t) => ({ fields: (t) => ({
action: t.exposeString("action"), action: t.exposeString("action"),
points: t.exposeInt("points"), points: t.exposeInt("points"),
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
recording_id: t.exposeString("recording_id", { nullable: true }), recording_id: t.exposeString("recording_id", { nullable: true }),
}), }),
}); });
// Analytics types // Analytics types
export const AnalyticsType = builder.objectRef<{ export const AnalyticsType = builder
.objectRef<{
total_videos: number; total_videos: number;
total_likes: number; total_likes: number;
total_plays: number; total_plays: number;
@@ -478,7 +539,8 @@ export const AnalyticsType = builder.objectRef<{
completion_rate: number; completion_rate: number;
avg_watch_time: number; avg_watch_time: number;
}[]; }[];
}>("Analytics").implement({ }>("Analytics")
.implement({
fields: (t) => ({ fields: (t) => ({
total_videos: t.exposeInt("total_videos"), total_videos: t.exposeInt("total_videos"),
total_likes: t.exposeInt("total_likes"), total_likes: t.exposeInt("total_likes"),
@@ -487,9 +549,10 @@ export const AnalyticsType = builder.objectRef<{
likes_by_date: t.expose("likes_by_date", { type: "JSON" }), likes_by_date: t.expose("likes_by_date", { type: "JSON" }),
videos: t.expose("videos", { type: [VideoAnalyticsType] }), videos: t.expose("videos", { type: [VideoAnalyticsType] }),
}), }),
}); });
export const VideoAnalyticsType = builder.objectRef<{ export const VideoAnalyticsType = builder
.objectRef<{
id: string; id: string;
title: string; title: string;
slug: string; slug: string;
@@ -499,7 +562,8 @@ export const VideoAnalyticsType = builder.objectRef<{
completed_plays: number; completed_plays: number;
completion_rate: number; completion_rate: number;
avg_watch_time: number; avg_watch_time: number;
}>("VideoAnalytics").implement({ }>("VideoAnalytics")
.implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
title: t.exposeString("title"), title: t.exposeString("title"),
@@ -511,35 +575,41 @@ export const VideoAnalyticsType = builder.objectRef<{
completion_rate: t.exposeFloat("completion_rate"), completion_rate: t.exposeFloat("completion_rate"),
avg_watch_time: t.exposeInt("avg_watch_time"), avg_watch_time: t.exposeInt("avg_watch_time"),
}), }),
}); });
// Response types // Response types
export const VideoLikeResponseType = builder.objectRef<{ export const VideoLikeResponseType = builder
.objectRef<{
liked: boolean; liked: boolean;
likes_count: number; likes_count: number;
}>("VideoLikeResponse").implement({ }>("VideoLikeResponse")
.implement({
fields: (t) => ({ fields: (t) => ({
liked: t.exposeBoolean("liked"), liked: t.exposeBoolean("liked"),
likes_count: t.exposeInt("likes_count"), likes_count: t.exposeInt("likes_count"),
}), }),
}); });
export const VideoPlayResponseType = builder.objectRef<{ export const VideoPlayResponseType = builder
.objectRef<{
success: boolean; success: boolean;
play_id: string; play_id: string;
plays_count: number; plays_count: number;
}>("VideoPlayResponse").implement({ }>("VideoPlayResponse")
.implement({
fields: (t) => ({ fields: (t) => ({
success: t.exposeBoolean("success"), success: t.exposeBoolean("success"),
play_id: t.exposeString("play_id"), play_id: t.exposeString("play_id"),
plays_count: t.exposeInt("plays_count"), plays_count: t.exposeInt("plays_count"),
}), }),
}); });
export const VideoLikeStatusType = builder.objectRef<{ export const VideoLikeStatusType = builder
.objectRef<{
liked: boolean; liked: boolean;
}>("VideoLikeStatus").implement({ }>("VideoLikeStatus")
.implement({
fields: (t) => ({ fields: (t) => ({
liked: t.exposeBoolean("liked"), liked: t.exposeBoolean("liked"),
}), }),
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,11 +77,11 @@
@keyframes pulseGlow { @keyframes pulseGlow {
0%, 0%,
100% { 100% {
boxShadow: 0 0 20px rgba(183, 0, 217, 0.3); boxshadow: 0 0 20px rgba(183, 0, 217, 0.3);
} }
50% { 50% {
boxShadow: 0 0 40px rgba(183, 0, 217, 0.6); boxshadow: 0 0 40px rgba(183, 0, 217, 0.6);
} }
} }
} }

View File

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

View File

@@ -19,9 +19,9 @@ export const handle: Handle = async ({ event, resolve }) => {
logger.request(request.method, url.pathname, { logger.request(request.method, url.pathname, {
requestId, requestId,
context: { context: {
userAgent: request.headers.get('user-agent')?.substring(0, 100), userAgent: request.headers.get("user-agent")?.substring(0, 100),
referer: request.headers.get('referer'), referer: request.headers.get("referer"),
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'), ip: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"),
}, },
}); });
@@ -33,7 +33,7 @@ export const handle: Handle = async ({ event, resolve }) => {
locals.authStatus = await isAuthenticated(token); locals.authStatus = await isAuthenticated(token);
if (locals.authStatus.authenticated) { if (locals.authStatus.authenticated) {
logger.auth('Token validated', true, { logger.auth("Token validated", true, {
requestId, requestId,
userId: locals.authStatus.user?.id, userId: locals.authStatus.user?.id,
context: { context: {
@@ -42,17 +42,17 @@ export const handle: Handle = async ({ event, resolve }) => {
}, },
}); });
} else { } else {
logger.auth('Token invalid', false, { requestId }); logger.auth("Token invalid", false, { requestId });
} }
} catch (error) { } catch (error) {
logger.error('Authentication check failed', { logger.error("Authentication check failed", {
requestId, requestId,
error: error instanceof Error ? error : new Error(String(error)), error: error instanceof Error ? error : new Error(String(error)),
}); });
locals.authStatus = { authenticated: false }; locals.authStatus = { authenticated: false };
} }
} else { } else {
logger.debug('No session token found', { requestId }); logger.debug("No session token found", { requestId });
locals.authStatus = { authenticated: false }; locals.authStatus = { authenticated: false };
} }
@@ -66,7 +66,7 @@ export const handle: Handle = async ({ event, resolve }) => {
}); });
} catch (error) { } catch (error) {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
logger.error('Request handler error', { logger.error("Request handler error", {
requestId, requestId,
method: request.method, method: request.method,
path: url.pathname, path: url.pathname,
@@ -82,12 +82,12 @@ export const handle: Handle = async ({ event, resolve }) => {
requestId, requestId,
userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined, userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined,
context: { context: {
cached: response.headers.get('x-sveltekit-page') === 'true', cached: response.headers.get("x-sveltekit-page") === "true",
}, },
}); });
// Add request ID to response headers (useful for debugging) // Add request ID to response headers (useful for debugging)
response.headers.set('x-request-id', requestId); response.headers.set("x-request-id", requestId);
return response; return response;
}; };

View File

@@ -1,31 +1,31 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "$lib/components/ui/dialog"; } from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import { onMount } from "svelte"; import { onMount } from "svelte";
const AGE_VERIFICATION_KEY = "age-verified"; const AGE_VERIFICATION_KEY = "age-verified";
let isOpen = true; let isOpen = true;
function handleAgeConfirmation() { function handleAgeConfirmation() {
localStorage.setItem(AGE_VERIFICATION_KEY, "true"); localStorage.setItem(AGE_VERIFICATION_KEY, "true");
isOpen = false; isOpen = false;
} }
onMount(() => { onMount(() => {
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY); const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
if (storedVerification === "true") { if (storedVerification === "true") {
isOpen = false; isOpen = false;
} }
}); });
</script> </script>
<Dialog bind:open={isOpen}> <Dialog bind:open={isOpen}>
@@ -40,9 +40,7 @@ onMount(() => {
<div <div
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center" class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
> >
<span class="text-primary-foreground text-sm" <span class="text-primary-foreground text-sm">{$_("age_verification_dialog.age")}</span>
>{$_("age_verification_dialog.age")}</span
>
</div> </div>
<div class=""> <div class="">
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground" <DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
@@ -63,12 +61,7 @@ onMount(() => {
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm"> <Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
{$_("age_verification_dialog.exit")} {$_("age_verification_dialog.exit")}
</Button> </Button>
<Button <Button variant="default" size="sm" onclick={handleAgeConfirmation} class="cursor-pointer">
variant="default"
size="sm"
onclick={handleAgeConfirmation}
class="cursor-pointer"
>
<span class="icon-[ri--check-line]"></span> <span class="icon-[ri--check-line]"></span>
{$_("age_verification_dialog.confirm")} {$_("age_verification_dialog.confirm")}
</Button> </Button>

View File

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

View File

@@ -1,46 +1,46 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import { Slider } from "$lib/components/ui/slider"; import { Slider } from "$lib/components/ui/slider";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card"; import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import type { BluetoothDevice } from "$lib/types"; import type { BluetoothDevice } from "$lib/types";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
interface Props { interface Props {
device: BluetoothDevice; device: BluetoothDevice;
onChange: (scalarIndex: number, val: number) => void; onChange: (scalarIndex: number, val: number) => void;
onStop: () => void; onStop: () => void;
} }
let { device, onChange, onStop }: Props = $props(); let { device, onChange, onStop }: Props = $props();
function getBatteryColor(level: number) { function getBatteryColor(level: number) {
if (!device.hasBattery) { if (!device.hasBattery) {
return "text-gray-400"; return "text-gray-400";
} }
if (level > 60) return "text-green-400"; if (level > 60) return "text-green-400";
if (level > 30) return "text-yellow-400"; if (level > 30) return "text-yellow-400";
return "text-red-400"; return "text-red-400";
} }
function getBatteryBgColor(level: number) { function getBatteryBgColor(level: number) {
if (!device.hasBattery) { if (!device.hasBattery) {
return "bg-gray-400/20"; return "bg-gray-400/20";
} }
if (level > 60) return "bg-green-400/20"; if (level > 60) return "bg-green-400/20";
if (level > 30) return "bg-yellow-400/20"; if (level > 30) return "bg-yellow-400/20";
return "bg-red-400/20"; return "bg-red-400/20";
} }
function getScalarAnimations() { function getScalarAnimations() {
return device.actuators return device.actuators
.filter((a) => a.value > 0) .filter((a) => a.value > 0)
.map((a) => `animate-${a.outputType.toLowerCase()}`); .map((a) => `animate-${a.outputType.toLowerCase()}`);
} }
function isActive() { function isActive() {
return device.actuators.some((a) => a.value > 0); return device.actuators.some((a) => a.value > 0);
} }
</script> </script>
<Card <Card
@@ -52,12 +52,12 @@ function isActive() {
<div <div
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0" class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
> >
<span class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}></span> <span
class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}
></span>
</div> </div>
<div> <div>
<h3 <h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
class="font-semibold text-card-foreground group-hover:text-primary transition-colors"
>
{device.name} {device.name}
</h3> </h3>
<!-- <p class="text-sm text-muted-foreground"> <!-- <p class="text-sm text-muted-foreground">
@@ -65,27 +65,20 @@ function isActive() {
</p> --> </p> -->
</div> </div>
</div> </div>
<button class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`} onclick={() => isActive() && onStop()}> <button
class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`}
onclick={() => isActive() && onStop()}
>
<div class="relative"> <div class="relative">
<div <div class="w-2 h-2 rounded-full {isActive() ? 'bg-green-400' : 'bg-red-400'}"></div>
class="w-2 h-2 rounded-full {isActive()
? 'bg-green-400'
: 'bg-red-400'}"
></div>
{#if isActive()} {#if isActive()}
<div <div
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75" class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
></div> ></div>
{/if} {/if}
</div> </div>
<span <span class="text-xs font-medium {isActive() ? 'text-green-400' : 'text-red-400'}">
class="text-xs font-medium {isActive() {isActive() ? $_("device_card.active") : $_("device_card.paused")}
? 'text-green-400'
: 'text-red-400'}"
>
{isActive()
? $_("device_card.active")
: $_("device_card.paused")}
</span> </span>
</button> </button>
</div> </div>
@@ -108,9 +101,7 @@ function isActive() {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <span
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor( class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(device.batteryLevel)}"
device.batteryLevel,
)}"
></span> ></span>
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span> <span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
</div> </div>
@@ -142,9 +133,7 @@ function isActive() {
{#each device.actuators as actuator, idx (idx)} {#each device.actuators as actuator, idx (idx)}
<div class="space-y-2"> <div class="space-y-2">
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`} <Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
>{$_( >{$_(`device_card.actuator_types.${actuator.outputType.toLowerCase()}`)}</Label
`device_card.actuator_types.${actuator.outputType.toLowerCase()}`,
)}</Label
> >
<Slider <Slider
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`} id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import Logo from "../logo/logo.svelte"; import Logo from "../logo/logo.svelte";
</script> </script>
<footer <footer

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,10 +18,7 @@
<svelte:head> <svelte:head>
<title>{$_("head.title", { values: { title } })}</title> <title>{$_("head.title", { values: { title } })}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
<meta <meta property="og:title" content={$_("head.title", { values: { title } })} />
property="og:title"
content={$_("head.title", { values: { title } })}
/>
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<meta property="og:image" content={image} /> <meta property="og:image" content={image} />
</svelte:head> </svelte:head>

View File

@@ -1,26 +1,26 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card"; import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import type { Recording } from "$lib/types"; import type { Recording } from "$lib/types";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
interface Props { interface Props {
recording: Recording; recording: Recording;
onPlay?: (id: string) => void; onPlay?: (id: string) => void;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
} }
let { recording, onPlay, onDelete }: Props = $props(); let { recording, onPlay, onDelete }: Props = $props();
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000); const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60); const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60; const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`; return `${minutes}:${seconds.toString().padStart(2, "0")}`;
} }
function getStatusColor(status: string): string { function getStatusColor(status: string): string {
switch (status) { switch (status) {
case "published": case "published":
return "text-green-400 bg-green-400/20"; return "text-green-400 bg-green-400/20";
@@ -31,7 +31,7 @@ function getStatusColor(status: string): string {
default: default:
return "text-gray-400 bg-gray-400/20"; return "text-gray-400 bg-gray-400/20";
} }
} }
</script> </script>
<Card <Card
@@ -41,17 +41,10 @@ function getStatusColor(status: string): string {
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<h3 <h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
class="font-semibold text-card-foreground group-hover:text-primary transition-colors"
>
{recording.title} {recording.title}
</h3> </h3>
<span <span class={cn("text-xs px-2 py-0.5 rounded-full", getStatusColor(recording.status))}>
class={cn(
"text-xs px-2 py-0.5 rounded-full",
getStatusColor(recording.status),
)}
>
{$_(`recording_card.status_${recording.status}`)} {$_(`recording_card.status_${recording.status}`)}
</span> </span>
</div> </div>
@@ -67,25 +60,17 @@ function getStatusColor(status: string): string {
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<!-- Stats Grid --> <!-- Stats Grid -->
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<div <div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
>
<span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span> <span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span>
<span class="text-xs text-muted-foreground" <span class="text-xs text-muted-foreground">{$_("recording_card.duration")}</span>
>{$_("recording_card.duration")}</span
>
<span class="font-medium text-sm">{formatDuration(recording.duration)}</span> <span class="font-medium text-sm">{formatDuration(recording.duration)}</span>
</div> </div>
<div <div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
>
<span class="icon-[ri--pulse-line] w-4 h-4 text-accent mb-1"></span> <span class="icon-[ri--pulse-line] w-4 h-4 text-accent mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span> <span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span>
<span class="font-medium text-sm">{recording.events.length}</span> <span class="font-medium text-sm">{recording.events.length}</span>
</div> </div>
<div <div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
>
<span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span> <span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span> <span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
<span class="font-medium text-sm">{recording.device_info.length}</span> <span class="font-medium text-sm">{recording.device_info.length}</span>
@@ -105,9 +90,7 @@ function getStatusColor(status: string): string {
{/each} {/each}
{#if recording.device_info.length > 2} {#if recording.device_info.length > 2}
<div class="text-xs text-muted-foreground/60 px-2"> <div class="text-xs text-muted-foreground/60 px-2">
+{recording.device_info.length - 2} more device{recording.device_info.length - +{recording.device_info.length - 2} more device{recording.device_info.length - 2 > 1
2 >
1
? "s" ? "s"
: ""} : ""}
</div> </div>

View File

@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
onclick: () => void; onclick: () => void;
icon: string; icon: string;
label: string; label: string;
} }
let { onclick, icon, label }: Props = $props(); let { onclick, icon, label }: Props = $props();
</script> </script>
<button <button

View File

@@ -1,52 +1,52 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import ShareButton from "./share-button.svelte"; import ShareButton from "./share-button.svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import type { ShareContent } from "$lib/types"; import type { ShareContent } from "$lib/types";
interface Props { interface Props {
content: ShareContent; content: ShareContent;
} }
let { content }: Props = $props(); let { content }: Props = $props();
// Share handlers // Share handlers
const shareToX = () => { const shareToX = () => {
const text = `${content.title} - ${content.description}`; const text = `${content.title} - ${content.description}`;
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(content.url)}`; const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(content.url)}`;
window.open(url, "_blank", "width=600,height=400"); window.open(url, "_blank", "width=600,height=400");
toast.success($_("sharing_popup.success.x")); toast.success($_("sharing_popup.success.x"));
}; };
const shareToFacebook = () => { const shareToFacebook = () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(content.url)}&quote=${encodeURIComponent(content.title)}`; const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(content.url)}&quote=${encodeURIComponent(content.title)}`;
window.open(url, "_blank", "width=600,height=400"); window.open(url, "_blank", "width=600,height=400");
toast.success($_("sharing_popup.success.facebook")); toast.success($_("sharing_popup.success.facebook"));
}; };
const shareViaEmail = () => { const shareViaEmail = () => {
const subject = encodeURIComponent(content.title); const subject = encodeURIComponent(content.title);
const body = encodeURIComponent(`${content.description}\n\n${content.url}`); const body = encodeURIComponent(`${content.description}\n\n${content.url}`);
const url = `mailto:?subject=${subject}&body=${body}`; const url = `mailto:?subject=${subject}&body=${body}`;
window.location.href = url; window.location.href = url;
toast.success($_("sharing_popup.success.email")); toast.success($_("sharing_popup.success.email"));
}; };
const shareToWhatsApp = () => { const shareToWhatsApp = () => {
const text = `${content.title}\n\n${content.description}\n\n${content.url}`; const text = `${content.title}\n\n${content.description}\n\n${content.url}`;
const url = `https://wa.me/?text=${encodeURIComponent(text)}`; const url = `https://wa.me/?text=${encodeURIComponent(text)}`;
window.open(url, "_blank"); window.open(url, "_blank");
toast.success($_("sharing_popup.success.whatsapp")); toast.success($_("sharing_popup.success.whatsapp"));
}; };
const shareToTelegram = () => { const shareToTelegram = () => {
const text = `${content.title}\n\n${content.description}`; const text = `${content.title}\n\n${content.description}`;
const url = `https://t.me/share/url?url=${encodeURIComponent(content.url)}&text=${encodeURIComponent(text)}`; const url = `https://t.me/share/url?url=${encodeURIComponent(content.url)}&text=${encodeURIComponent(text)}`;
window.open(url, "_blank"); window.open(url, "_blank");
toast.success($_("sharing_popup.success.telegram")); toast.success($_("sharing_popup.success.telegram"));
}; };
const copyLink = async () => { const copyLink = async () => {
try { try {
await navigator.clipboard.writeText(content.url); await navigator.clipboard.writeText(content.url);
toast.success($_("sharing_popup.success.copy")); toast.success($_("sharing_popup.success.copy"));
@@ -60,7 +60,7 @@ const copyLink = async () => {
document.body.removeChild(textArea); document.body.removeChild(textArea);
toast.success($_("sharing_popup.success.copy")); toast.success($_("sharing_popup.success.copy"));
} }
}; };
</script> </script>
<div class="space-y-6"> <div class="space-y-6">

View File

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

View File

@@ -1,31 +1,31 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "$lib/components/ui/dialog"; } from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import ShareServices from "./share-services.svelte"; import ShareServices from "./share-services.svelte";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
interface ShareContent { interface ShareContent {
title: string; title: string;
description: string; description: string;
url: string; url: string;
type: "video" | "model" | "article" | "link"; type: "video" | "model" | "article" | "link";
} }
interface Props { interface Props {
open: boolean; open: boolean;
content: ShareContent; content: ShareContent;
children?: Snippet; children?: Snippet;
} }
let { open = $bindable(), content }: Props = $props(); let { open = $bindable(), content }: Props = $props();
</script> </script>
<Dialog bind:open> <Dialog bind:open>

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div

View File

@@ -1,7 +1,7 @@
<script lang="ts" module> <script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants"; import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({ export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: { variants: {
variant: { variant: {
@@ -13,9 +13,9 @@ export const alertVariants = tv({
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
}); });
export type AlertVariant = VariantProps<typeof alertVariants>["variant"]; export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script> </script>
<script lang="ts"> <script lang="ts">

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: AvatarPrimitive.FallbackProps = $props(); }: AvatarPrimitive.FallbackProps = $props();
</script> </script>
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: AvatarPrimitive.ImageProps = $props(); }: AvatarPrimitive.ImageProps = $props();
</script> </script>
<AvatarPrimitive.Image <AvatarPrimitive.Image

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
loadingStatus = $bindable("loading"), loadingStatus = $bindable("loading"),
class: className, class: className,
...restProps ...restProps
}: AvatarPrimitive.RootProps = $props(); }: AvatarPrimitive.RootProps = $props();
</script> </script>
<AvatarPrimitive.Root <AvatarPrimitive.Root

View File

@@ -1,25 +1,19 @@
<script lang="ts" module> <script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
import type { import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
HTMLAnchorAttributes, import { type VariantProps, tv } from "tailwind-variants";
HTMLButtonAttributes,
} from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({ export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: { variants: {
variant: { variant: {
default: default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: destructive:
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white", "bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
outline: outline:
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border", "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
@@ -33,12 +27,12 @@ export const buttonVariants = tv({
variant: "default", variant: "default",
size: "default", size: "default",
}, },
}); });
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"]; export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"]; export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> & export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & { WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ButtonSize; size?: ButtonSize;

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div

View File

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

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script> </script>
<p <p

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div

View File

@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui"; import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check"; import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus"; import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
checked = $bindable(false), checked = $bindable(false),
indeterminate = $bindable(false), indeterminate = $bindable(false),
class: className, class: className,
...restProps ...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props(); }: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script> </script>
<CheckboxPrimitive.Root <CheckboxPrimitive.Root

View File

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

View File

@@ -1,22 +1,22 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x"; import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import * as Dialog from "./index.js"; import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
portalProps, portalProps,
children, children,
showCloseButton = true, showCloseButton = true,
...restProps ...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & { }: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps; portalProps?: DialogPrimitive.PortalProps;
children: Snippet; children: Snippet;
showCloseButton?: boolean; showCloseButton?: boolean;
} = $props(); } = $props();
</script> </script>
<Dialog.Portal {...portalProps}> <Dialog.Portal {...portalProps}>

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: DialogPrimitive.DescriptionProps = $props(); }: DialogPrimitive.DescriptionProps = $props();
</script> </script>
<DialogPrimitive.Description <DialogPrimitive.Description

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div <div

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: DialogPrimitive.OverlayProps = $props(); }: DialogPrimitive.OverlayProps = $props();
</script> </script>
<DialogPrimitive.Overlay <DialogPrimitive.Overlay

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: DialogPrimitive.TitleProps = $props(); }: DialogPrimitive.TitleProps = $props();
</script> </script>
<DialogPrimitive.Title <DialogPrimitive.Title

View File

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

View File

@@ -3,13 +3,13 @@
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils/utils"; import { cn } from "$lib/utils/utils";
import UploadIcon from "@lucide/svelte/icons/upload"; import UploadIcon from "@lucide/svelte/icons/upload";
import { displaySize } from "."; import { displaySize } from ".";
import { useId } from "bits-ui"; import { useId } from "bits-ui";
import type { FileDropZoneProps, FileRejectedReason } from "./types"; import type { FileDropZoneProps, FileRejectedReason } from "./types";
let { let {
id = useId(), id = useId(),
children, children,
maxFiles, maxFiles,
@@ -21,21 +21,21 @@ let {
accept, accept,
class: className, class: className,
...rest ...rest
}: FileDropZoneProps = $props(); }: FileDropZoneProps = $props();
if (maxFiles !== undefined && fileCount === undefined) { if (maxFiles !== undefined && fileCount === undefined) {
console.warn( console.warn(
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt", "Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
); );
} }
let uploading = $state(false); let uploading = $state(false);
const drop = async ( const drop = async (
e: DragEvent & { e: DragEvent & {
currentTarget: EventTarget & HTMLLabelElement; currentTarget: EventTarget & HTMLLabelElement;
}, },
) => { ) => {
if (disabled || !canUploadFiles) return; if (disabled || !canUploadFiles) return;
e.preventDefault(); e.preventDefault();
@@ -43,13 +43,13 @@ const drop = async (
const droppedFiles = Array.from(e.dataTransfer?.files ?? []); const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
await upload(droppedFiles); await upload(droppedFiles);
}; };
const change = async ( const change = async (
e: Event & { e: Event & {
currentTarget: EventTarget & HTMLInputElement; currentTarget: EventTarget & HTMLInputElement;
}, },
) => { ) => {
if (disabled) return; if (disabled) return;
const selectedFiles = e.currentTarget.files; const selectedFiles = e.currentTarget.files;
@@ -60,17 +60,12 @@ const change = async (
// this if a file fails and we upload the same file again we still get feedback // this if a file fails and we upload the same file again we still get feedback
(e.target as HTMLInputElement).value = ""; (e.target as HTMLInputElement).value = "";
}; };
const shouldAcceptFile = ( const shouldAcceptFile = (file: File, fileNumber: number): FileRejectedReason | undefined => {
file: File, if (maxFileSize !== undefined && file.size > maxFileSize) return "Maximum file size exceeded";
fileNumber: number,
): FileRejectedReason | undefined => {
if (maxFileSize !== undefined && file.size > maxFileSize)
return "Maximum file size exceeded";
if (maxFiles !== undefined && fileNumber > maxFiles) if (maxFiles !== undefined && fileNumber > maxFiles) return "Maximum files uploaded";
return "Maximum files uploaded";
if (!accept) return undefined; if (!accept) return undefined;
@@ -97,9 +92,9 @@ const shouldAcceptFile = (
if (!isAcceptable) return "File type not allowed"; if (!isAcceptable) return "File type not allowed";
return undefined; return undefined;
}; };
const upload = async (uploadFiles: File[]) => { const upload = async (uploadFiles: File[]) => {
uploading = true; uploading = true;
const validFiles: File[] = []; const validFiles: File[] = [];
@@ -120,17 +115,13 @@ const upload = async (uploadFiles: File[]) => {
await onUpload(validFiles); await onUpload(validFiles);
uploading = false; uploading = false;
}; };
const canUploadFiles = $derived( const canUploadFiles = $derived(
!disabled && !disabled &&
!uploading && !uploading &&
!( !(maxFiles !== undefined && fileCount !== undefined && fileCount >= maxFiles),
maxFiles !== undefined && );
fileCount !== undefined &&
fileCount >= maxFiles
),
);
</script> </script>
<label <label

View File

@@ -1,28 +1,22 @@
<script lang="ts"> <script lang="ts">
import type { import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
HTMLInputAttributes, import { cn, type WithElementRef } from "$lib/utils.js";
HTMLInputTypeAttribute,
} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">; type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef< type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> & Omit<HTMLInputAttributes, "type"> &
( ({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
| { type: "file"; files?: FileList } >;
| { type?: InputType; files?: undefined }
)
>;
let { let {
ref = $bindable(null), ref = $bindable(null),
value = $bindable(), value = $bindable(),
type, type,
files = $bindable(), files = $bindable(),
class: className, class: className,
...restProps ...restProps
}: Props = $props(); }: Props = $props();
</script> </script>
{#if type === "file"} {#if type === "file"}

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Label as LabelPrimitive } from "bits-ui"; import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: LabelPrimitive.RootProps = $props(); }: LabelPrimitive.RootProps = $props();
</script> </script>
<LabelPrimitive.Root <LabelPrimitive.Root

View File

@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from "bits-ui";
import SelectScrollUpButton from "./select-scroll-up-button.svelte"; import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte"; import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
sideOffset = 4, sideOffset = 4,
portalProps, portalProps,
children, children,
...restProps ...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & { }: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: SelectPrimitive.PortalProps; portalProps?: SelectPrimitive.PortalProps;
} = $props(); } = $props();
</script> </script>
<SelectPrimitive.Portal {...portalProps}> <SelectPrimitive.Portal {...portalProps}>

View File

@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte"; import type { ComponentProps } from "svelte";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props(); }: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script> </script>
<SelectPrimitive.GroupHeading <SelectPrimitive.GroupHeading

View File

@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from "bits-ui";
// eslint-disable-next-line no-useless-assignment // eslint-disable-next-line no-useless-assignment
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
$props();
</script> </script>
<SelectPrimitive.Group data-slot="select-group" {...restProps} /> <SelectPrimitive.Group data-slot="select-group" {...restProps} />

View File

@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check"; import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
value, value,
label, label,
children: childrenProp, children: childrenProp,
...restProps ...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props(); }: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script> </script>
<SelectPrimitive.Item <SelectPrimitive.Item

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from "svelte/elements";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script> </script>
<div <div

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down"; import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props(); }: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script> </script>
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up"; import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props(); }: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script> </script>
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui"; import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js"; import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: SeparatorPrimitive.RootProps = $props(); }: SeparatorPrimitive.RootProps = $props();
</script> </script>
<Separator <Separator

View File

@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down"; import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
size = "default", size = "default",
...restProps ...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & { }: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default"; size?: "sm" | "default";
} = $props(); } = $props();
</script> </script>
<SelectPrimitive.Trigger <SelectPrimitive.Trigger

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui"; import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: SeparatorPrimitive.RootProps = $props(); }: SeparatorPrimitive.RootProps = $props();
</script> </script>
<SeparatorPrimitive.Root <SeparatorPrimitive.Root

View File

@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Slider as SliderPrimitive } from "bits-ui"; import { Slider as SliderPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
value = $bindable(), value = $bindable(),
orientation = "horizontal", orientation = "horizontal",
class: className, class: className,
...restProps ...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props(); }: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
</script> </script>
<!-- <!--

View File

@@ -1,11 +1,8 @@
<script lang="ts"> <script lang="ts">
import { import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
Toaster as Sonner, import { mode } from "mode-watcher";
type ToasterProps as SonnerProps,
} from "svelte-sonner";
import { mode } from "mode-watcher";
let { ...restProps }: SonnerProps = $props(); let { ...restProps }: SonnerProps = $props();
</script> </script>
<Sonner <Sonner

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui"; import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: TabsPrimitive.ContentProps = $props(); }: TabsPrimitive.ContentProps = $props();
</script> </script>
<TabsPrimitive.Content <TabsPrimitive.Content

View File

@@ -1,12 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui"; import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let { ref = $bindable(null), class: className, ...restProps }: TabsPrimitive.ListProps = $props();
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ListProps = $props();
</script> </script>
<TabsPrimitive.List <TabsPrimitive.List

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui"; import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
...restProps ...restProps
}: TabsPrimitive.TriggerProps = $props(); }: TabsPrimitive.TriggerProps = $props();
</script> </script>
<TabsPrimitive.Trigger <TabsPrimitive.Trigger

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui"; import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
value = $bindable(""), value = $bindable(""),
class: className, class: className,
...restProps ...restProps
}: TabsPrimitive.RootProps = $props(); }: TabsPrimitive.RootProps = $props();
</script> </script>
<TabsPrimitive.Root <TabsPrimitive.Root

View File

@@ -3,16 +3,16 @@
--> -->
<script lang="ts"> <script lang="ts">
import XIcon from "@lucide/svelte/icons/x"; import XIcon from "@lucide/svelte/icons/x";
type Props = { type Props = {
value: string; value: string;
disabled: boolean | null; disabled: boolean | null;
active: boolean; active: boolean;
onDelete: (value: string) => void; onDelete: (value: string) => void;
}; };
let { value, disabled, onDelete, active }: Props = $props(); let { value, disabled, onDelete, active }: Props = $props();
</script> </script>
<div <div

View File

@@ -3,12 +3,12 @@
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils/utils"; import { cn } from "$lib/utils/utils";
import type { TagsInputProps } from "./types"; import type { TagsInputProps } from "./types";
import TagsInputTag from "./tags-input-tag.svelte"; import TagsInputTag from "./tags-input-tag.svelte";
import { untrack } from "svelte"; import { untrack } from "svelte";
const defaultValidate: TagsInputProps["validate"] = (val, tags) => { const defaultValidate: TagsInputProps["validate"] = (val, tags) => {
const transformed = val.trim(); const transformed = val.trim();
// disallow empties // disallow empties
@@ -18,23 +18,23 @@ const defaultValidate: TagsInputProps["validate"] = (val, tags) => {
if (tags.find((t) => transformed === t)) return undefined; if (tags.find((t) => transformed === t)) return undefined;
return transformed; return transformed;
}; };
let { let {
value = $bindable([]), value = $bindable([]),
placeholder, placeholder,
class: className, class: className,
disabled = false, disabled = false,
validate = defaultValidate, validate = defaultValidate,
...rest ...rest
}: TagsInputProps = $props(); }: TagsInputProps = $props();
let inputValue = $state(""); let inputValue = $state("");
let tagIndex = $state<number>(); let tagIndex = $state<number>();
let invalid = $state(false); let invalid = $state(false);
let isComposing = $state(false); let isComposing = $state(false);
$effect(() => { $effect(() => {
// whenever input value changes reset invalid // whenever input value changes reset invalid
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
inputValue; inputValue;
@@ -42,9 +42,9 @@ $effect(() => {
untrack(() => { untrack(() => {
invalid = false; invalid = false;
}); });
}); });
const enter = () => { const enter = () => {
if (isComposing) return; if (isComposing) return;
const validated = validate(inputValue, value); const validated = validate(inputValue, value);
@@ -56,17 +56,17 @@ const enter = () => {
value = [...value, validated]; value = [...value, validated];
inputValue = ""; inputValue = "";
}; };
const compositionStart = () => { const compositionStart = () => {
isComposing = true; isComposing = true;
}; };
const compositionEnd = () => { const compositionEnd = () => {
isComposing = false; isComposing = false;
}; };
const keydown = (e: KeyboardEvent) => { const keydown = (e: KeyboardEvent) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
if (e.key === "Enter") { if (e.key === "Enter") {
@@ -79,8 +79,7 @@ const keydown = (e: KeyboardEvent) => {
return; return;
} }
const isAtBeginning = const isAtBeginning = target.selectionStart === 0 && target.selectionEnd === 0;
target.selectionStart === 0 && target.selectionEnd === 0;
let shouldResetIndex = true; let shouldResetIndex = true;
@@ -167,23 +166,23 @@ const keydown = (e: KeyboardEvent) => {
if (shouldResetIndex) { if (shouldResetIndex) {
tagIndex = undefined; tagIndex = undefined;
} }
}; };
const deleteValue = (val: string) => { const deleteValue = (val: string) => {
const index = value.findIndex((v) => val === v); const index = value.findIndex((v) => val === v);
if (index === -1) return; if (index === -1) return;
deleteIndex(index); deleteIndex(index);
}; };
const deleteIndex = (index: number) => { const deleteIndex = (index: number) => {
value = [...value.slice(0, index), ...value.slice(index + 1)]; value = [...value.slice(0, index), ...value.slice(index + 1)];
}; };
const blur = () => { const blur = () => {
tagIndex = undefined; tagIndex = undefined;
}; };
</script> </script>
<div <div

View File

@@ -9,5 +9,4 @@ export type TagsInputPropsWithoutHTML = {
validate?: (val: string, tags: string[]) => string | undefined; validate?: (val: string, tags: string[]) => string | undefined;
}; };
export type TagsInputProps = TagsInputPropsWithoutHTML & export type TagsInputProps = TagsInputPropsWithoutHTML & Omit<HTMLInputAttributes, "value">;
Omit<HTMLInputAttributes, "value">;

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