style: apply prettier formatting to all files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
- develop
|
- develop
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- "v*.*.*"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
- develop
|
- develop
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- "v*.*.*"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|||||||
92
README.md
92
README.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||

|

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

|

|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
93
package.json
93
package.json
@@ -1,48 +1,49 @@
|
|||||||
{
|
{
|
||||||
"name": "sexy.pivoine.art",
|
"name": "sexy.pivoine.art",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"build:frontend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/frontend build",
|
"build:frontend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/frontend build",
|
||||||
"build:backend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/backend build",
|
"build:backend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/backend build",
|
||||||
"dev:data": "docker compose up -d postgres redis",
|
"dev:data": "docker compose up -d postgres redis",
|
||||||
"dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev",
|
"dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev",
|
||||||
"dev": "pnpm dev:data && pnpm dev:backend & pnpm --filter @sexy.pivoine.art/frontend dev",
|
"dev": "pnpm dev:data && pnpm dev:backend & pnpm --filter @sexy.pivoine.art/frontend dev",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"check": "pnpm -r --filter=!sexy.pivoine.art check"
|
"check": "pnpm -r --filter=!sexy.pivoine.art check"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Valknar",
|
"name": "Valknar",
|
||||||
"email": "valknar@pivoine.art"
|
"email": "valknar@pivoine.art"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"packageManager": "pnpm@10.19.0",
|
"packageManager": "pnpm@10.19.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"argon2",
|
"argon2",
|
||||||
"es5-ext",
|
"es5-ext",
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"svelte-preprocess",
|
"svelte-preprocess",
|
||||||
"wasm-pack"
|
"wasm-pack"
|
||||||
],
|
],
|
||||||
"ignoredBuiltDependencies": [
|
"ignoredBuiltDependencies": [
|
||||||
"@tailwindcss/oxide",
|
"@tailwindcss/oxide",
|
||||||
"node-sass"
|
"node-sass"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.15.0",
|
"eslint-plugin-svelte": "^3.15.0",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript-eslint": "^8.56.1"
|
"prettier-plugin-svelte": "^3.5.1",
|
||||||
}
|
"typescript-eslint": "^8.56.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import { GraphQLError } from "graphql";
|
import { GraphQLError } from "graphql";
|
||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
import { VideoType, VideoLikeResponseType, VideoPlayResponseType, VideoLikeStatusType } from "../types/index";
|
import {
|
||||||
import { videos, video_models, video_likes, video_plays, users, files } from "../../db/schema/index";
|
VideoType,
|
||||||
|
VideoLikeResponseType,
|
||||||
|
VideoPlayResponseType,
|
||||||
|
VideoLikeStatusType,
|
||||||
|
} from "../types/index";
|
||||||
|
import {
|
||||||
|
videos,
|
||||||
|
video_models,
|
||||||
|
video_likes,
|
||||||
|
video_plays,
|
||||||
|
users,
|
||||||
|
files,
|
||||||
|
} from "../../db/schema/index";
|
||||||
import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
|
import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
|
||||||
|
|
||||||
async function enrichVideo(db: any, video: any) {
|
async function enrichVideo(db: any, video: any) {
|
||||||
@@ -25,8 +37,14 @@ async function enrichVideo(db: any, video: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Count likes
|
// Count likes
|
||||||
const likesCount = await db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, video.id));
|
const likesCount = await db
|
||||||
const playsCount = await db.select({ count: count() }).from(video_plays).where(eq(video_plays.video_id, video.id));
|
.select({ count: count() })
|
||||||
|
.from(video_likes)
|
||||||
|
.where(eq(video_likes.video_id, video.id));
|
||||||
|
const playsCount = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(video_plays)
|
||||||
|
.where(eq(video_plays.video_id, video.id));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...video,
|
...video,
|
||||||
@@ -63,10 +81,15 @@ builder.queryField("videos", (t) =>
|
|||||||
query = ctx.db
|
query = ctx.db
|
||||||
.select({ v: videos })
|
.select({ v: videos })
|
||||||
.from(videos)
|
.from(videos)
|
||||||
.where(and(
|
.where(
|
||||||
lte(videos.upload_date, new Date()),
|
and(
|
||||||
inArray(videos.id, videoIds.map((v: any) => v.video_id)),
|
lte(videos.upload_date, new Date()),
|
||||||
))
|
inArray(
|
||||||
|
videos.id,
|
||||||
|
videoIds.map((v: any) => v.video_id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.orderBy(desc(videos.upload_date));
|
.orderBy(desc(videos.upload_date));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +97,7 @@ builder.queryField("videos", (t) =>
|
|||||||
query = ctx.db
|
query = ctx.db
|
||||||
.select({ v: videos })
|
.select({ v: videos })
|
||||||
.from(videos)
|
.from(videos)
|
||||||
.where(and(
|
.where(and(lte(videos.upload_date, new Date()), eq(videos.featured, args.featured)))
|
||||||
lte(videos.upload_date, new Date()),
|
|
||||||
eq(videos.featured, args.featured),
|
|
||||||
))
|
|
||||||
.orderBy(desc(videos.upload_date));
|
.orderBy(desc(videos.upload_date));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +143,9 @@ builder.queryField("videoLikeStatus", (t) =>
|
|||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.select()
|
.select()
|
||||||
.from(video_likes)
|
.from(video_likes)
|
||||||
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
|
.where(
|
||||||
|
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return { liked: existing.length > 0 };
|
return { liked: existing.length > 0 };
|
||||||
},
|
},
|
||||||
@@ -142,7 +164,9 @@ builder.mutationField("likeVideo", (t) =>
|
|||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.select()
|
.select()
|
||||||
.from(video_likes)
|
.from(video_likes)
|
||||||
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
|
.where(
|
||||||
|
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing.length > 0) throw new GraphQLError("Already liked");
|
if (existing.length > 0) throw new GraphQLError("Already liked");
|
||||||
@@ -154,10 +178,22 @@ builder.mutationField("likeVideo", (t) =>
|
|||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(videos)
|
.update(videos)
|
||||||
.set({ likes_count: (await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number + 1 || 1 })
|
.set({
|
||||||
|
likes_count:
|
||||||
|
((
|
||||||
|
await ctx.db
|
||||||
|
.select({ c: videos.likes_count })
|
||||||
|
.from(videos)
|
||||||
|
.where(eq(videos.id, args.videoId))
|
||||||
|
.limit(1)
|
||||||
|
)[0]?.c as number) + 1 || 1,
|
||||||
|
})
|
||||||
.where(eq(videos.id, args.videoId));
|
.where(eq(videos.id, args.videoId));
|
||||||
|
|
||||||
const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId));
|
const likesCount = await ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(video_likes)
|
||||||
|
.where(eq(video_likes.video_id, args.videoId));
|
||||||
return { liked: true, likes_count: likesCount[0]?.count || 1 };
|
return { liked: true, likes_count: likesCount[0]?.count || 1 };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -175,21 +211,39 @@ builder.mutationField("unlikeVideo", (t) =>
|
|||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.select()
|
.select()
|
||||||
.from(video_likes)
|
.from(video_likes)
|
||||||
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
|
.where(
|
||||||
|
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing.length === 0) throw new GraphQLError("Not liked");
|
if (existing.length === 0) throw new GraphQLError("Not liked");
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.delete(video_likes)
|
.delete(video_likes)
|
||||||
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)));
|
.where(
|
||||||
|
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
|
||||||
|
);
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(videos)
|
.update(videos)
|
||||||
.set({ likes_count: Math.max(((await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number || 1) - 1, 0) })
|
.set({
|
||||||
|
likes_count: Math.max(
|
||||||
|
(((
|
||||||
|
await ctx.db
|
||||||
|
.select({ c: videos.likes_count })
|
||||||
|
.from(videos)
|
||||||
|
.where(eq(videos.id, args.videoId))
|
||||||
|
.limit(1)
|
||||||
|
)[0]?.c as number) || 1) - 1,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
})
|
||||||
.where(eq(videos.id, args.videoId));
|
.where(eq(videos.id, args.videoId));
|
||||||
|
|
||||||
const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId));
|
const likesCount = await ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(video_likes)
|
||||||
|
.where(eq(video_likes.video_id, args.videoId));
|
||||||
return { liked: false, likes_count: likesCount[0]?.count || 0 };
|
return { liked: false, likes_count: likesCount[0]?.count || 0 };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -203,13 +257,19 @@ builder.mutationField("recordVideoPlay", (t) =>
|
|||||||
sessionId: t.arg.string(),
|
sessionId: t.arg.string(),
|
||||||
},
|
},
|
||||||
resolve: async (_root, args, ctx) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
const play = await ctx.db.insert(video_plays).values({
|
const play = await ctx.db
|
||||||
video_id: args.videoId,
|
.insert(video_plays)
|
||||||
user_id: ctx.currentUser?.id || null,
|
.values({
|
||||||
session_id: args.sessionId || null,
|
video_id: args.videoId,
|
||||||
}).returning({ id: video_plays.id });
|
user_id: ctx.currentUser?.id || null,
|
||||||
|
session_id: args.sessionId || null,
|
||||||
|
})
|
||||||
|
.returning({ id: video_plays.id });
|
||||||
|
|
||||||
const playsCount = await ctx.db.select({ count: count() }).from(video_plays).where(eq(video_plays.video_id, args.videoId));
|
const playsCount = await ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(video_plays)
|
||||||
|
.where(eq(video_plays.video_id, args.videoId));
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(videos)
|
.update(videos)
|
||||||
@@ -237,7 +297,11 @@ builder.mutationField("updateVideoPlay", (t) =>
|
|||||||
resolve: async (_root, args, ctx) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(video_plays)
|
.update(video_plays)
|
||||||
.set({ duration_watched: args.durationWatched, completed: args.completed, date_updated: new Date() })
|
.set({
|
||||||
|
duration_watched: args.durationWatched,
|
||||||
|
completed: args.completed,
|
||||||
|
date_updated: new Date(),
|
||||||
|
})
|
||||||
.where(eq(video_plays.id, args.playId));
|
.where(eq(video_plays.id, args.playId));
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -262,13 +326,26 @@ builder.queryField("analytics", (t) =>
|
|||||||
.where(eq(video_models.user_id, userId));
|
.where(eq(video_models.user_id, userId));
|
||||||
|
|
||||||
if (modelVideoIds.length === 0) {
|
if (modelVideoIds.length === 0) {
|
||||||
return { total_videos: 0, total_likes: 0, total_plays: 0, plays_by_date: {}, likes_by_date: {}, videos: [] };
|
return {
|
||||||
|
total_videos: 0,
|
||||||
|
total_likes: 0,
|
||||||
|
total_plays: 0,
|
||||||
|
plays_by_date: {},
|
||||||
|
likes_by_date: {},
|
||||||
|
videos: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoIds = modelVideoIds.map((v: any) => v.video_id);
|
const videoIds = modelVideoIds.map((v: any) => v.video_id);
|
||||||
const videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds));
|
const videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds));
|
||||||
const plays = await ctx.db.select().from(video_plays).where(inArray(video_plays.video_id, videoIds));
|
const plays = await ctx.db
|
||||||
const likes = await ctx.db.select().from(video_likes).where(inArray(video_likes.video_id, videoIds));
|
.select()
|
||||||
|
.from(video_plays)
|
||||||
|
.where(inArray(video_plays.video_id, videoIds));
|
||||||
|
const likes = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(video_likes)
|
||||||
|
.where(inArray(video_likes.video_id, videoIds));
|
||||||
|
|
||||||
const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0);
|
const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0);
|
||||||
const totalPlays = videoList.reduce((sum, v) => sum + (v.plays_count || 0), 0);
|
const totalPlays = videoList.reduce((sum, v) => sum + (v.plays_count || 0), 0);
|
||||||
@@ -290,9 +367,10 @@ builder.queryField("analytics", (t) =>
|
|||||||
const videoAnalytics = videoList.map((video) => {
|
const videoAnalytics = videoList.map((video) => {
|
||||||
const vPlays = plays.filter((p) => p.video_id === video.id);
|
const vPlays = plays.filter((p) => p.video_id === video.id);
|
||||||
const completedPlays = vPlays.filter((p) => p.completed).length;
|
const completedPlays = vPlays.filter((p) => p.completed).length;
|
||||||
const avgWatchTime = vPlays.length > 0
|
const avgWatchTime =
|
||||||
? vPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) / vPlays.length
|
vPlays.length > 0
|
||||||
: 0;
|
? vPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) / vPlays.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: video.id,
|
id: video.id,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ const transporter = nodemailer.createTransport({
|
|||||||
host: process.env.SMTP_HOST || "localhost",
|
host: process.env.SMTP_HOST || "localhost",
|
||||||
port: parseInt(process.env.SMTP_PORT || "587"),
|
port: parseInt(process.env.SMTP_PORT || "587"),
|
||||||
secure: process.env.SMTP_SECURE === "true",
|
secure: process.env.SMTP_SECURE === "true",
|
||||||
auth: process.env.SMTP_USER ? {
|
auth: process.env.SMTP_USER
|
||||||
user: process.env.SMTP_USER,
|
? {
|
||||||
pass: process.env.SMTP_PASS,
|
user: process.env.SMTP_USER,
|
||||||
} : undefined,
|
pass: process.env.SMTP_PASS,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const FROM = process.env.EMAIL_FROM || "noreply@sexy.pivoine.art";
|
const FROM = process.env.EMAIL_FROM || "noreply@sexy.pivoine.art";
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "@sexy.pivoine.art/buttplug",
|
"name": "@sexy.pivoine.art/buttplug",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target bundler --release"
|
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target bundler --release"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eventemitter3": "^5.0.4",
|
"eventemitter3": "^5.0.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-wasm": "3.5.0",
|
"vite-plugin-wasm": "3.5.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"wasm-pack": "^0.14.0"
|
"wasm-pack": "^0.14.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
4
packages/buttplug/src/core/index.d.ts
vendored
4
packages/buttplug/src/core/index.d.ts
vendored
@@ -1,4 +1,4 @@
|
|||||||
declare module "*.json" {
|
declare module "*.json" {
|
||||||
const content: string;
|
const content: string;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,27 +6,24 @@
|
|||||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ButtplugMessage } from './core/Messages';
|
import { ButtplugMessage } from "./core/Messages";
|
||||||
import { IButtplugClientConnector } from './client/IButtplugClientConnector';
|
import { IButtplugClientConnector } from "./client/IButtplugClientConnector";
|
||||||
import { EventEmitter } from 'eventemitter3';
|
import { EventEmitter } from "eventemitter3";
|
||||||
|
|
||||||
export * from './client/ButtplugClient';
|
export * from "./client/ButtplugClient";
|
||||||
export * from './client/ButtplugClientDevice';
|
export * from "./client/ButtplugClientDevice";
|
||||||
export * from './client/ButtplugBrowserWebsocketClientConnector';
|
export * from "./client/ButtplugBrowserWebsocketClientConnector";
|
||||||
export * from './client/ButtplugNodeWebsocketClientConnector';
|
export * from "./client/ButtplugNodeWebsocketClientConnector";
|
||||||
export * from './client/ButtplugClientConnectorException';
|
export * from "./client/ButtplugClientConnectorException";
|
||||||
export * from './utils/ButtplugMessageSorter';
|
export * from "./utils/ButtplugMessageSorter";
|
||||||
export * from './client/ButtplugClientDeviceCommand';
|
export * from "./client/ButtplugClientDeviceCommand";
|
||||||
export * from './client/ButtplugClientDeviceFeature';
|
export * from "./client/ButtplugClientDeviceFeature";
|
||||||
export * from './client/IButtplugClientConnector';
|
export * from "./client/IButtplugClientConnector";
|
||||||
export * from './core/Messages';
|
export * from "./core/Messages";
|
||||||
export * from './core/Logging';
|
export * from "./core/Logging";
|
||||||
export * from './core/Exceptions';
|
export * from "./core/Exceptions";
|
||||||
|
|
||||||
export class ButtplugWasmClientConnector
|
export class ButtplugWasmClientConnector extends EventEmitter implements IButtplugClientConnector {
|
||||||
extends EventEmitter
|
|
||||||
implements IButtplugClientConnector
|
|
||||||
{
|
|
||||||
private static _loggingActivated = false;
|
private static _loggingActivated = false;
|
||||||
private static wasmInstance;
|
private static wasmInstance;
|
||||||
private _connected: boolean = false;
|
private _connected: boolean = false;
|
||||||
@@ -43,35 +40,30 @@ export class ButtplugWasmClientConnector
|
|||||||
|
|
||||||
private static maybeLoadWasm = async () => {
|
private static maybeLoadWasm = async () => {
|
||||||
if (ButtplugWasmClientConnector.wasmInstance == undefined) {
|
if (ButtplugWasmClientConnector.wasmInstance == undefined) {
|
||||||
ButtplugWasmClientConnector.wasmInstance = await import(
|
ButtplugWasmClientConnector.wasmInstance = await import("../wasm/index.js");
|
||||||
'../wasm/index.js'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public static activateLogging = async (logLevel: string = 'debug') => {
|
public static activateLogging = async (logLevel: string = "debug") => {
|
||||||
await ButtplugWasmClientConnector.maybeLoadWasm();
|
await ButtplugWasmClientConnector.maybeLoadWasm();
|
||||||
if (this._loggingActivated) {
|
if (this._loggingActivated) {
|
||||||
console.log('Logging already activated, ignoring.');
|
console.log("Logging already activated, ignoring.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('Turning on logging.');
|
console.log("Turning on logging.");
|
||||||
ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger(
|
ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger(logLevel);
|
||||||
logLevel,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public initialize = async (): Promise<void> => {};
|
public initialize = async (): Promise<void> => {};
|
||||||
|
|
||||||
public connect = async (): Promise<void> => {
|
public connect = async (): Promise<void> => {
|
||||||
await ButtplugWasmClientConnector.maybeLoadWasm();
|
await ButtplugWasmClientConnector.maybeLoadWasm();
|
||||||
this.client =
|
this.client = ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server(
|
||||||
ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server(
|
(msgs) => {
|
||||||
(msgs) => {
|
this.emitMessage(msgs);
|
||||||
this.emitMessage(msgs);
|
},
|
||||||
},
|
this.serverPtr,
|
||||||
this.serverPtr,
|
);
|
||||||
);
|
|
||||||
this._connected = true;
|
this._connected = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,7 +72,7 @@ export class ButtplugWasmClientConnector
|
|||||||
public send = (msg: ButtplugMessage): void => {
|
public send = (msg: ButtplugMessage): void => {
|
||||||
ButtplugWasmClientConnector.wasmInstance.buttplug_client_send_json_message(
|
ButtplugWasmClientConnector.wasmInstance.buttplug_client_send_json_message(
|
||||||
this.client,
|
this.client,
|
||||||
new TextEncoder().encode('[' + JSON.stringify(msg) + ']'),
|
new TextEncoder().encode("[" + JSON.stringify(msg) + "]"),
|
||||||
(output) => {
|
(output) => {
|
||||||
this.emitMessage(output);
|
this.emitMessage(output);
|
||||||
},
|
},
|
||||||
@@ -90,6 +82,6 @@ export class ButtplugWasmClientConnector
|
|||||||
private emitMessage = (msg: Uint8Array) => {
|
private emitMessage = (msg: Uint8Array) => {
|
||||||
const str = new TextDecoder().decode(msg);
|
const str = new TextDecoder().decode(msg);
|
||||||
const msgs: ButtplugMessage[] = JSON.parse(str);
|
const msgs: ButtplugMessage[] = JSON.parse(str);
|
||||||
this.emit('message', msgs);
|
this.emit("message", msgs);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export function getRandomInt(max: number) {
|
export function getRandomInt(max: number) {
|
||||||
return Math.floor(Math.random() * Math.floor(max));
|
return Math.floor(Math.random() * Math.floor(max));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import path from "path";
|
|||||||
import wasm from "vite-plugin-wasm";
|
import wasm from "vite-plugin-wasm";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [wasm()], // include wasm plugin
|
plugins: [wasm()], // include wasm plugin
|
||||||
build: {
|
build: {
|
||||||
lib: {
|
lib: {
|
||||||
entry: path.resolve(__dirname, "src/index.ts"),
|
entry: path.resolve(__dirname, "src/index.ts"),
|
||||||
name: "buttplug",
|
name: "buttplug",
|
||||||
fileName: "index",
|
fileName: "index",
|
||||||
formats: ["es"], // this is important
|
formats: ["es"], // this is important
|
||||||
},
|
},
|
||||||
minify: false, // for demo purposes
|
minify: false, // for demo purposes
|
||||||
target: "esnext", // this is important as well
|
target: "esnext", // this is important as well
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: [/\.\/wasm\//, /\.\.\/wasm\//],
|
external: [/\.\/wasm\//, /\.\.\/wasm\//],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"css": "src/app.css",
|
"css": "src/app.css",
|
||||||
"baseColor": "slate"
|
"baseColor": "slate"
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "$lib/components",
|
"components": "$lib/components",
|
||||||
"utils": "$lib/utils",
|
"utils": "$lib/utils",
|
||||||
"ui": "$lib/components/ui",
|
"ui": "$lib/components/ui",
|
||||||
"hooks": "$lib/hooks",
|
"hooks": "$lib/hooks",
|
||||||
"lib": "$lib"
|
"lib": "$lib"
|
||||||
},
|
},
|
||||||
"typescript": true,
|
"typescript": true,
|
||||||
"registry": "https://shadcn-svelte.com/registry"
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://unpkg.com/jsrepo@2.4.9/schemas/project-config.json",
|
"$schema": "https://unpkg.com/jsrepo@2.4.9/schemas/project-config.json",
|
||||||
"repos": ["@ieedan/shadcn-svelte-extras"],
|
"repos": ["@ieedan/shadcn-svelte-extras"],
|
||||||
"includeTests": false,
|
"includeTests": false,
|
||||||
"includeDocs": false,
|
"includeDocs": false,
|
||||||
"watermark": true,
|
"watermark": true,
|
||||||
"formatter": "prettier",
|
"formatter": "prettier",
|
||||||
"configFiles": {},
|
"configFiles": {},
|
||||||
"paths": {
|
"paths": {
|
||||||
"*": "$lib/blocks",
|
"*": "$lib/blocks",
|
||||||
"ui": "$lib/components/ui",
|
"ui": "$lib/components/ui",
|
||||||
"actions": "$lib/actions",
|
"actions": "$lib/actions",
|
||||||
"hooks": "$lib/hooks",
|
"hooks": "$lib/hooks",
|
||||||
"utils": "$lib/utils"
|
"utils": "$lib/utils"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,82 +8,82 @@
|
|||||||
@custom-variant hover (&:hover);
|
@custom-variant hover (&:hover);
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--animate-vibrate: vibrate 0.3s linear infinite;
|
--animate-vibrate: vibrate 0.3s linear infinite;
|
||||||
--animate-fade-in: fadeIn 0.3s ease-out;
|
--animate-fade-in: fadeIn 0.3s ease-out;
|
||||||
--animate-slide-up: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
--animate-slide-up: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--animate-zoom-in: zoomIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
--animate-zoom-in: zoomIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--animate-pulse-glow: pulseGlow 2s infinite;
|
--animate-pulse-glow: pulseGlow 2s infinite;
|
||||||
|
|
||||||
@keyframes vibrate {
|
@keyframes vibrate {
|
||||||
0% {
|
0% {
|
||||||
transform: translate(0);
|
transform: translate(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
20% {
|
20% {
|
||||||
transform: translate(-2px, 2px);
|
transform: translate(-2px, 2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
40% {
|
40% {
|
||||||
transform: translate(-2px, -2px);
|
transform: translate(-2px, -2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
60% {
|
60% {
|
||||||
transform: translate(2px, 2px);
|
transform: translate(2px, 2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
80% {
|
80% {
|
||||||
transform: translate(2px, -2px);
|
transform: translate(2px, -2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: translate(0);
|
transform: translate(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(30px) scale(0.95);
|
transform: translateY(30px) scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes zoomIn {
|
@keyframes zoomIn {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.9);
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulseGlow {
|
@keyframes pulseGlow {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
boxShadow: 0 0 20px rgba(183, 0, 217, 0.3);
|
boxshadow: 0 0 20px rgba(183, 0, 217, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
boxShadow: 0 0 40px rgba(183, 0, 217, 0.6);
|
boxshadow: 0 0 40px rgba(183, 0, 217, 0.6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -95,134 +95,134 @@
|
|||||||
color utility to any element that depends on these defaults.
|
color utility to any element that depends on these defaults.
|
||||||
*/
|
*/
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
outline-color: color-mix(in oklab, var(--ring) 50%, transparent);
|
outline-color: color-mix(in oklab, var(--ring) 50%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
outline-color: var(--ring);
|
outline-color: var(--ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose h2 {
|
.prose h2 {
|
||||||
@apply text-2xl font-bold mt-8 mb-4 text-foreground;
|
@apply text-2xl font-bold mt-8 mb-4 text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose h3 {
|
.prose h3 {
|
||||||
@apply text-xl font-semibold mt-6 mb-3 text-foreground;
|
@apply text-xl font-semibold mt-6 mb-3 text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose p {
|
.prose p {
|
||||||
@apply mb-4 leading-relaxed;
|
@apply mb-4 leading-relaxed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose ul {
|
.prose ul {
|
||||||
@apply mb-4 pl-6;
|
@apply mb-4 pl-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose li {
|
.prose li {
|
||||||
@apply mb-2;
|
@apply mb-2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--default-font-family: "Noto Sans", sans-serif;
|
--default-font-family: "Noto Sans", sans-serif;
|
||||||
--background: oklch(0.98 0.01 320);
|
--background: oklch(0.98 0.01 320);
|
||||||
--foreground: oklch(0.08 0.02 280);
|
--foreground: oklch(0.08 0.02 280);
|
||||||
--muted: oklch(0.95 0.01 280);
|
--muted: oklch(0.95 0.01 280);
|
||||||
--muted-foreground: oklch(0.4 0.02 280);
|
--muted-foreground: oklch(0.4 0.02 280);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(0.99 0.005 320);
|
--card: oklch(0.99 0.005 320);
|
||||||
--card-foreground: oklch(0.08 0.02 280);
|
--card-foreground: oklch(0.08 0.02 280);
|
||||||
--border: oklch(0.85 0.02 280);
|
--border: oklch(0.85 0.02 280);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--primary: oklch(56.971% 0.27455 319.257);
|
--primary: oklch(56.971% 0.27455 319.257);
|
||||||
--primary-foreground: oklch(0.98 0.01 320);
|
--primary-foreground: oklch(0.98 0.01 320);
|
||||||
--secondary: oklch(0.92 0.02 260);
|
--secondary: oklch(0.92 0.02 260);
|
||||||
--secondary-foreground: oklch(0.15 0.05 260);
|
--secondary-foreground: oklch(0.15 0.05 260);
|
||||||
--accent: oklch(0.45 0.35 280);
|
--accent: oklch(0.45 0.35 280);
|
||||||
--accent-foreground: oklch(0.98 0.01 280);
|
--accent-foreground: oklch(0.98 0.01 280);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--ring: oklch(0.55 0.3 320);
|
--ring: oklch(0.55 0.3 320);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.08 0.02 280);
|
--background: oklch(0.08 0.02 280);
|
||||||
--foreground: oklch(0.98 0.01 280);
|
--foreground: oklch(0.98 0.01 280);
|
||||||
--muted: oklch(0.12 0.03 280);
|
--muted: oklch(0.12 0.03 280);
|
||||||
--muted-foreground: oklch(0.6 0.02 280);
|
--muted-foreground: oklch(0.6 0.02 280);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.1 0.02 280);
|
--card: oklch(0.1 0.02 280);
|
||||||
--card-foreground: oklch(0.95 0.01 280);
|
--card-foreground: oklch(0.95 0.01 280);
|
||||||
--border: oklch(0.2 0.05 280);
|
--border: oklch(0.2 0.05 280);
|
||||||
--input: oklch(1 0 0 / 0.15);
|
--input: oklch(1 0 0 / 0.15);
|
||||||
--primary: oklch(0.65 0.25 320);
|
--primary: oklch(0.65 0.25 320);
|
||||||
--primary-foreground: oklch(0.98 0.01 320);
|
--primary-foreground: oklch(0.98 0.01 320);
|
||||||
--secondary: oklch(0.15 0.05 260);
|
--secondary: oklch(0.15 0.05 260);
|
||||||
--secondary-foreground: oklch(0.9 0.02 260);
|
--secondary-foreground: oklch(0.9 0.02 260);
|
||||||
--accent: oklch(0.55 0.3 280);
|
--accent: oklch(0.55 0.3 280);
|
||||||
--accent-foreground: oklch(0.98 0.01 280);
|
--accent-foreground: oklch(0.98 0.01 280);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--ring: oklch(0.65 0.25 320);
|
--ring: oklch(0.65 0.25 320);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 0.1);
|
--sidebar-border: oklch(1 0 0 / 0.1);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
|
||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-sans);
|
||||||
--font-mono: var(--font-mono);
|
--font-mono: var(--font-mono);
|
||||||
--font-serif: var(--font-serif);
|
--font-serif: var(--font-serif);
|
||||||
}
|
}
|
||||||
|
|||||||
32
packages/frontend/src/app.d.ts
vendored
32
packages/frontend/src/app.d.ts
vendored
@@ -4,22 +4,22 @@ import type { AuthStatus } from "$lib/types";
|
|||||||
|
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
interface Locals {
|
interface Locals {
|
||||||
authStatus: AuthStatus;
|
authStatus: AuthStatus;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
}
|
}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
interface Window {
|
interface Window {
|
||||||
sidebar: {
|
sidebar: {
|
||||||
addPanel: () => void;
|
addPanel: () => void;
|
||||||
};
|
};
|
||||||
opera: object;
|
opera: object;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -6,88 +6,88 @@ import type { Handle } from "@sveltejs/kit";
|
|||||||
logger.startup();
|
logger.startup();
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
const { cookies, locals, url, request } = event;
|
const { cookies, locals, url, request } = event;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
// Generate unique request ID
|
// Generate unique request ID
|
||||||
const requestId = generateRequestId();
|
const requestId = generateRequestId();
|
||||||
|
|
||||||
// Add request ID to locals for access in other handlers
|
// Add request ID to locals for access in other handlers
|
||||||
locals.requestId = requestId;
|
locals.requestId = requestId;
|
||||||
|
|
||||||
// Log incoming request
|
// Log incoming request
|
||||||
logger.request(request.method, url.pathname, {
|
logger.request(request.method, url.pathname, {
|
||||||
requestId,
|
requestId,
|
||||||
context: {
|
context: {
|
||||||
userAgent: request.headers.get('user-agent')?.substring(0, 100),
|
userAgent: request.headers.get("user-agent")?.substring(0, 100),
|
||||||
referer: request.headers.get('referer'),
|
referer: request.headers.get("referer"),
|
||||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
|
ip: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle authentication
|
// Handle authentication
|
||||||
const token = cookies.get("session_token");
|
const token = cookies.get("session_token");
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
locals.authStatus = await isAuthenticated(token);
|
locals.authStatus = await isAuthenticated(token);
|
||||||
|
|
||||||
if (locals.authStatus.authenticated) {
|
if (locals.authStatus.authenticated) {
|
||||||
logger.auth('Token validated', true, {
|
logger.auth("Token validated", true, {
|
||||||
requestId,
|
requestId,
|
||||||
userId: locals.authStatus.user?.id,
|
userId: locals.authStatus.user?.id,
|
||||||
context: {
|
context: {
|
||||||
email: locals.authStatus.user?.email,
|
email: locals.authStatus.user?.email,
|
||||||
role: locals.authStatus.user?.role,
|
role: locals.authStatus.user?.role,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.auth('Token invalid', false, { requestId });
|
logger.auth("Token invalid", false, { requestId });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Authentication check failed', {
|
logger.error("Authentication check failed", {
|
||||||
requestId,
|
requestId,
|
||||||
error: error instanceof Error ? error : new Error(String(error)),
|
error: error instanceof Error ? error : new Error(String(error)),
|
||||||
});
|
});
|
||||||
locals.authStatus = { authenticated: false };
|
locals.authStatus = { authenticated: false };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.debug('No session token found', { requestId });
|
logger.debug("No session token found", { requestId });
|
||||||
locals.authStatus = { authenticated: false };
|
locals.authStatus = { authenticated: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the request
|
// Resolve the request
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await resolve(event, {
|
response = await resolve(event, {
|
||||||
filterSerializedResponseHeaders: (key) => {
|
filterSerializedResponseHeaders: (key) => {
|
||||||
return key.toLowerCase() === "content-type";
|
return key.toLowerCase() === "content-type";
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
logger.error('Request handler error', {
|
logger.error("Request handler error", {
|
||||||
requestId,
|
requestId,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
path: url.pathname,
|
path: url.pathname,
|
||||||
duration,
|
duration,
|
||||||
error: error instanceof Error ? error : new Error(String(error)),
|
error: error instanceof Error ? error : new Error(String(error)),
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log response
|
// Log response
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
logger.response(request.method, url.pathname, response.status, duration, {
|
logger.response(request.method, url.pathname, response.status, duration, {
|
||||||
requestId,
|
requestId,
|
||||||
userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined,
|
userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined,
|
||||||
context: {
|
context: {
|
||||||
cached: response.headers.get('x-sveltekit-page') === 'true',
|
cached: response.headers.get("x-sveltekit-page") === "true",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add request ID to response headers (useful for debugging)
|
// Add request ID to response headers (useful for debugging)
|
||||||
response.headers.set('x-request-id', requestId);
|
response.headers.set("x-request-id", requestId);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,77 +1,70 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "$lib/components/ui/dialog";
|
} from "$lib/components/ui/dialog";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Separator } from "$lib/components/ui/separator";
|
import { Separator } from "$lib/components/ui/separator";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
const AGE_VERIFICATION_KEY = "age-verified";
|
const AGE_VERIFICATION_KEY = "age-verified";
|
||||||
|
|
||||||
let isOpen = true;
|
let isOpen = true;
|
||||||
|
|
||||||
function handleAgeConfirmation() {
|
function handleAgeConfirmation() {
|
||||||
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
|
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
|
||||||
isOpen = false;
|
isOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
|
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
|
||||||
if (storedVerification === "true") {
|
if (storedVerification === "true") {
|
||||||
isOpen = false;
|
isOpen = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog bind:open={isOpen}>
|
<Dialog bind:open={isOpen}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
class="sm:max-w-md"
|
class="sm:max-w-md"
|
||||||
onInteractOutside={(e) => e.preventDefault()}
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
>
|
>
|
||||||
<DialogHeader class="space-y-4">
|
<DialogHeader class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
|
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<span class="text-primary-foreground text-sm"
|
<span class="text-primary-foreground text-sm">{$_("age_verification_dialog.age")}</span>
|
||||||
>{$_("age_verification_dialog.age")}</span
|
</div>
|
||||||
>
|
<div class="">
|
||||||
</div>
|
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
||||||
<div class="">
|
>{$_("age_verification_dialog.title")}</DialogTitle
|
||||||
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
|
||||||
>{$_("age_verification_dialog.title")}</DialogTitle
|
|
||||||
>
|
|
||||||
<DialogDescription class="text-left text-sm">
|
|
||||||
{$_("age_verification_dialog.description")}
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Separator class="my-4" />
|
|
||||||
|
|
||||||
<!-- Close Button -->
|
|
||||||
<div class="flex justify-end gap-4">
|
|
||||||
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
|
|
||||||
{$_("age_verification_dialog.exit")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onclick={handleAgeConfirmation}
|
|
||||||
class="cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--check-line]"></span>
|
<DialogDescription class="text-left text-sm">
|
||||||
{$_("age_verification_dialog.confirm")}
|
{$_("age_verification_dialog.description")}
|
||||||
</Button>
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Separator class="my-4" />
|
||||||
|
|
||||||
|
<!-- Close Button -->
|
||||||
|
<div class="flex justify-end gap-4">
|
||||||
|
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
|
||||||
|
{$_("age_verification_dialog.exit")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" size="sm" onclick={handleAgeConfirmation} class="cursor-pointer">
|
||||||
|
<span class="icon-[ri--check-line]"></span>
|
||||||
|
{$_("age_verification_dialog.confirm")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,55 +1,55 @@
|
|||||||
<!-- Advanced Plasma Background -->
|
<!-- Advanced Plasma Background -->
|
||||||
<div class="absolute inset-0 pointer-events-none">
|
<div class="absolute inset-0 pointer-events-none">
|
||||||
<!-- Primary gradient layers -->
|
<!-- Primary gradient layers -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
|
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
|
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Large floating orbs -->
|
<!-- Large floating orbs -->
|
||||||
<!-- <div
|
<!-- <div
|
||||||
class="absolute top-20 left-20 w-80 h-80 bg-gradient-to-br from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-slow"
|
class="absolute top-20 left-20 w-80 h-80 bg-gradient-to-br from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-slow"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-20 right-20 w-96 h-96 bg-gradient-to-tl from-accent/12 via-primary/18 to-accent/8 rounded-full blur-3xl animate-blob-slow animation-delay-6000"
|
class="absolute bottom-20 right-20 w-96 h-96 bg-gradient-to-tl from-accent/12 via-primary/18 to-accent/8 rounded-full blur-3xl animate-blob-slow animation-delay-6000"
|
||||||
></div> -->
|
></div> -->
|
||||||
|
|
||||||
<!-- Medium morphing elements -->
|
<!-- Medium morphing elements -->
|
||||||
<!-- <div
|
<!-- <div
|
||||||
class="absolute top-1/2 left-1/3 w-64 h-64 bg-gradient-to-r from-primary/10 via-accent/15 to-primary/8 rounded-full blur-2xl animate-blob-reverse animation-delay-3000"
|
class="absolute top-1/2 left-1/3 w-64 h-64 bg-gradient-to-r from-primary/10 via-accent/15 to-primary/8 rounded-full blur-2xl animate-blob-reverse animation-delay-3000"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-1/3 right-1/3 w-72 h-72 bg-gradient-to-l from-accent/10 via-primary/15 to-accent/8 rounded-full blur-2xl animate-blob-reverse animation-delay-9000"
|
class="absolute bottom-1/3 right-1/3 w-72 h-72 bg-gradient-to-l from-accent/10 via-primary/15 to-accent/8 rounded-full blur-2xl animate-blob-reverse animation-delay-9000"
|
||||||
></div> -->
|
></div> -->
|
||||||
|
|
||||||
<!-- Soft particle effects -->
|
<!-- Soft particle effects -->
|
||||||
<!-- <div
|
<!-- <div
|
||||||
class="absolute top-1/4 right-1/4 w-48 h-48 bg-gradient-to-br from-primary/15 to-accent/12 rounded-full blur-xl animate-float animation-delay-2000"
|
class="absolute top-1/4 right-1/4 w-48 h-48 bg-gradient-to-br from-primary/15 to-accent/12 rounded-full blur-xl animate-float animation-delay-2000"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-1/4 left-1/4 w-56 h-56 bg-gradient-to-tl from-accent/15 to-primary/12 rounded-full blur-xl animate-float animation-delay-8000"
|
class="absolute bottom-1/4 left-1/4 w-56 h-56 bg-gradient-to-tl from-accent/15 to-primary/12 rounded-full blur-xl animate-float animation-delay-8000"
|
||||||
></div> -->
|
></div> -->
|
||||||
|
|
||||||
<!-- Premium glassmorphism overlay -->
|
<!-- Premium glassmorphism overlay -->
|
||||||
<!-- <div
|
<!-- <div
|
||||||
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/3 backdrop-blur-[1px]"
|
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/3 backdrop-blur-[1px]"
|
||||||
></div> -->
|
></div> -->
|
||||||
|
|
||||||
<!-- Animated Plasma Background -->
|
<!-- Animated Plasma Background -->
|
||||||
<div
|
<div
|
||||||
class="absolute top-1/3 left-1/3 w-72 h-72 bg-gradient-to-r from-accent/20 via-primary/25 to-accent/15 rounded-full blur-2xl animate-blob"
|
class="absolute top-1/3 left-1/3 w-72 h-72 bg-gradient-to-r from-accent/20 via-primary/25 to-accent/15 rounded-full blur-2xl animate-blob"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-1/3 right-1/3 w-88 h-88 bg-gradient-to-r from-primary/20 via-accent/25 to-primary/15 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
|
class="absolute bottom-1/3 right-1/3 w-88 h-88 bg-gradient-to-r from-primary/20 via-accent/25 to-primary/15 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="absolute top-1/2 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/20 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1000"
|
class="absolute top-1/2 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/20 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1000"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Global Plasma Background -->
|
<!-- Global Plasma Background -->
|
||||||
<!-- <div
|
<!-- <div
|
||||||
class="absolute top-32 right-32 w-72 h-72 bg-gradient-to-r from-accent/18 via-primary/22 to-accent/12 rounded-full blur-3xl animate-blob"
|
class="absolute top-32 right-32 w-72 h-72 bg-gradient-to-r from-accent/18 via-primary/22 to-accent/12 rounded-full blur-3xl animate-blob"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,99 +1,92 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
import { Slider } from "$lib/components/ui/slider";
|
import { Slider } from "$lib/components/ui/slider";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
||||||
import type { BluetoothDevice } from "$lib/types";
|
import type { BluetoothDevice } from "$lib/types";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
device: BluetoothDevice;
|
device: BluetoothDevice;
|
||||||
onChange: (scalarIndex: number, val: number) => void;
|
onChange: (scalarIndex: number, val: number) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { device, onChange, onStop }: Props = $props();
|
let { device, onChange, onStop }: Props = $props();
|
||||||
|
|
||||||
function getBatteryColor(level: number) {
|
function getBatteryColor(level: number) {
|
||||||
if (!device.hasBattery) {
|
if (!device.hasBattery) {
|
||||||
return "text-gray-400";
|
return "text-gray-400";
|
||||||
}
|
}
|
||||||
if (level > 60) return "text-green-400";
|
if (level > 60) return "text-green-400";
|
||||||
if (level > 30) return "text-yellow-400";
|
if (level > 30) return "text-yellow-400";
|
||||||
return "text-red-400";
|
return "text-red-400";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBatteryBgColor(level: number) {
|
function getBatteryBgColor(level: number) {
|
||||||
if (!device.hasBattery) {
|
if (!device.hasBattery) {
|
||||||
return "bg-gray-400/20";
|
return "bg-gray-400/20";
|
||||||
}
|
}
|
||||||
if (level > 60) return "bg-green-400/20";
|
if (level > 60) return "bg-green-400/20";
|
||||||
if (level > 30) return "bg-yellow-400/20";
|
if (level > 30) return "bg-yellow-400/20";
|
||||||
return "bg-red-400/20";
|
return "bg-red-400/20";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScalarAnimations() {
|
function getScalarAnimations() {
|
||||||
return device.actuators
|
return device.actuators
|
||||||
.filter((a) => a.value > 0)
|
.filter((a) => a.value > 0)
|
||||||
.map((a) => `animate-${a.outputType.toLowerCase()}`);
|
.map((a) => `animate-${a.outputType.toLowerCase()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActive() {
|
function isActive() {
|
||||||
return device.actuators.some((a) => a.value > 0);
|
return device.actuators.some((a) => a.value > 0);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
|
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="pb-3">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
|
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
|
||||||
>
|
>
|
||||||
<span class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}></span>
|
<span
|
||||||
</div>
|
class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}
|
||||||
<div>
|
></span>
|
||||||
<h3
|
</div>
|
||||||
class="font-semibold text-card-foreground group-hover:text-primary transition-colors"
|
<div>
|
||||||
>
|
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
|
||||||
{device.name}
|
{device.name}
|
||||||
</h3>
|
</h3>
|
||||||
<!-- <p class="text-sm text-muted-foreground">
|
<!-- <p class="text-sm text-muted-foreground">
|
||||||
{device.deviceType}
|
{device.deviceType}
|
||||||
</p> -->
|
</p> -->
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`} onclick={() => isActive() && onStop()}>
|
|
||||||
<div class="relative">
|
|
||||||
<div
|
|
||||||
class="w-2 h-2 rounded-full {isActive()
|
|
||||||
? 'bg-green-400'
|
|
||||||
: 'bg-red-400'}"
|
|
||||||
></div>
|
|
||||||
{#if isActive()}
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="text-xs font-medium {isActive()
|
|
||||||
? 'text-green-400'
|
|
||||||
: 'text-red-400'}"
|
|
||||||
>
|
|
||||||
{isActive()
|
|
||||||
? $_("device_card.active")
|
|
||||||
: $_("device_card.paused")}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
|
<button
|
||||||
|
class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`}
|
||||||
|
onclick={() => isActive() && onStop()}
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="w-2 h-2 rounded-full {isActive() ? 'bg-green-400' : 'bg-red-400'}"></div>
|
||||||
|
{#if isActive()}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium {isActive() ? 'text-green-400' : 'text-red-400'}">
|
||||||
|
{isActive() ? $_("device_card.active") : $_("device_card.paused")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<!-- Current Value -->
|
<!-- Current Value -->
|
||||||
<!-- <div
|
<!-- <div
|
||||||
class="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/30"
|
class="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/30"
|
||||||
>
|
>
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground"
|
||||||
@@ -103,58 +96,54 @@ function isActive() {
|
|||||||
>
|
>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
<!-- Battery Level -->
|
<!-- Battery Level -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(
|
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(device.batteryLevel)}"
|
||||||
device.batteryLevel,
|
></span>
|
||||||
)}"
|
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
|
||||||
></span>
|
|
||||||
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
|
|
||||||
</div>
|
|
||||||
{#if device.hasBattery}
|
|
||||||
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
|
|
||||||
{device.batteryLevel}%
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-muted/50 rounded-full h-2 overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full transition-all duration-500 {getBatteryBgColor(
|
|
||||||
device.batteryLevel,
|
|
||||||
)} bg-gradient-to-r from-current to-current/80"
|
|
||||||
style="width: {device.batteryLevel}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{#if device.hasBattery}
|
||||||
|
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
|
||||||
|
{device.batteryLevel}%
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-muted/50 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all duration-500 {getBatteryBgColor(
|
||||||
|
device.batteryLevel,
|
||||||
|
)} bg-gradient-to-r from-current to-current/80"
|
||||||
|
style="width: {device.batteryLevel}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Last Seen -->
|
<!-- Last Seen -->
|
||||||
<!-- <div
|
<!-- <div
|
||||||
class="flex items-center justify-between text-xs text-muted-foreground"
|
class="flex items-center justify-between text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
<span>{$_("device_card.last_seen")}</span>
|
<span>{$_("device_card.last_seen")}</span>
|
||||||
<span>{device.lastSeen.toLocaleTimeString()}</span>
|
<span>{device.lastSeen.toLocaleTimeString()}</span>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
<!-- Action Button -->
|
<!-- Action Button -->
|
||||||
{#each device.actuators as actuator, idx (idx)}
|
{#each device.actuators as actuator, idx (idx)}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
||||||
>{$_(
|
>{$_(`device_card.actuator_types.${actuator.outputType.toLowerCase()}`)}</Label
|
||||||
`device_card.actuator_types.${actuator.outputType.toLowerCase()}`,
|
>
|
||||||
)}</Label
|
<Slider
|
||||||
>
|
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
||||||
<Slider
|
type="single"
|
||||||
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
value={actuator.value}
|
||||||
type="single"
|
onValueChange={(val) => onChange(idx, val)}
|
||||||
value={actuator.value}
|
max={actuator.maxSteps}
|
||||||
onValueChange={(val) => onChange(idx, val)}
|
step={1}
|
||||||
max={actuator.maxSteps}
|
/>
|
||||||
step={1}
|
</div>
|
||||||
/>
|
{/each}
|
||||||
</div>
|
</CardContent>
|
||||||
{/each}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,120 +1,120 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import Logo from "../logo/logo.svelte";
|
import Logo from "../logo/logo.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer
|
<footer
|
||||||
class="bg-gradient-to-t from-card/95 to-card/85 backdrop-blur-xl mt-20 shadow-2xl shadow-primary/10"
|
class="bg-gradient-to-t from-card/95 to-card/85 backdrop-blur-xl mt-20 shadow-2xl shadow-primary/10"
|
||||||
>
|
>
|
||||||
<div class="container mx-auto px-4 py-12">
|
<div class="container mx-auto px-4 py-12">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
<!-- Brand -->
|
<!-- Brand -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center gap-3 text-xl font-bold">
|
<div class="flex items-center gap-3 text-xl font-bold">
|
||||||
<Logo />
|
<Logo />
|
||||||
</div>
|
|
||||||
<p class="text-sm text-muted-foreground">{$_("brand.description")}</p>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<a
|
|
||||||
aria-label="Email"
|
|
||||||
href="mailto:{$_('footer.contact.email')}"
|
|
||||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--mail-line] w-4 h-4 text-primary"></span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
aria-label="X"
|
|
||||||
href="https://www.x.com/{$_('footer.contact.x')}"
|
|
||||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--twitter-x-line] w-4 h-4 text-primary"></span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
aria-label="YouTube"
|
|
||||||
href="https://www.youtube.com/@{$_('footer.contact.youtube')}"
|
|
||||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--youtube-line] w-4 h-4 text-primary"></span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Links -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h3 class="font-semibold text-foreground">
|
|
||||||
{$_("footer.quick_links")}
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<a
|
|
||||||
href="/models"
|
|
||||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
>{$_("footer.models")}</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="/videos"
|
|
||||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
>{$_("footer.videos")}</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="/magazine"
|
|
||||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
>{$_("footer.magazine")}</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="/about"
|
|
||||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
>{$_("footer.about")}</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Support -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h3 class="font-semibold text-foreground">{$_("footer.support")}</h3>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<a
|
|
||||||
href="mailto:{$_('footer.contact_support_email')}"
|
|
||||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
>{$_("footer.contact_support")}</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="mailto:{$_('footer.model_applications_email')}"
|
|
||||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
>{$_("footer.model_applications")}</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="/faq"
|
|
||||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
>{$_("footer.faq")}</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Legal -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h3 class="font-semibold text-foreground">{$_("footer.legal")}</h3>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<a
|
|
||||||
href="/legal"
|
|
||||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
>{$_("footer.privacy_policy")}</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="/legal"
|
|
||||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
>{$_("footer.terms_of_service")}</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="/imprint"
|
|
||||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
>{$_("footer.imprint")}</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">{$_("brand.description")}</p>
|
||||||
<div class="border-t border-border/50 mt-8 pt-8 text-center">
|
<div class="flex gap-3">
|
||||||
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p>
|
<a
|
||||||
|
aria-label="Email"
|
||||||
|
href="mailto:{$_('footer.contact.email')}"
|
||||||
|
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--mail-line] w-4 h-4 text-primary"></span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
aria-label="X"
|
||||||
|
href="https://www.x.com/{$_('footer.contact.x')}"
|
||||||
|
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--twitter-x-line] w-4 h-4 text-primary"></span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
aria-label="YouTube"
|
||||||
|
href="https://www.youtube.com/@{$_('footer.contact.youtube')}"
|
||||||
|
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--youtube-line] w-4 h-4 text-primary"></span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Links -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-semibold text-foreground">
|
||||||
|
{$_("footer.quick_links")}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<a
|
||||||
|
href="/models"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.models")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/videos"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.videos")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/magazine"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.magazine")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/about"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.about")}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Support -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-semibold text-foreground">{$_("footer.support")}</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<a
|
||||||
|
href="mailto:{$_('footer.contact_support_email')}"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.contact_support")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="mailto:{$_('footer.model_applications_email')}"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.model_applications")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/faq"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.faq")}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legal -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-semibold text-foreground">{$_("footer.legal")}</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<a
|
||||||
|
href="/legal"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.privacy_policy")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/legal"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.terms_of_service")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/imprint"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.imprint")}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-border/50 mt-8 pt-8 text-center">
|
||||||
|
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -7,9 +7,7 @@
|
|||||||
stroke="#ce47eb"
|
stroke="#ce47eb"
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
>
|
>
|
||||||
<metadata>
|
<metadata> Created by potrace 1.15, written by Peter Selinger 2001-2017 </metadata>
|
||||||
Created by potrace 1.15, written by Peter Selinger 2001-2017
|
|
||||||
</metadata>
|
|
||||||
<g transform="translate(0.000000,904.000000) scale(0.100000,-0.100000)">
|
<g transform="translate(0.000000,904.000000) scale(0.100000,-0.100000)">
|
||||||
<path
|
<path
|
||||||
d="M7930 7043 c-73 -10 -95 -18 -134 -51 -25 -20 -66 -53 -91 -72 -26
|
d="M7930 7043 c-73 -10 -95 -18 -134 -51 -25 -20 -66 -53 -91 -72 -26
|
||||||
@@ -117,4 +115,4 @@ m-3487 -790 c-17 -35 -55 -110 -84 -168 -29 -58 -72 -163 -96 -235 -45 -134
|
|||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,51 +1,51 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import type { AuthStatus } from "$lib/types";
|
import type { AuthStatus } from "$lib/types";
|
||||||
import { logout } from "$lib/services";
|
import { logout } from "$lib/services";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/directus";
|
||||||
import LogoutButton from "../logout-button/logout-button.svelte";
|
import LogoutButton from "../logout-button/logout-button.svelte";
|
||||||
import Separator from "../ui/separator/separator.svelte";
|
import Separator from "../ui/separator/separator.svelte";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||||
import { getUserInitials } from "$lib/utils";
|
import { getUserInitials } from "$lib/utils";
|
||||||
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
||||||
import Girls from "../girls/girls.svelte";
|
import Girls from "../girls/girls.svelte";
|
||||||
import Logo from "../logo/logo.svelte";
|
import Logo from "../logo/logo.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
authStatus: AuthStatus;
|
authStatus: AuthStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { authStatus }: Props = $props();
|
let { authStatus }: Props = $props();
|
||||||
|
|
||||||
let isMobileMenuOpen = $state(false);
|
let isMobileMenuOpen = $state(false);
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ name: $_("header.home"), href: "/" },
|
{ name: $_("header.home"), href: "/" },
|
||||||
{ name: $_("header.models"), href: "/models" },
|
{ name: $_("header.models"), href: "/models" },
|
||||||
{ name: $_("header.videos"), href: "/videos" },
|
{ name: $_("header.videos"), href: "/videos" },
|
||||||
{ name: $_("header.magazine"), href: "/magazine" },
|
{ name: $_("header.magazine"), href: "/magazine" },
|
||||||
{ name: $_("header.about"), href: "/about" },
|
{ name: $_("header.about"), href: "/about" },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
await logout();
|
await logout();
|
||||||
goto("/login", { invalidateAll: true });
|
goto("/login", { invalidateAll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMenu() {
|
function closeMenu() {
|
||||||
isMobileMenuOpen = false;
|
isMobileMenuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActiveLink(link: any) {
|
function isActiveLink(link: any) {
|
||||||
return (
|
return (
|
||||||
(page.url.pathname === "/" && link === navLinks[0]) ||
|
(page.url.pathname === "/" && link === navLinks[0]) ||
|
||||||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
@@ -58,7 +58,7 @@ function isActiveLink(link: any) {
|
|||||||
href="/"
|
href="/"
|
||||||
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
|
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<Logo hideName={true} />
|
<Logo hideName={true} />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop Navigation -->
|
<!-- Desktop Navigation -->
|
||||||
@@ -67,12 +67,12 @@ function isActiveLink(link: any) {
|
|||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
class={`text-sm hover:text-foreground transition-colors duration-200 font-medium relative group ${
|
class={`text-sm hover:text-foreground transition-colors duration-200 font-medium relative group ${
|
||||||
isActiveLink(link) ? 'text-foreground' : 'text-foreground/85'
|
isActiveLink(link) ? "text-foreground" : "text-foreground/85"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{link.name}
|
{link.name}
|
||||||
<span
|
<span
|
||||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink(link) ? 'w-full' : 'group-hover:w-full'}`}
|
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink(link) ? "w-full" : "group-hover:w-full"}`}
|
||||||
></span>
|
></span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -95,29 +95,29 @@ function isActiveLink(link: any) {
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="icon"
|
size="icon"
|
||||||
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: '/me' }) ? 'text-foreground' : 'hover:text-foreground'}`}
|
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "text-foreground" : "hover:text-foreground"}`}
|
||||||
href="/me"
|
href="/me"
|
||||||
title={$_('header.dashboard')}
|
title={$_("header.dashboard")}
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--dashboard-2-line] h-4 w-4"></span>
|
<span class="icon-[ri--dashboard-2-line] h-4 w-4"></span>
|
||||||
<span
|
<span
|
||||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: '/me' }) ? 'w-full' : 'group-hover:w-full'}`}
|
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/me" }) ? "w-full" : "group-hover:w-full"}`}
|
||||||
></span>
|
></span>
|
||||||
<span class="sr-only">{$_('header.dashboard')}</span>
|
<span class="sr-only">{$_("header.dashboard")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="icon"
|
size="icon"
|
||||||
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: '/play' }) ? 'text-foreground' : 'hover:text-foreground'}`}
|
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
|
||||||
href="/play"
|
href="/play"
|
||||||
title={$_('header.play')}
|
title={$_("header.play")}
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--rocket-line] h-4 w-4"></span>
|
<span class="icon-[ri--rocket-line] h-4 w-4"></span>
|
||||||
<span
|
<span
|
||||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: '/play' }) ? 'w-full' : 'group-hover:w-full'}`}
|
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/play" }) ? "w-full" : "group-hover:w-full"}`}
|
||||||
></span>
|
></span>
|
||||||
<span class="sr-only">{$_('header.play')}</span>
|
<span class="sr-only">{$_("header.play")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Separator orientation="vertical" class="hidden md:flex mx-1 h-6 bg-border/50" />
|
<Separator orientation="vertical" class="hidden md:flex mx-1 h-6 bg-border/50" />
|
||||||
@@ -126,9 +126,10 @@ function isActiveLink(link: any) {
|
|||||||
|
|
||||||
<LogoutButton
|
<LogoutButton
|
||||||
user={{
|
user={{
|
||||||
name: authStatus.user!.artist_name || authStatus.user!.email.split('@')[0] || 'User',
|
name:
|
||||||
avatar: getAssetUrl(authStatus.user!.avatar?.id, 'mini')!,
|
authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
|
||||||
email: authStatus.user!.email
|
avatar: getAssetUrl(authStatus.user!.avatar?.id, "mini")!,
|
||||||
|
email: authStatus.user!.email,
|
||||||
}}
|
}}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
@@ -136,18 +137,16 @@ function isActiveLink(link: any) {
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex w-full items-center justify-end gap-4">
|
<div class="flex w-full items-center justify-end gap-4">
|
||||||
<Button variant="outline" class="font-medium" href="/login"
|
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button>
|
||||||
>{$_('header.login')}</Button
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
href="/signup"
|
href="/signup"
|
||||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
|
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
|
||||||
>{$_('header.signup')}</Button
|
>{$_("header.signup")}</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<BurgerMenuButton
|
<BurgerMenuButton
|
||||||
label={$_('header.navigation')}
|
label={$_("header.navigation")}
|
||||||
bind:isMobileMenuOpen
|
bind:isMobileMenuOpen
|
||||||
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||||
/>
|
/>
|
||||||
@@ -155,26 +154,24 @@ function isActiveLink(link: any) {
|
|||||||
</div>
|
</div>
|
||||||
<!-- Mobile Navigation -->
|
<!-- Mobile Navigation -->
|
||||||
<div
|
<div
|
||||||
class={`border-t border-border/20 bg-background/95 bg-gradient-to-br from-primary to-accent backdrop-blur-xl max-h-[calc(100vh-4rem)] overflow-y-auto shadow-xl/30 transition-all duration-250 ${isMobileMenuOpen ? 'opacity-100' : 'opacity-0'}`}
|
class={`border-t border-border/20 bg-background/95 bg-gradient-to-br from-primary to-accent backdrop-blur-xl max-h-[calc(100vh-4rem)] overflow-y-auto shadow-xl/30 transition-all duration-250 ${isMobileMenuOpen ? "opacity-100" : "opacity-0"}`}
|
||||||
>
|
>
|
||||||
{#if isMobileMenuOpen}
|
{#if isMobileMenuOpen}
|
||||||
<div class="container mx-auto grid grid-cols-1 lg:grid-cols-3">
|
<div class="container mx-auto grid grid-cols-1 lg:grid-cols-3">
|
||||||
<div class="hidden lg:flex col-span-2">
|
<div class="hidden lg:flex col-span-2">
|
||||||
<Girls />
|
<Girls />
|
||||||
</div>
|
</div>
|
||||||
<div class="py-6 px-4 space-y-6 lg:col-start-3 border-t border-border/20 bg-background/95 ">
|
<div class="py-6 px-4 space-y-6 lg:col-start-3 border-t border-border/20 bg-background/95">
|
||||||
<!-- User Profile Card -->
|
<!-- User Profile Card -->
|
||||||
{#if authStatus.authenticated}
|
{#if authStatus.authenticated}
|
||||||
<div
|
<div
|
||||||
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4 backdrop-blur-sm"
|
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<div
|
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"></div>
|
||||||
class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"
|
|
||||||
></div>
|
|
||||||
<div class="relative flex items-center gap-4">
|
<div class="relative flex items-center gap-4">
|
||||||
<Avatar class="h-14 w-14 ring-2 ring-primary/30">
|
<Avatar class="h-14 w-14 ring-2 ring-primary/30">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={getAssetUrl(authStatus.user!.avatar?.id, 'mini')}
|
src={getAssetUrl(authStatus.user!.avatar?.id, "mini")}
|
||||||
alt={authStatus.user!.artist_name}
|
alt={authStatus.user!.artist_name}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
@@ -212,17 +209,15 @@ function isActiveLink(link: any) {
|
|||||||
{/if}
|
{/if}
|
||||||
<!-- Navigation Cards -->
|
<!-- Navigation Cards -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h3
|
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider"
|
{$_("header.navigation")}
|
||||||
>
|
|
||||||
{$_('header.navigation')}
|
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
{#each navLinks as link (link.href)}
|
{#each navLinks as link (link.href)}
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
class="flex items-center justify-between rounded-xl border border-border/50 bg-card/50 p-4 backdrop-blur-sm transition-all hover:bg-card hover:border-primary/20 {isActiveLink(
|
class="flex items-center justify-between rounded-xl border border-border/50 bg-card/50 p-4 backdrop-blur-sm transition-all hover:bg-card hover:border-primary/20 {isActiveLink(
|
||||||
link
|
link,
|
||||||
)
|
)
|
||||||
? 'border-primary/30 bg-primary/5'
|
? 'border-primary/30 bg-primary/5'
|
||||||
: ''}"
|
: ''}"
|
||||||
@@ -233,8 +228,7 @@ function isActiveLink(link: any) {
|
|||||||
<!-- {#if isActiveLink(link)}
|
<!-- {#if isActiveLink(link)}
|
||||||
<div class="h-2 w-2 rounded-full bg-primary"></div>
|
<div class="h-2 w-2 rounded-full bg-primary"></div>
|
||||||
{/if} -->
|
{/if} -->
|
||||||
<span
|
<span class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground"
|
||||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground"
|
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -244,16 +238,14 @@ function isActiveLink(link: any) {
|
|||||||
|
|
||||||
<!-- Account Actions -->
|
<!-- Account Actions -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h3
|
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider"
|
{$_("header.account")}
|
||||||
>
|
|
||||||
{$_('header.account')}
|
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
{#if authStatus.authenticated}
|
{#if authStatus.authenticated}
|
||||||
<a
|
<a
|
||||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/me' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/me" }) ? "border-primary/30 bg-primary/5" : ""}`}
|
||||||
href="/me"
|
href="/me"
|
||||||
onclick={closeMenu}
|
onclick={closeMenu}
|
||||||
>
|
>
|
||||||
@@ -266,13 +258,9 @@ function isActiveLink(link: any) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<div class="flex flex-1 flex-col gap-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium text-foreground"
|
<span class="font-medium text-foreground">{$_("header.dashboard")}</span>
|
||||||
>{$_('header.dashboard')}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground">{$_("header.dashboard_hint")}</span>
|
||||||
>{$_('header.dashboard_hint')}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||||
@@ -280,7 +268,7 @@ function isActiveLink(link: any) {
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/play' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/play" }) ? "border-primary/30 bg-primary/5" : ""}`}
|
||||||
href="/play"
|
href="/play"
|
||||||
onclick={closeMenu}
|
onclick={closeMenu}
|
||||||
>
|
>
|
||||||
@@ -293,13 +281,9 @@ function isActiveLink(link: any) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<div class="flex flex-1 flex-col gap-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium text-foreground"
|
<span class="font-medium text-foreground">{$_("header.play")}</span>
|
||||||
>{$_('header.play')}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground">{$_("header.play_hint")}</span>
|
||||||
>{$_('header.play_hint')}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||||
@@ -307,7 +291,7 @@ function isActiveLink(link: any) {
|
|||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<a
|
<a
|
||||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/login' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/login" }) ? "border-primary/30 bg-primary/5" : ""}`}
|
||||||
href="/login"
|
href="/login"
|
||||||
onclick={closeMenu}
|
onclick={closeMenu}
|
||||||
>
|
>
|
||||||
@@ -320,13 +304,9 @@ function isActiveLink(link: any) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<div class="flex flex-1 flex-col gap-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium text-foreground"
|
<span class="font-medium text-foreground">{$_("header.login")}</span>
|
||||||
>{$_('header.login')}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground">{$_("header.login_hint")}</span>
|
||||||
>{$_('header.login_hint')}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||||
@@ -334,7 +314,7 @@ function isActiveLink(link: any) {
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/signup' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: "/signup" }) ? "border-primary/30 bg-primary/5" : ""}`}
|
||||||
href="/signup"
|
href="/signup"
|
||||||
onclick={closeMenu}
|
onclick={closeMenu}
|
||||||
>
|
>
|
||||||
@@ -347,13 +327,9 @@ function isActiveLink(link: any) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<div class="flex flex-1 flex-col gap-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium text-foreground"
|
<span class="font-medium text-foreground">{$_("header.signup")}</span>
|
||||||
>{$_('header.signup')}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground">{$_("header.signup_hint")}</span>
|
||||||
>{$_('header.signup_hint')}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||||
@@ -372,17 +348,11 @@ function isActiveLink(link: any) {
|
|||||||
<div
|
<div
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-destructive/10 group-hover:bg-destructive/20 transition-all"
|
class="flex h-10 w-10 items-center justify-center rounded-xl bg-destructive/10 group-hover:bg-destructive/20 transition-all"
|
||||||
>
|
>
|
||||||
<span
|
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"></span>
|
||||||
class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"
|
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<div class="flex flex-1 flex-col gap-1">
|
||||||
<span class="font-medium text-foreground"
|
<span class="font-medium text-foreground">{$_("header.logout")}</span>
|
||||||
>{$_('header.logout')}</span
|
<span class="text-sm text-muted-foreground">{$_("header.logout_hint")}</span>
|
||||||
>
|
|
||||||
<span class="text-sm text-muted-foreground"
|
|
||||||
>{$_('header.logout_hint')}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
size?: string | number;
|
size?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { class: className = "", size = "24" }: Props = $props();
|
let { class: className = "", size = "24" }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 512 512"
|
viewBox="0 0 512 512"
|
||||||
class={className}
|
class={className}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<g class="" transform="translate(0,0)" style=""
|
<g class="" transform="translate(0,0)" style=""
|
||||||
><path
|
><path
|
||||||
d="M418.813 30.625c-21.178 26.27-49.712 50.982-84.125 70.844-36.778 21.225-75.064 33.62-110.313 38.06a310.317 310.317 0 0 0 6.813 18.25c16.01.277 29.366-.434 36.406-1.5l9.47-1.53 8.436-1.28.22 10.186a307.48 307.48 0 0 1-1.095 18.72l56.625 8.843c.86-.095 1.713-.15 2.563-.157 11.188-.114 21.44 7.29 24.468 18.593.657 2.448.922 4.903.845 7.313 5.972-2.075 11.753-4.305 17.28-6.72l9.595-4.188 2.313 10.22a340.211 340.211 0 0 1 7.375 48.062C438.29 247.836 468.438 225.71 493 197.5c-3.22-36.73-16.154-78.04-39.125-117.813a290.509 290.509 0 0 0-2.22-3.78l-27.56 71.374c5.154.762 10.123 3.158 14.092 7.126 9.81 9.807 9.813 25.69 0 35.5-9.812 9.81-25.722 9.807-35.53 0-8.86-8.858-9.69-22.68-2.532-32.5l38.938-100.844a322.02 322.02 0 0 0-20.25-25.937zM51.842 118.72c-8.46 17.373-15.76 36.198-21.187 56.436-14.108 52.617-13.96 103.682-2.812 143.438 13.3-2.605 26.442-3.96 39.312-4.03 1.855-.012 3.688.02 5.53.06 20.857.48 40.98 4.332 59.97 11.5a355.064 355.064 0 0 1-1.656-34.218c0-27.8 3.135-54.377 9-78.937l2.47-10.407 9.655 4.562c29.467 13.98 66.194 23.424 106.28 25.22 5.136-20.05 8.19-39.78 9.408-58.75-35.198 4.83-75.387 2.766-116.407-8.22-38.363-10.272-72.314-26.78-99.562-46.656zm230.594 82.218c-1.535 10.452-3.615 21.03-6.218 31.687a312.754 312.754 0 0 0 46-3.97 24.98 24.98 0 0 1-1.532-21.748l-38.25-5.97zM105 201.375l4.156 18.22-21.594 4.905c8.75 5.174 13.353 15.703 10.594 26-3.32 12.394-16.045 19.758-28.437 16.438-12.394-3.32-19.76-16.075-16.44-28.47a23.235 23.235 0 0 1 3.126-6.874l-21.062 4.78-4.125-18.218 73.78-16.78zm388.594 22.813c-25.53 25.46-55.306 45.445-86.906 60.5.05 2.397.093 4.8.093 7.218 0 9.188-.354 18.232-1.03 27.125 16.635 1.33 32.045-1.7 45.344-9.374 25.925-14.962 40.608-45.694 42.5-85.47zm-338.844 3c-4.03 19.993-6.33 41.31-6.406 63.593l.125-.342c30.568 10.174 62.622 17.572 95.25 21.375l7.5.875.718 7.5 5.687 60.125-18.625 1.75-2.53-26.75a23.117 23.117 0 0 1-14.845.968c-12.393-3.32-19.76-16.042-16.438-28.436.285-1.06.647-2.08 1.063-3.063a496.627 496.627 0 0 1-57.406-14.53c2.69 49.62 16.154 94.04 36.094 126.656 22.366 36.588 52.13 57.78 83.968 57.78 31.838.003 61.602-21.19 83.97-57.78 19.536-31.96 32.846-75.244 35.905-123.656a499.132 499.132 0 0 1-48.25 11.656c1.914 4.57 2.415 9.78 1.033 14.938-3.322 12.394-16.045 19.758-28.438 16.437a23.01 23.01 0 0 1-2.125-.686l-2.5 26.47-18.594-1.752 5.688-60.125.72-7.5 7.498-.875c29.245-3.407 57.995-9.717 85.657-18.312v-1.594c0-21.573-2.27-42.23-6.064-61.75C351.132 242.653 313.092 250 272.312 250c-43.59 0-83.986-8.658-117.562-22.813zm-87.5 105.968c-10.87.102-21.995 1.22-33.375 3.313 12.695 31.62 33.117 53.07 59 60 16.9 4.523 34.896 2.536 52.813-5.25-4.382-13.89-7.874-28.606-10.344-43.97-21.115-9.623-43.934-14.32-68.094-14.094zm137.5 80.22h130.813c-40.082 44.594-92.623 42.844-130.813 0z"
|
d="M418.813 30.625c-21.178 26.27-49.712 50.982-84.125 70.844-36.778 21.225-75.064 33.62-110.313 38.06a310.317 310.317 0 0 0 6.813 18.25c16.01.277 29.366-.434 36.406-1.5l9.47-1.53 8.436-1.28.22 10.186a307.48 307.48 0 0 1-1.095 18.72l56.625 8.843c.86-.095 1.713-.15 2.563-.157 11.188-.114 21.44 7.29 24.468 18.593.657 2.448.922 4.903.845 7.313 5.972-2.075 11.753-4.305 17.28-6.72l9.595-4.188 2.313 10.22a340.211 340.211 0 0 1 7.375 48.062C438.29 247.836 468.438 225.71 493 197.5c-3.22-36.73-16.154-78.04-39.125-117.813a290.509 290.509 0 0 0-2.22-3.78l-27.56 71.374c5.154.762 10.123 3.158 14.092 7.126 9.81 9.807 9.813 25.69 0 35.5-9.812 9.81-25.722 9.807-35.53 0-8.86-8.858-9.69-22.68-2.532-32.5l38.938-100.844a322.02 322.02 0 0 0-20.25-25.937zM51.842 118.72c-8.46 17.373-15.76 36.198-21.187 56.436-14.108 52.617-13.96 103.682-2.812 143.438 13.3-2.605 26.442-3.96 39.312-4.03 1.855-.012 3.688.02 5.53.06 20.857.48 40.98 4.332 59.97 11.5a355.064 355.064 0 0 1-1.656-34.218c0-27.8 3.135-54.377 9-78.937l2.47-10.407 9.655 4.562c29.467 13.98 66.194 23.424 106.28 25.22 5.136-20.05 8.19-39.78 9.408-58.75-35.198 4.83-75.387 2.766-116.407-8.22-38.363-10.272-72.314-26.78-99.562-46.656zm230.594 82.218c-1.535 10.452-3.615 21.03-6.218 31.687a312.754 312.754 0 0 0 46-3.97 24.98 24.98 0 0 1-1.532-21.748l-38.25-5.97zM105 201.375l4.156 18.22-21.594 4.905c8.75 5.174 13.353 15.703 10.594 26-3.32 12.394-16.045 19.758-28.437 16.438-12.394-3.32-19.76-16.075-16.44-28.47a23.235 23.235 0 0 1 3.126-6.874l-21.062 4.78-4.125-18.218 73.78-16.78zm388.594 22.813c-25.53 25.46-55.306 45.445-86.906 60.5.05 2.397.093 4.8.093 7.218 0 9.188-.354 18.232-1.03 27.125 16.635 1.33 32.045-1.7 45.344-9.374 25.925-14.962 40.608-45.694 42.5-85.47zm-338.844 3c-4.03 19.993-6.33 41.31-6.406 63.593l.125-.342c30.568 10.174 62.622 17.572 95.25 21.375l7.5.875.718 7.5 5.687 60.125-18.625 1.75-2.53-26.75a23.117 23.117 0 0 1-14.845.968c-12.393-3.32-19.76-16.042-16.438-28.436.285-1.06.647-2.08 1.063-3.063a496.627 496.627 0 0 1-57.406-14.53c2.69 49.62 16.154 94.04 36.094 126.656 22.366 36.588 52.13 57.78 83.968 57.78 31.838.003 61.602-21.19 83.97-57.78 19.536-31.96 32.846-75.244 35.905-123.656a499.132 499.132 0 0 1-48.25 11.656c1.914 4.57 2.415 9.78 1.033 14.938-3.322 12.394-16.045 19.758-28.438 16.437a23.01 23.01 0 0 1-2.125-.686l-2.5 26.47-18.594-1.752 5.688-60.125.72-7.5 7.498-.875c29.245-3.407 57.995-9.717 85.657-18.312v-1.594c0-21.573-2.27-42.23-6.064-61.75C351.132 242.653 313.092 250 272.312 250c-43.59 0-83.986-8.658-117.562-22.813zm-87.5 105.968c-10.87.102-21.995 1.22-33.375 3.313 12.695 31.62 33.117 53.07 59 60 16.9 4.523 34.896 2.536 52.813-5.25-4.382-13.89-7.874-28.606-10.344-43.97-21.115-9.623-43.934-14.32-68.094-14.094zm137.5 80.22h130.813c-40.082 44.594-92.623 42.844-130.813 0z"
|
||||||
fill-opacity="1"
|
fill-opacity="1"
|
||||||
style="fill: currentColor; stroke: #ce47eb; stroke-width: 10px;"
|
style="fill: currentColor; stroke: #ce47eb; stroke-width: 10px;"
|
||||||
|
></path></g
|
||||||
></path></g
|
></svg
|
||||||
></svg
|
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,109 +1,107 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import Button from "../ui/button/button.svelte";
|
import Button from "../ui/button/button.svelte";
|
||||||
|
|
||||||
const { images = [] } = $props();
|
const { images = [] } = $props();
|
||||||
|
|
||||||
let isViewerOpen = $state(false);
|
let isViewerOpen = $state(false);
|
||||||
let currentImageIndex = $state(0);
|
let currentImageIndex = $state(0);
|
||||||
let imageLoading = $state(false);
|
let imageLoading = $state(false);
|
||||||
|
|
||||||
let currentImage = $derived(images[currentImageIndex]);
|
let currentImage = $derived(images[currentImageIndex]);
|
||||||
let canGoPrev = $derived(currentImageIndex > 0);
|
let canGoPrev = $derived(currentImageIndex > 0);
|
||||||
let canGoNext = $derived(currentImageIndex < images.length - 1);
|
let canGoNext = $derived(currentImageIndex < images.length - 1);
|
||||||
|
|
||||||
function openViewer(index) {
|
function openViewer(index) {
|
||||||
currentImageIndex = index;
|
currentImageIndex = index;
|
||||||
isViewerOpen = true;
|
isViewerOpen = true;
|
||||||
imageLoading = true;
|
imageLoading = true;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeViewer() {
|
function closeViewer() {
|
||||||
isViewerOpen = false;
|
isViewerOpen = false;
|
||||||
document.body.style.overflow = "";
|
document.body.style.overflow = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigatePrev() {
|
function navigatePrev() {
|
||||||
if (canGoPrev) {
|
if (canGoPrev) {
|
||||||
currentImageIndex--;
|
currentImageIndex--;
|
||||||
imageLoading = true;
|
imageLoading = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateNext() {
|
function navigateNext() {
|
||||||
if (canGoNext) {
|
if (canGoNext) {
|
||||||
currentImageIndex++;
|
currentImageIndex++;
|
||||||
imageLoading = true;
|
imageLoading = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadImage() {
|
function downloadImage() {
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = currentImage.url;
|
link.href = currentImage.url;
|
||||||
link.download = currentImage.title.replace(/\\s+/g, "_") + ".jpg";
|
link.download = currentImage.title.replace(/\\s+/g, "_") + ".jpg";
|
||||||
link.target = "_blank";
|
link.target = "_blank";
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(event) {
|
function handleKeydown(event) {
|
||||||
if (!isViewerOpen) return;
|
if (!isViewerOpen) return;
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case "ArrowLeft":
|
case "ArrowLeft":
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
navigatePrev();
|
navigatePrev();
|
||||||
break;
|
break;
|
||||||
case "ArrowRight":
|
case "ArrowRight":
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
navigateNext();
|
navigateNext();
|
||||||
break;
|
break;
|
||||||
case "Escape":
|
case "Escape":
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
closeViewer();
|
closeViewer();
|
||||||
break;
|
break;
|
||||||
case "d":
|
case "d":
|
||||||
case "D":
|
case "D":
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
downloadImage();
|
downloadImage();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleImageLoad() {
|
function handleImageLoad() {
|
||||||
imageLoading = false;
|
imageLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!browser) {
|
if (!browser) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", handleKeydown);
|
window.addEventListener("keydown", handleKeydown);
|
||||||
// Preload images
|
// Preload images
|
||||||
images.forEach((img) => {
|
images.forEach((img) => {
|
||||||
const preload = new Image();
|
const preload = new Image();
|
||||||
preload.src = img.url;
|
preload.src = img.url;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (!browser) {
|
if (!browser) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.removeEventListener("keydown", handleKeydown);
|
window.removeEventListener("keydown", handleKeydown);
|
||||||
document.body.style.overflow = "";
|
document.body.style.overflow = "";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Gallery Grid -->
|
<!-- Gallery Grid -->
|
||||||
<div class="w-full mx-auto">
|
<div class="w-full mx-auto">
|
||||||
<div
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 animate-fade-in">
|
||||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 animate-fade-in"
|
|
||||||
>
|
|
||||||
{#each images as image, index (index)}
|
{#each images as image, index (index)}
|
||||||
<button
|
<button
|
||||||
onclick={() => openViewer(index)}
|
onclick={() => openViewer(index)}
|
||||||
@@ -145,14 +143,9 @@ onDestroy(() => {
|
|||||||
|
|
||||||
<!-- Image Viewer Modal -->
|
<!-- Image Viewer Modal -->
|
||||||
{#if isViewerOpen}
|
{#if isViewerOpen}
|
||||||
<div
|
<div class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in"
|
|
||||||
>
|
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
<div
|
<div class="absolute inset-0 bg-black/95 backdrop-blur-xl" onclick={closeViewer}></div>
|
||||||
class="absolute inset-0 bg-black/95 backdrop-blur-xl"
|
|
||||||
onclick={closeViewer}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Viewer Content -->
|
<!-- Viewer Content -->
|
||||||
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">
|
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">
|
||||||
@@ -166,9 +159,9 @@ onDestroy(() => {
|
|||||||
<div class="text-primary font-medium mb-3">
|
<div class="text-primary font-medium mb-3">
|
||||||
{$_("image_viewer.index", {
|
{$_("image_viewer.index", {
|
||||||
values: {
|
values: {
|
||||||
index: currentImageIndex + 1,
|
index: currentImageIndex + 1,
|
||||||
size: images.length
|
size: images.length,
|
||||||
}
|
},
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-zinc-400 max-w-2xl">
|
<p class="text-zinc-400 max-w-2xl">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,148 +1,184 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||||
import { getUserInitials } from "$lib/utils";
|
import { getUserInitials } from "$lib/utils";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
name?: string;
|
name?: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { user, onLogout }: Props = $props();
|
let { user, onLogout }: Props = $props();
|
||||||
|
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
let slidePosition = $state(0);
|
let slidePosition = $state(0);
|
||||||
let startX = 0;
|
let startX = 0;
|
||||||
let currentX = 0;
|
let currentX = 0;
|
||||||
let maxSlide = 117; // Maximum slide distance
|
let maxSlide = 117; // Maximum slide distance
|
||||||
let threshold = 0.75; // 70% threshold to trigger logout
|
let threshold = 0.75; // 70% threshold to trigger logout
|
||||||
|
|
||||||
// Calculate slide progress (0 to 1)
|
// Calculate slide progress (0 to 1)
|
||||||
const slideProgress = $derived(Math.min(slidePosition / maxSlide, 1));
|
const slideProgress = $derived(Math.min(slidePosition / maxSlide, 1));
|
||||||
const isNearThreshold = $derived(slideProgress > threshold);
|
const isNearThreshold = $derived(slideProgress > threshold);
|
||||||
|
|
||||||
const handleStart = (clientX: number) => {
|
const handleStart = (clientX: number) => {
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
startX = clientX;
|
startX = clientX;
|
||||||
currentX = clientX;
|
currentX = clientX;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMove = (clientX: number) => {
|
const handleMove = (clientX: number) => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
|
||||||
currentX = clientX;
|
currentX = clientX;
|
||||||
const deltaX = currentX - startX;
|
const deltaX = currentX - startX;
|
||||||
slidePosition = Math.max(0, Math.min(deltaX, maxSlide));
|
slidePosition = Math.max(0, Math.min(deltaX, maxSlide));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnd = () => {
|
const handleEnd = () => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
|
|
||||||
if (slideProgress >= threshold) {
|
if (slideProgress >= threshold) {
|
||||||
// Trigger logout
|
// Trigger logout
|
||||||
slidePosition = maxSlide;
|
slidePosition = maxSlide;
|
||||||
onLogout();
|
onLogout();
|
||||||
} else {
|
} else {
|
||||||
// Snap back
|
// Snap back
|
||||||
slidePosition = 0;
|
slidePosition = 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mouse events
|
// Mouse events
|
||||||
const handleMouseDown = (e: MouseEvent) => {
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleStart(e.clientX);
|
handleStart(e.clientX);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
handleMove(e.clientX);
|
handleMove(e.clientX);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
handleEnd();
|
handleEnd();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Touch events
|
// Touch events
|
||||||
const handleTouchStart = (e: TouchEvent) => {
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
handleStart(e.touches[0].clientX);
|
handleStart(e.touches[0].clientX);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchMove = (e: TouchEvent) => {
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleMove(e.touches[0].clientX);
|
handleMove(e.touches[0].clientX);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = () => {
|
||||||
handleEnd();
|
handleEnd();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add global event listeners when dragging
|
// Add global event listeners when dragging
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
document.addEventListener("touchmove", handleTouchMove, { passive: false });
|
document.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||||
document.addEventListener("touchend", handleTouchEnd);
|
document.addEventListener("touchend", handleTouchEnd);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
document.removeEventListener("touchmove", handleTouchMove);
|
document.removeEventListener("touchmove", handleTouchMove);
|
||||||
document.removeEventListener("touchend", handleTouchEnd);
|
document.removeEventListener("touchend", handleTouchEnd);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative h-10 w-40 rounded-full bg-muted/30 overflow-hidden select-none transition-all duration-300 bg-muted/40 shadow-lg shadow-accent/10 {isDragging ? 'cursor-grabbing' : ''}"
|
class="relative h-10 w-40 rounded-full bg-muted/30 overflow-hidden select-none transition-all duration-300 bg-muted/40 shadow-lg shadow-accent/10 {isDragging
|
||||||
style="background: linear-gradient(90deg,
|
? 'cursor-grabbing'
|
||||||
|
: ''}"
|
||||||
|
style="background: linear-gradient(90deg,
|
||||||
oklch(var(--primary) / 0.3) 0%,
|
oklch(var(--primary) / 0.3) 0%,
|
||||||
oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%,
|
oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%,
|
||||||
oklch(var(--accent) / {0.1 + slideProgress * 0.2}) {(1 - slideProgress) * 100}%,
|
oklch(var(--accent) / {0.1 + slideProgress * 0.2}) {(1 - slideProgress) * 100}%,
|
||||||
oklch(var(--accent) / {0.2 + slideProgress * 0.3}) 100%
|
oklch(var(--accent) / {0.2 + slideProgress * 0.3}) 100%
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
<!-- Background slide indicator -->
|
<!-- Background slide indicator -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 rounded-full transition-all duration-200"
|
class="absolute inset-0 rounded-full transition-all duration-200"
|
||||||
style="background: linear-gradient(90deg,
|
style="background: linear-gradient(90deg,
|
||||||
transparent 0%,
|
transparent 0%,
|
||||||
transparent {Math.max(0, slideProgress * 100 - 20)}%,
|
transparent {Math.max(0, slideProgress * 100 - 20)}%,
|
||||||
oklch(var(--accent) / {slideProgress * 0.1}) {slideProgress * 100}%,
|
oklch(var(--accent) / {slideProgress * 0.1}) {slideProgress * 100}%,
|
||||||
oklch(var(--accent) / {slideProgress * 0.2}) 100%
|
oklch(var(--accent) / {slideProgress * 0.2}) 100%
|
||||||
)"
|
)"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Sliding user info -->
|
<!-- Sliding user info -->
|
||||||
<button class="cursor-grab absolute left-0 top-0 h-full flex items-center gap-3 px-2 transition-all duration-200 ease-out rounded-full bg-background/80 backdrop-blur-sm border border-border/50 bg-background/90 border-primary/20 {isDragging ? '' : 'transition-all duration-300 ease-out'}" style="transform: translateX({slidePosition}px); width: calc(100% - {slidePosition}px);" onmousedown={handleMouseDown} ontouchstart={handleTouchStart}>
|
<button
|
||||||
<Avatar class="h-7 w-7 ring-2 ring-accent/20 transition-all duration-200 {isNearThreshold ? 'ring-destructive/40' : ''}" style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}">
|
class="cursor-grab absolute left-0 top-0 h-full flex items-center gap-3 px-2 transition-all duration-200 ease-out rounded-full bg-background/80 backdrop-blur-sm border border-border/50 bg-background/90 border-primary/20 {isDragging
|
||||||
<AvatarImage src={user.avatar} alt={user.name || user.email} />
|
? ''
|
||||||
<AvatarFallback class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200 {isNearThreshold ? 'from-destructive to-destructive/80' : ''}">
|
: 'transition-all duration-300 ease-out'}"
|
||||||
{getUserInitials(user.name || user.email)}
|
style="transform: translateX({slidePosition}px); width: calc(100% - {slidePosition}px);"
|
||||||
</AvatarFallback>
|
onmousedown={handleMouseDown}
|
||||||
</Avatar>
|
ontouchstart={handleTouchStart}
|
||||||
<div class="text-left flex flex-col min-w-0 flex-1">
|
>
|
||||||
<span class="text-sm font-medium text-foreground leading-none truncate transition-all duration-200 {isNearThreshold ? 'text-destructive' : ''}" style="opacity: {Math.max(0.15, 1 - slideProgress * 1.5)}">{user?.name ? user.name.split(" ")[0] : "User"}</span>
|
<Avatar
|
||||||
<span class="text-xs text-muted-foreground leading-none transition-all duration-200 {isNearThreshold ? 'text-destructive/70' : ''}" style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}">
|
class="h-7 w-7 ring-2 ring-accent/20 transition-all duration-200 {isNearThreshold
|
||||||
{slideProgress > 0.3 ? "Logout" : "Online"}
|
? 'ring-destructive/40'
|
||||||
</span>
|
: ''}"
|
||||||
</div>
|
style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}"
|
||||||
</button>
|
>
|
||||||
|
<AvatarImage src={user.avatar} alt={user.name || user.email} />
|
||||||
|
<AvatarFallback
|
||||||
|
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200 {isNearThreshold
|
||||||
|
? 'from-destructive to-destructive/80'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{getUserInitials(user.name || user.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="text-left flex flex-col min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium text-foreground leading-none truncate transition-all duration-200 {isNearThreshold
|
||||||
|
? 'text-destructive'
|
||||||
|
: ''}"
|
||||||
|
style="opacity: {Math.max(0.15, 1 - slideProgress * 1.5)}"
|
||||||
|
>{user?.name ? user.name.split(" ")[0] : "User"}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-xs text-muted-foreground leading-none transition-all duration-200 {isNearThreshold
|
||||||
|
? 'text-destructive/70'
|
||||||
|
: ''}"
|
||||||
|
style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}"
|
||||||
|
>
|
||||||
|
{slideProgress > 0.3 ? "Logout" : "Online"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Logout icon area -->
|
<!-- Logout icon area -->
|
||||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 {isNearThreshold ? 'bg-destructive text-destructive-foreground scale-110' : 'bg-transparent text-foreground'}">
|
<div
|
||||||
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 transition-transform duration-200 {isNearThreshold ? 'scale-110' : ''}" ></span>
|
class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 {isNearThreshold
|
||||||
</div>
|
? 'bg-destructive text-destructive-foreground scale-110'
|
||||||
|
: 'bg-transparent text-foreground'}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--logout-circle-r-line] h-4 w-4 transition-transform duration-200 {isNearThreshold
|
||||||
|
? 'scale-110'
|
||||||
|
: ''}"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress indicator -->
|
||||||
<!-- Progress indicator -->
|
<!-- <div class="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-200 rounded-full" style="width: {slideProgress * 100}%"></div> -->
|
||||||
<!-- <div class="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-200 rounded-full" style="width: {slideProgress * 100}%"></div> -->
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { env } from "$env/dynamic/public";
|
import { env } from "$env/dynamic/public";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
image = `${env.PUBLIC_URL || "http://localhost:3000"}/img/kamasutra.jpg`,
|
image = `${env.PUBLIC_URL || "http://localhost:3000"}/img/kamasutra.jpg`,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$_("head.title", { values: { title } })}</title>
|
<title>{$_("head.title", { values: { title } })}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<meta
|
<meta property="og:title" content={$_("head.title", { values: { title } })} />
|
||||||
property="og:title"
|
<meta property="og:description" content={description} />
|
||||||
content={$_("head.title", { values: { title } })}
|
<meta property="og:image" content={image} />
|
||||||
/>
|
|
||||||
<meta property="og:description" content={description} />
|
|
||||||
<meta property="og:image" content={image} />
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|||||||
@@ -1,180 +1,163 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import type { Recording } from "$lib/types";
|
import type { Recording } from "$lib/types";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
recording: Recording;
|
recording: Recording;
|
||||||
onPlay?: (id: string) => void;
|
onPlay?: (id: string) => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { recording, onPlay, onDelete }: Props = $props();
|
let { recording, onPlay, onDelete }: Props = $props();
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
function formatDuration(ms: number): string {
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
const seconds = totalSeconds % 60;
|
const seconds = totalSeconds % 60;
|
||||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusColor(status: string): string {
|
function getStatusColor(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "published":
|
case "published":
|
||||||
return "text-green-400 bg-green-400/20";
|
return "text-green-400 bg-green-400/20";
|
||||||
case "draft":
|
case "draft":
|
||||||
return "text-yellow-400 bg-yellow-400/20";
|
return "text-yellow-400 bg-yellow-400/20";
|
||||||
case "archived":
|
case "archived":
|
||||||
return "text-red-400 bg-red-400/20";
|
return "text-red-400 bg-red-400/20";
|
||||||
default:
|
default:
|
||||||
return "text-gray-400 bg-gray-400/20";
|
return "text-gray-400 bg-gray-400/20";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
|
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="pb-3">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<h3
|
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
|
||||||
class="font-semibold text-card-foreground group-hover:text-primary transition-colors"
|
{recording.title}
|
||||||
>
|
</h3>
|
||||||
{recording.title}
|
<span class={cn("text-xs px-2 py-0.5 rounded-full", getStatusColor(recording.status))}>
|
||||||
</h3>
|
{$_(`recording_card.status_${recording.status}`)}
|
||||||
<span
|
</span>
|
||||||
class={cn(
|
</div>
|
||||||
"text-xs px-2 py-0.5 rounded-full",
|
{#if recording.description}
|
||||||
getStatusColor(recording.status),
|
<p class="text-sm text-muted-foreground line-clamp-2">
|
||||||
)}
|
{recording.description}
|
||||||
>
|
</p>
|
||||||
{$_(`recording_card.status_${recording.status}`)}
|
{/if}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if recording.description}
|
</CardHeader>
|
||||||
<p class="text-sm text-muted-foreground line-clamp-2">
|
|
||||||
{recording.description}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<!-- Stats Grid -->
|
<!-- Stats Grid -->
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<div
|
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
|
||||||
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
|
<span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span>
|
||||||
>
|
<span class="text-xs text-muted-foreground">{$_("recording_card.duration")}</span>
|
||||||
<span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span>
|
<span class="font-medium text-sm">{formatDuration(recording.duration)}</span>
|
||||||
<span class="text-xs text-muted-foreground"
|
</div>
|
||||||
>{$_("recording_card.duration")}</span
|
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
|
||||||
>
|
<span class="icon-[ri--pulse-line] w-4 h-4 text-accent mb-1"></span>
|
||||||
<span class="font-medium text-sm">{formatDuration(recording.duration)}</span>
|
<span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span>
|
||||||
</div>
|
<span class="font-medium text-sm">{recording.events.length}</span>
|
||||||
<div
|
</div>
|
||||||
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
|
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
|
||||||
>
|
<span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span>
|
||||||
<span class="icon-[ri--pulse-line] w-4 h-4 text-accent mb-1"></span>
|
<span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
|
||||||
<span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span>
|
<span class="font-medium text-sm">{recording.device_info.length}</span>
|
||||||
<span class="font-medium text-sm">{recording.events.length}</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span>
|
|
||||||
<span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
|
|
||||||
<span class="font-medium text-sm">{recording.device_info.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Device Info -->
|
<!-- Device Info -->
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#each recording.device_info.slice(0, 2) as device (device.name)}
|
{#each recording.device_info.slice(0, 2) as device (device.name)}
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1"
|
class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--rocket-line] w-3 h-3"></span>
|
<span class="icon-[ri--rocket-line] w-3 h-3"></span>
|
||||||
<span>{device.name}</span>
|
<span>{device.name}</span>
|
||||||
<span class="text-xs opacity-60">• {device.capabilities.join(", ")}</span>
|
<span class="text-xs opacity-60">• {device.capabilities.join(", ")}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if recording.device_info.length > 2}
|
{#if recording.device_info.length > 2}
|
||||||
<div class="text-xs text-muted-foreground/60 px-2">
|
<div class="text-xs text-muted-foreground/60 px-2">
|
||||||
+{recording.device_info.length - 2} more device{recording.device_info.length -
|
+{recording.device_info.length - 2} more device{recording.device_info.length - 2 > 1
|
||||||
2 >
|
? "s"
|
||||||
1
|
: ""}
|
||||||
? "s"
|
</div>
|
||||||
: ""}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
{#if recording.tags && recording.tags.length > 0}
|
{#if recording.tags && recording.tags.length > 0}
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#each recording.tags as tag (tag)}
|
{#each recording.tags as tag (tag)}
|
||||||
<span
|
<span
|
||||||
class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"
|
class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Metadata -->
|
<!-- Metadata -->
|
||||||
<div class="flex items-center justify-between text-xs text-muted-foreground pt-2">
|
<div class="flex items-center justify-between text-xs text-muted-foreground pt-2">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span>
|
<span>
|
||||||
{new Date(recording.date_created).toLocaleDateString()}
|
{new Date(recording.date_created).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
{#if recording.public}
|
{#if recording.public}
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<span class="icon-[ri--global-line] w-3 h-3"></span>
|
<span class="icon-[ri--global-line] w-3 h-3"></span>
|
||||||
{$_("recording_card.public")}
|
{$_("recording_card.public")}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<span class="icon-[ri--lock-line] w-3 h-3"></span>
|
<span class="icon-[ri--lock-line] w-3 h-3"></span>
|
||||||
{$_("recording_card.private")}
|
{$_("recording_card.private")}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if recording.linked_video}
|
{#if recording.linked_video}
|
||||||
<span class="flex items-center gap-1 text-accent">
|
<span class="flex items-center gap-1 text-accent">
|
||||||
<span class="icon-[ri--video-line] w-3 h-3"></span>
|
<span class="icon-[ri--video-line] w-3 h-3"></span>
|
||||||
{$_("recording_card.linked_video")}
|
{$_("recording_card.linked_video")}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="flex gap-2 pt-2">
|
||||||
{#if onPlay}
|
{#if onPlay}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => onPlay?.(recording.id)}
|
onclick={() => onPlay?.(recording.id)}
|
||||||
class="flex-1 cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
class="flex-1 cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--play-fill] w-4 h-4 mr-1"></span>
|
<span class="icon-[ri--play-fill] w-4 h-4 mr-1"></span>
|
||||||
{$_("recording_card.play")}
|
{$_("recording_card.play")}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if onDelete}
|
{#if onDelete}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => onDelete?.(recording.id)}
|
onclick={() => onDelete?.(recording.id)}
|
||||||
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
|
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
|
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
onclick: () => void;
|
onclick: () => void;
|
||||||
icon: string;
|
icon: string;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onclick, icon, label }: Props = $props();
|
let { onclick, icon, label }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
{onclick}
|
{onclick}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors cursor-pointer"
|
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<span class={icon + " w-4 h-4 text-primary"}></span>
|
<span class={icon + " w-4 h-4 text-primary"}></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,110 +1,110 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import ShareButton from "./share-button.svelte";
|
import ShareButton from "./share-button.svelte";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import type { ShareContent } from "$lib/types";
|
import type { ShareContent } from "$lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: ShareContent;
|
content: ShareContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { content }: Props = $props();
|
let { content }: Props = $props();
|
||||||
|
|
||||||
// Share handlers
|
// Share handlers
|
||||||
const shareToX = () => {
|
const shareToX = () => {
|
||||||
const text = `${content.title} - ${content.description}`;
|
const text = `${content.title} - ${content.description}`;
|
||||||
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(content.url)}`;
|
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(content.url)}`;
|
||||||
window.open(url, "_blank", "width=600,height=400");
|
window.open(url, "_blank", "width=600,height=400");
|
||||||
toast.success($_("sharing_popup.success.x"));
|
toast.success($_("sharing_popup.success.x"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const shareToFacebook = () => {
|
const shareToFacebook = () => {
|
||||||
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(content.url)}"e=${encodeURIComponent(content.title)}`;
|
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(content.url)}"e=${encodeURIComponent(content.title)}`;
|
||||||
window.open(url, "_blank", "width=600,height=400");
|
window.open(url, "_blank", "width=600,height=400");
|
||||||
toast.success($_("sharing_popup.success.facebook"));
|
toast.success($_("sharing_popup.success.facebook"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const shareViaEmail = () => {
|
const shareViaEmail = () => {
|
||||||
const subject = encodeURIComponent(content.title);
|
const subject = encodeURIComponent(content.title);
|
||||||
const body = encodeURIComponent(`${content.description}\n\n${content.url}`);
|
const body = encodeURIComponent(`${content.description}\n\n${content.url}`);
|
||||||
const url = `mailto:?subject=${subject}&body=${body}`;
|
const url = `mailto:?subject=${subject}&body=${body}`;
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
toast.success($_("sharing_popup.success.email"));
|
toast.success($_("sharing_popup.success.email"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const shareToWhatsApp = () => {
|
const shareToWhatsApp = () => {
|
||||||
const text = `${content.title}\n\n${content.description}\n\n${content.url}`;
|
const text = `${content.title}\n\n${content.description}\n\n${content.url}`;
|
||||||
const url = `https://wa.me/?text=${encodeURIComponent(text)}`;
|
const url = `https://wa.me/?text=${encodeURIComponent(text)}`;
|
||||||
window.open(url, "_blank");
|
window.open(url, "_blank");
|
||||||
toast.success($_("sharing_popup.success.whatsapp"));
|
toast.success($_("sharing_popup.success.whatsapp"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const shareToTelegram = () => {
|
const shareToTelegram = () => {
|
||||||
const text = `${content.title}\n\n${content.description}`;
|
const text = `${content.title}\n\n${content.description}`;
|
||||||
const url = `https://t.me/share/url?url=${encodeURIComponent(content.url)}&text=${encodeURIComponent(text)}`;
|
const url = `https://t.me/share/url?url=${encodeURIComponent(content.url)}&text=${encodeURIComponent(text)}`;
|
||||||
window.open(url, "_blank");
|
window.open(url, "_blank");
|
||||||
toast.success($_("sharing_popup.success.telegram"));
|
toast.success($_("sharing_popup.success.telegram"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyLink = async () => {
|
const copyLink = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(content.url);
|
await navigator.clipboard.writeText(content.url);
|
||||||
toast.success($_("sharing_popup.success.copy"));
|
toast.success($_("sharing_popup.success.copy"));
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback for older browsers
|
// Fallback for older browsers
|
||||||
const textArea = document.createElement("textarea");
|
const textArea = document.createElement("textarea");
|
||||||
textArea.value = content.url;
|
textArea.value = content.url;
|
||||||
document.body.appendChild(textArea);
|
document.body.appendChild(textArea);
|
||||||
textArea.select();
|
textArea.select();
|
||||||
document.execCommand("copy");
|
document.execCommand("copy");
|
||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
toast.success($_("sharing_popup.success.copy"));
|
toast.success($_("sharing_popup.success.copy"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="text-center space-y-4">
|
<div class="text-center space-y-4">
|
||||||
<h4 class="text-sm font-medium text-muted-foreground">
|
<h4 class="text-sm font-medium text-muted-foreground">
|
||||||
{$_("sharing_popup.subtitle")}
|
{$_("sharing_popup.subtitle")}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div class="flex justify-center gap-3 flex-wrap">
|
<div class="flex justify-center gap-3 flex-wrap">
|
||||||
<ShareButton
|
<ShareButton
|
||||||
onclick={shareToX}
|
onclick={shareToX}
|
||||||
icon="icon-[ri--twitter-x-line]"
|
icon="icon-[ri--twitter-x-line]"
|
||||||
label={$_("sharing_popup.share.x")}
|
label={$_("sharing_popup.share.x")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShareButton
|
<ShareButton
|
||||||
onclick={shareToFacebook}
|
onclick={shareToFacebook}
|
||||||
icon="icon-[ri--facebook-line]"
|
icon="icon-[ri--facebook-line]"
|
||||||
label={$_("sharing_popup.share.facebook")}
|
label={$_("sharing_popup.share.facebook")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShareButton
|
<ShareButton
|
||||||
onclick={shareViaEmail}
|
onclick={shareViaEmail}
|
||||||
icon="icon-[ri--mail-line]"
|
icon="icon-[ri--mail-line]"
|
||||||
label={$_("sharing_popup.share.email")}
|
label={$_("sharing_popup.share.email")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShareButton
|
<ShareButton
|
||||||
onclick={shareToWhatsApp}
|
onclick={shareToWhatsApp}
|
||||||
icon="icon-[ri--whatsapp-line]"
|
icon="icon-[ri--whatsapp-line]"
|
||||||
label={$_("sharing_popup.share.whatsapp")}
|
label={$_("sharing_popup.share.whatsapp")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShareButton
|
<ShareButton
|
||||||
onclick={shareToTelegram}
|
onclick={shareToTelegram}
|
||||||
icon="icon-[ri--telegram-2-line]"
|
icon="icon-[ri--telegram-2-line]"
|
||||||
label={$_("sharing_popup.share.telegram")}
|
label={$_("sharing_popup.share.telegram")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShareButton
|
<ShareButton
|
||||||
onclick={copyLink}
|
onclick={copyLink}
|
||||||
icon="icon-[ri--file-copy-line]"
|
icon="icon-[ri--file-copy-line]"
|
||||||
label={$_("sharing_popup.share.copy")}
|
label={$_("sharing_popup.share.copy")}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -1,89 +1,89 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "$lib/components/ui/dialog";
|
} from "$lib/components/ui/dialog";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Separator } from "$lib/components/ui/separator";
|
import { Separator } from "$lib/components/ui/separator";
|
||||||
import ShareServices from "./share-services.svelte";
|
import ShareServices from "./share-services.svelte";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface ShareContent {
|
interface ShareContent {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
url: string;
|
url: string;
|
||||||
type: "video" | "model" | "article" | "link";
|
type: "video" | "model" | "article" | "link";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
content: ShareContent;
|
content: ShareContent;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { open = $bindable(), content }: Props = $props();
|
let { open = $bindable(), content }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog bind:open>
|
<Dialog bind:open>
|
||||||
<DialogContent class="sm:max-w-md">
|
<DialogContent class="sm:max-w-md">
|
||||||
<DialogHeader class="space-y-4">
|
<DialogHeader class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center shrink-0 grow-0"
|
class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center shrink-0 grow-0"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--share-2-line] text-primary-foreground"></span>
|
<span class="icon-[ri--share-2-line] text-primary-foreground"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div class="">
|
||||||
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
||||||
>{$_("sharing_popup.title")}</DialogTitle
|
>{$_("sharing_popup.title")}</DialogTitle
|
||||||
>
|
|
||||||
<DialogDescription class="text-left text-sm">
|
|
||||||
{$_("sharing_popup.description", {
|
|
||||||
values: { type: content.type },
|
|
||||||
})}
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Preview -->
|
|
||||||
<div class="text-left bg-muted/60 rounded-lg p-4 space-y-2">
|
|
||||||
<h4 class="font-medium text-sm text-primary-foreground">
|
|
||||||
{content.title}
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs text-muted-foreground">{content.description}</p>
|
|
||||||
<div class="flex items-center gap-2 text-xs">
|
|
||||||
<span class="px-2 py-1 bg-primary/10 text-primary rounded-full capitalize">
|
|
||||||
{content.type}
|
|
||||||
</span>
|
|
||||||
<span class="text-muted-foreground text-clip">{content.url}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Separator class="my-4" />
|
|
||||||
|
|
||||||
<!-- Share Services -->
|
|
||||||
<ShareServices {content} />
|
|
||||||
|
|
||||||
<Separator class="my-4" />
|
|
||||||
|
|
||||||
<!-- Close Button -->
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => (open = false)}
|
|
||||||
class="text-muted-foreground hover:text-foreground cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--close-large-line]"></span>
|
<DialogDescription class="text-left text-sm">
|
||||||
{$_("sharing_popup.close")}
|
{$_("sharing_popup.description", {
|
||||||
</Button>
|
values: { type: content.type },
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Preview -->
|
||||||
|
<div class="text-left bg-muted/60 rounded-lg p-4 space-y-2">
|
||||||
|
<h4 class="font-medium text-sm text-primary-foreground">
|
||||||
|
{content.title}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-muted-foreground">{content.description}</p>
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<span class="px-2 py-1 bg-primary/10 text-primary rounded-full capitalize">
|
||||||
|
{content.type}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted-foreground text-clip">{content.url}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Separator class="my-4" />
|
||||||
|
|
||||||
|
<!-- Share Services -->
|
||||||
|
<ShareServices {content} />
|
||||||
|
|
||||||
|
<Separator class="my-4" />
|
||||||
|
|
||||||
|
<!-- Close Button -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (open = false)}
|
||||||
|
class="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--close-large-line]"></span>
|
||||||
|
{$_("sharing_popup.close")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert-description"
|
data-slot="alert-description"
|
||||||
class={cn(
|
class={cn(
|
||||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert-title"
|
data-slot="alert-title"
|
||||||
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
export const alertVariants = tv({
|
export const alertVariants = tv({
|
||||||
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-card text-card-foreground",
|
default: "bg-card text-card-foreground",
|
||||||
destructive:
|
destructive:
|
||||||
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
variant?: AlertVariant;
|
variant?: AlertVariant;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert"
|
data-slot="alert"
|
||||||
class={cn(alertVariants({ variant }), className)}
|
class={cn(alertVariants({ variant }), className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import Title from "./alert-title.svelte";
|
|||||||
export { alertVariants, type AlertVariant } from "./alert.svelte";
|
export { alertVariants, type AlertVariant } from "./alert.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
Description,
|
Description,
|
||||||
Title,
|
Title,
|
||||||
//
|
//
|
||||||
Root as Alert,
|
Root as Alert,
|
||||||
Description as AlertDescription,
|
Description as AlertDescription,
|
||||||
Title as AlertTitle,
|
Title as AlertTitle,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: AvatarPrimitive.FallbackProps = $props();
|
}: AvatarPrimitive.FallbackProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="avatar-fallback"
|
data-slot="avatar-fallback"
|
||||||
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: AvatarPrimitive.ImageProps = $props();
|
}: AvatarPrimitive.ImageProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AvatarPrimitive.Image
|
<AvatarPrimitive.Image
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="avatar-image"
|
data-slot="avatar-image"
|
||||||
class={cn("aspect-square size-full", className)}
|
class={cn("aspect-square size-full", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
loadingStatus = $bindable("loading"),
|
loadingStatus = $bindable("loading"),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: AvatarPrimitive.RootProps = $props();
|
}: AvatarPrimitive.RootProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AvatarPrimitive.Root
|
<AvatarPrimitive.Root
|
||||||
bind:ref
|
bind:ref
|
||||||
bind:loadingStatus
|
bind:loadingStatus
|
||||||
data-slot="avatar"
|
data-slot="avatar"
|
||||||
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import Image from "./avatar-image.svelte";
|
|||||||
import Fallback from "./avatar-fallback.svelte";
|
import Fallback from "./avatar-fallback.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
Image,
|
Image,
|
||||||
Fallback,
|
Fallback,
|
||||||
//
|
//
|
||||||
Root as Avatar,
|
Root as Avatar,
|
||||||
Image as AvatarImage,
|
Image as AvatarImage,
|
||||||
Fallback as AvatarFallback,
|
Fallback as AvatarFallback,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,86 +1,80 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
import type {
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||||
HTMLAnchorAttributes,
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
HTMLButtonAttributes,
|
|
||||||
} from "svelte/elements";
|
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
export const buttonVariants = tv({
|
||||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
destructive:
|
||||||
destructive:
|
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||||
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
outline:
|
||||||
outline:
|
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||||
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
secondary:
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
ghost:
|
},
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
size: {
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
},
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
size: {
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
icon: "size-9",
|
||||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
},
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
},
|
||||||
icon: "size-9",
|
defaultVariants: {
|
||||||
},
|
variant: "default",
|
||||||
},
|
size: "default",
|
||||||
defaultVariants: {
|
},
|
||||||
variant: "default",
|
});
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||||
|
|
||||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||||
WithElementRef<HTMLAnchorAttributes> & {
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let {
|
let {
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
size = "default",
|
size = "default",
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
href = undefined,
|
href = undefined,
|
||||||
type = "button",
|
type = "button",
|
||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: ButtonProps = $props();
|
}: ButtonProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
{#if href}
|
||||||
<a
|
<a
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
href={disabled ? undefined : href}
|
href={disabled ? undefined : href}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
role={disabled ? "link" : undefined}
|
role={disabled ? "link" : undefined}
|
||||||
tabindex={disabled ? -1 : undefined}
|
tabindex={disabled ? -1 : undefined}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
{type}
|
{type}
|
||||||
{disabled}
|
{disabled}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import Root, {
|
import Root, {
|
||||||
type ButtonProps,
|
type ButtonProps,
|
||||||
type ButtonSize,
|
type ButtonSize,
|
||||||
type ButtonVariant,
|
type ButtonVariant,
|
||||||
buttonVariants,
|
buttonVariants,
|
||||||
} from "./button.svelte";
|
} from "./button.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
type ButtonProps as Props,
|
type ButtonProps as Props,
|
||||||
//
|
//
|
||||||
Root as Button,
|
Root as Button,
|
||||||
buttonVariants,
|
buttonVariants,
|
||||||
type ButtonProps,
|
type ButtonProps,
|
||||||
type ButtonSize,
|
type ButtonSize,
|
||||||
type ButtonVariant,
|
type ButtonVariant,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-description"
|
data-slot="card-description"
|
||||||
class={cn("text-muted-foreground text-sm", className)}
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
class={cn(
|
class={cn(
|
||||||
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
|
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
class={cn("font-semibold leading-none", className)}
|
class={cn("font-semibold leading-none", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,19 +7,19 @@ import Title from "./card-title.svelte";
|
|||||||
import Action from "./card-action.svelte";
|
import Action from "./card-action.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
Content,
|
Content,
|
||||||
Description,
|
Description,
|
||||||
Footer,
|
Footer,
|
||||||
Header,
|
Header,
|
||||||
Title,
|
Title,
|
||||||
Action,
|
Action,
|
||||||
//
|
//
|
||||||
Root as Card,
|
Root as Card,
|
||||||
Content as CardContent,
|
Content as CardContent,
|
||||||
Description as CardDescription,
|
Description as CardDescription,
|
||||||
Footer as CardFooter,
|
Footer as CardFooter,
|
||||||
Header as CardHeader,
|
Header as CardHeader,
|
||||||
Title as CardTitle,
|
Title as CardTitle,
|
||||||
Action as CardAction,
|
Action as CardAction,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
||||||
import CheckIcon from "@lucide/svelte/icons/check";
|
import CheckIcon from "@lucide/svelte/icons/check";
|
||||||
import MinusIcon from "@lucide/svelte/icons/minus";
|
import MinusIcon from "@lucide/svelte/icons/minus";
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
checked = $bindable(false),
|
checked = $bindable(false),
|
||||||
indeterminate = $bindable(false),
|
indeterminate = $bindable(false),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
|
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
class={cn(
|
class={cn(
|
||||||
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
bind:checked
|
bind:checked
|
||||||
bind:indeterminate
|
bind:indeterminate
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{#snippet children({ checked, indeterminate })}
|
{#snippet children({ checked, indeterminate })}
|
||||||
<div data-slot="checkbox-indicator" class="text-current transition-none">
|
<div data-slot="checkbox-indicator" class="text-current transition-none">
|
||||||
{#if checked}
|
{#if checked}
|
||||||
<CheckIcon class="size-3.5" />
|
<CheckIcon class="size-3.5" />
|
||||||
{:else if indeterminate}
|
{:else if indeterminate}
|
||||||
<MinusIcon class="size-3.5" />
|
<MinusIcon class="size-3.5" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Root from "./checkbox.svelte";
|
import Root from "./checkbox.svelte";
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
//
|
//
|
||||||
Root as Checkbox,
|
Root as Checkbox,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
import XIcon from "@lucide/svelte/icons/x";
|
import XIcon from "@lucide/svelte/icons/x";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import * as Dialog from "./index.js";
|
import * as Dialog from "./index.js";
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
portalProps,
|
portalProps,
|
||||||
children,
|
children,
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||||
portalProps?: DialogPrimitive.PortalProps;
|
portalProps?: DialogPrimitive.PortalProps;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Portal {...portalProps}>
|
<Dialog.Portal {...portalProps}>
|
||||||
<Dialog.Overlay />
|
<Dialog.Overlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{#if showCloseButton}
|
{#if showCloseButton}
|
||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
{/if}
|
{/if}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: DialogPrimitive.DescriptionProps = $props();
|
}: DialogPrimitive.DescriptionProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
class={cn("text-muted-foreground text-sm", className)}
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="dialog-header"
|
data-slot="dialog-header"
|
||||||
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: DialogPrimitive.OverlayProps = $props();
|
}: DialogPrimitive.OverlayProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
class={cn(
|
class={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: DialogPrimitive.TitleProps = $props();
|
}: DialogPrimitive.TitleProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
class={cn("text-lg font-semibold leading-none", className)}
|
class={cn("text-lg font-semibold leading-none", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -13,25 +13,25 @@ const Root = DialogPrimitive.Root;
|
|||||||
const Portal = DialogPrimitive.Portal;
|
const Portal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
Title,
|
Title,
|
||||||
Portal,
|
Portal,
|
||||||
Footer,
|
Footer,
|
||||||
Header,
|
Header,
|
||||||
Trigger,
|
Trigger,
|
||||||
Overlay,
|
Overlay,
|
||||||
Content,
|
Content,
|
||||||
Description,
|
Description,
|
||||||
Close,
|
Close,
|
||||||
//
|
//
|
||||||
Root as Dialog,
|
Root as Dialog,
|
||||||
Title as DialogTitle,
|
Title as DialogTitle,
|
||||||
Portal as DialogPortal,
|
Portal as DialogPortal,
|
||||||
Footer as DialogFooter,
|
Footer as DialogFooter,
|
||||||
Header as DialogHeader,
|
Header as DialogHeader,
|
||||||
Trigger as DialogTrigger,
|
Trigger as DialogTrigger,
|
||||||
Overlay as DialogOverlay,
|
Overlay as DialogOverlay,
|
||||||
Content as DialogContent,
|
Content as DialogContent,
|
||||||
Description as DialogDescription,
|
Description as DialogDescription,
|
||||||
Close as DialogClose,
|
Close as DialogClose,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,183 +3,174 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from "$lib/utils/utils";
|
import { cn } from "$lib/utils/utils";
|
||||||
import UploadIcon from "@lucide/svelte/icons/upload";
|
import UploadIcon from "@lucide/svelte/icons/upload";
|
||||||
import { displaySize } from ".";
|
import { displaySize } from ".";
|
||||||
import { useId } from "bits-ui";
|
import { useId } from "bits-ui";
|
||||||
import type { FileDropZoneProps, FileRejectedReason } from "./types";
|
import type { FileDropZoneProps, FileRejectedReason } from "./types";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
id = useId(),
|
id = useId(),
|
||||||
children,
|
children,
|
||||||
maxFiles,
|
maxFiles,
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
fileCount,
|
fileCount,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onUpload,
|
onUpload,
|
||||||
onFileRejected,
|
onFileRejected,
|
||||||
accept,
|
accept,
|
||||||
class: className,
|
class: className,
|
||||||
...rest
|
...rest
|
||||||
}: FileDropZoneProps = $props();
|
}: FileDropZoneProps = $props();
|
||||||
|
|
||||||
if (maxFiles !== undefined && fileCount === undefined) {
|
if (maxFiles !== undefined && fileCount === undefined) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
|
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let uploading = $state(false);
|
let uploading = $state(false);
|
||||||
|
|
||||||
const drop = async (
|
const drop = async (
|
||||||
e: DragEvent & {
|
e: DragEvent & {
|
||||||
currentTarget: EventTarget & HTMLLabelElement;
|
currentTarget: EventTarget & HTMLLabelElement;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (disabled || !canUploadFiles) return;
|
if (disabled || !canUploadFiles) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
|
const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
|
||||||
|
|
||||||
await upload(droppedFiles);
|
await upload(droppedFiles);
|
||||||
};
|
};
|
||||||
|
|
||||||
const change = async (
|
const change = async (
|
||||||
e: Event & {
|
e: Event & {
|
||||||
currentTarget: EventTarget & HTMLInputElement;
|
currentTarget: EventTarget & HTMLInputElement;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
|
||||||
const selectedFiles = e.currentTarget.files;
|
const selectedFiles = e.currentTarget.files;
|
||||||
|
|
||||||
if (!selectedFiles) return;
|
if (!selectedFiles) return;
|
||||||
|
|
||||||
await upload(Array.from(selectedFiles));
|
await upload(Array.from(selectedFiles));
|
||||||
|
|
||||||
// this if a file fails and we upload the same file again we still get feedback
|
// this if a file fails and we upload the same file again we still get feedback
|
||||||
(e.target as HTMLInputElement).value = "";
|
(e.target as HTMLInputElement).value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldAcceptFile = (
|
const shouldAcceptFile = (file: File, fileNumber: number): FileRejectedReason | undefined => {
|
||||||
file: File,
|
if (maxFileSize !== undefined && file.size > maxFileSize) return "Maximum file size exceeded";
|
||||||
fileNumber: number,
|
|
||||||
): FileRejectedReason | undefined => {
|
|
||||||
if (maxFileSize !== undefined && file.size > maxFileSize)
|
|
||||||
return "Maximum file size exceeded";
|
|
||||||
|
|
||||||
if (maxFiles !== undefined && fileNumber > maxFiles)
|
if (maxFiles !== undefined && fileNumber > maxFiles) return "Maximum files uploaded";
|
||||||
return "Maximum files uploaded";
|
|
||||||
|
|
||||||
if (!accept) return undefined;
|
if (!accept) return undefined;
|
||||||
|
|
||||||
const acceptedTypes = accept.split(",").map((a) => a.trim().toLowerCase());
|
const acceptedTypes = accept.split(",").map((a) => a.trim().toLowerCase());
|
||||||
const fileType = file.type.toLowerCase();
|
const fileType = file.type.toLowerCase();
|
||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
|
|
||||||
const isAcceptable = acceptedTypes.some((pattern) => {
|
const isAcceptable = acceptedTypes.some((pattern) => {
|
||||||
// check extension like .mp4
|
// check extension like .mp4
|
||||||
if (fileType.startsWith(".")) {
|
if (fileType.startsWith(".")) {
|
||||||
return fileName.endsWith(pattern);
|
return fileName.endsWith(pattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if pattern has wild card like video/*
|
// if pattern has wild card like video/*
|
||||||
if (pattern.endsWith("/*")) {
|
if (pattern.endsWith("/*")) {
|
||||||
const baseType = pattern.slice(0, pattern.indexOf("/*"));
|
const baseType = pattern.slice(0, pattern.indexOf("/*"));
|
||||||
return fileType.startsWith(baseType + "/");
|
return fileType.startsWith(baseType + "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise it must be a specific type like video/mp4
|
// otherwise it must be a specific type like video/mp4
|
||||||
return fileType === pattern;
|
return fileType === pattern;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isAcceptable) return "File type not allowed";
|
if (!isAcceptable) return "File type not allowed";
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const upload = async (uploadFiles: File[]) => {
|
const upload = async (uploadFiles: File[]) => {
|
||||||
uploading = true;
|
uploading = true;
|
||||||
|
|
||||||
const validFiles: File[] = [];
|
const validFiles: File[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < uploadFiles.length; i++) {
|
for (let i = 0; i < uploadFiles.length; i++) {
|
||||||
const file = uploadFiles[i];
|
const file = uploadFiles[i];
|
||||||
|
|
||||||
const rejectedReason = shouldAcceptFile(file, (fileCount ?? 0) + i + 1);
|
const rejectedReason = shouldAcceptFile(file, (fileCount ?? 0) + i + 1);
|
||||||
|
|
||||||
if (rejectedReason) {
|
if (rejectedReason) {
|
||||||
onFileRejected?.({ file, reason: rejectedReason });
|
onFileRejected?.({ file, reason: rejectedReason });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
validFiles.push(file);
|
validFiles.push(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
await onUpload(validFiles);
|
await onUpload(validFiles);
|
||||||
|
|
||||||
uploading = false;
|
uploading = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const canUploadFiles = $derived(
|
const canUploadFiles = $derived(
|
||||||
!disabled &&
|
!disabled &&
|
||||||
!uploading &&
|
!uploading &&
|
||||||
!(
|
!(maxFiles !== undefined && fileCount !== undefined && fileCount >= maxFiles),
|
||||||
maxFiles !== undefined &&
|
);
|
||||||
fileCount !== undefined &&
|
|
||||||
fileCount >= maxFiles
|
|
||||||
),
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
ondragover={(e) => e.preventDefault()}
|
ondragover={(e) => e.preventDefault()}
|
||||||
ondrop={drop}
|
ondrop={drop}
|
||||||
for={id}
|
for={id}
|
||||||
aria-disabled={!canUploadFiles}
|
aria-disabled={!canUploadFiles}
|
||||||
class={cn(
|
class={cn(
|
||||||
"border-border hover:bg-accent/25 flex h-48 w-full place-items-center justify-center rounded-lg border-2 border-dashed p-6 transition-all hover:cursor-pointer aria-disabled:opacity-50 aria-disabled:hover:cursor-not-allowed",
|
"border-border hover:bg-accent/25 flex h-48 w-full place-items-center justify-center rounded-lg border-2 border-dashed p-6 transition-all hover:cursor-pointer aria-disabled:opacity-50 aria-disabled:hover:cursor-not-allowed",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{#if children}
|
{#if children}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col place-items-center justify-center gap-2">
|
<div class="flex flex-col place-items-center justify-center gap-2">
|
||||||
<div
|
<div
|
||||||
class="border-border text-muted-foreground flex size-14 place-items-center justify-center rounded-full border border-dashed"
|
class="border-border text-muted-foreground flex size-14 place-items-center justify-center rounded-full border border-dashed"
|
||||||
>
|
>
|
||||||
<UploadIcon class="size-7" />
|
<UploadIcon class="size-7" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 text-center">
|
<div class="flex flex-col gap-0.5 text-center">
|
||||||
<span class="text-muted-foreground font-medium">
|
<span class="text-muted-foreground font-medium">
|
||||||
Drag 'n' drop files here, or click to select files
|
Drag 'n' drop files here, or click to select files
|
||||||
</span>
|
</span>
|
||||||
{#if maxFiles || maxFileSize}
|
{#if maxFiles || maxFileSize}
|
||||||
<span class="text-muted-foreground/75 text-sm">
|
<span class="text-muted-foreground/75 text-sm">
|
||||||
{#if maxFiles}
|
{#if maxFiles}
|
||||||
<span>You can upload {maxFiles} files</span>
|
<span>You can upload {maxFiles} files</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if maxFiles && maxFileSize}
|
{#if maxFiles && maxFileSize}
|
||||||
<span>(up to {displaySize(maxFileSize)} each)</span>
|
<span>(up to {displaySize(maxFileSize)} each)</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if maxFileSize && !maxFiles}
|
{#if maxFileSize && !maxFiles}
|
||||||
<span>Maximum size {displaySize(maxFileSize)}</span>
|
<span>Maximum size {displaySize(maxFileSize)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<input
|
<input
|
||||||
{...rest}
|
{...rest}
|
||||||
disabled={!canUploadFiles}
|
disabled={!canUploadFiles}
|
||||||
{id}
|
{id}
|
||||||
{accept}
|
{accept}
|
||||||
multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1}
|
multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1}
|
||||||
type="file"
|
type="file"
|
||||||
onchange={change}
|
onchange={change}
|
||||||
class="hidden"
|
class="hidden"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import FileDropZone from "./file-drop-zone.svelte";
|
|||||||
import { type FileRejectedReason, type FileDropZoneProps } from "./types";
|
import { type FileRejectedReason, type FileDropZoneProps } from "./types";
|
||||||
|
|
||||||
export const displaySize = (bytes: number): string => {
|
export const displaySize = (bytes: number): string => {
|
||||||
if (bytes < KILOBYTE) return `${bytes.toFixed(0)} B`;
|
if (bytes < KILOBYTE) return `${bytes.toFixed(0)} B`;
|
||||||
|
|
||||||
if (bytes < MEGABYTE) return `${(bytes / KILOBYTE).toFixed(0)} KB`;
|
if (bytes < MEGABYTE) return `${(bytes / KILOBYTE).toFixed(0)} KB`;
|
||||||
|
|
||||||
if (bytes < GIGABYTE) return `${(bytes / MEGABYTE).toFixed(0)} MB`;
|
if (bytes < GIGABYTE) return `${(bytes / MEGABYTE).toFixed(0)} MB`;
|
||||||
|
|
||||||
return `${(bytes / GIGABYTE).toFixed(0)} GB`;
|
return `${(bytes / GIGABYTE).toFixed(0)} GB`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Utilities for working with file sizes
|
// Utilities for working with file sizes
|
||||||
|
|||||||
@@ -6,46 +6,46 @@ import type { WithChildren } from "bits-ui";
|
|||||||
import type { HTMLInputAttributes } from "svelte/elements";
|
import type { HTMLInputAttributes } from "svelte/elements";
|
||||||
|
|
||||||
export type FileRejectedReason =
|
export type FileRejectedReason =
|
||||||
| "Maximum file size exceeded"
|
| "Maximum file size exceeded"
|
||||||
| "File type not allowed"
|
| "File type not allowed"
|
||||||
| "Maximum files uploaded";
|
| "Maximum files uploaded";
|
||||||
|
|
||||||
export type FileDropZonePropsWithoutHTML = WithChildren<{
|
export type FileDropZonePropsWithoutHTML = WithChildren<{
|
||||||
ref?: HTMLInputElement | null;
|
ref?: HTMLInputElement | null;
|
||||||
/** Called with the uploaded files when the user drops or clicks and selects their files.
|
/** Called with the uploaded files when the user drops or clicks and selects their files.
|
||||||
*
|
*
|
||||||
* @param files
|
* @param files
|
||||||
*/
|
*/
|
||||||
onUpload: (files: File[]) => Promise<void>;
|
onUpload: (files: File[]) => Promise<void>;
|
||||||
/** The maximum amount files allowed to be uploaded */
|
/** The maximum amount files allowed to be uploaded */
|
||||||
maxFiles?: number;
|
maxFiles?: number;
|
||||||
fileCount?: number;
|
fileCount?: number;
|
||||||
/** The maximum size of a file in bytes */
|
/** The maximum size of a file in bytes */
|
||||||
maxFileSize?: number;
|
maxFileSize?: number;
|
||||||
/** Called when a file does not meet the upload criteria (size, or type) */
|
/** Called when a file does not meet the upload criteria (size, or type) */
|
||||||
onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void;
|
onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void;
|
||||||
|
|
||||||
// just for extra documentation
|
// just for extra documentation
|
||||||
/** Takes a comma separated list of one or more file types.
|
/** Takes a comma separated list of one or more file types.
|
||||||
*
|
*
|
||||||
* [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept)
|
* [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept)
|
||||||
*
|
*
|
||||||
* ### Usage
|
* ### Usage
|
||||||
* ```svelte
|
* ```svelte
|
||||||
* <FileDropZone
|
* <FileDropZone
|
||||||
* accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
* accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
* />
|
* />
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* ### Common Values
|
* ### Common Values
|
||||||
* ```svelte
|
* ```svelte
|
||||||
* <FileDropZone accept="audio/*"/>
|
* <FileDropZone accept="audio/*"/>
|
||||||
* <FileDropZone accept="image/*"/>
|
* <FileDropZone accept="image/*"/>
|
||||||
* <FileDropZone accept="video/*"/>
|
* <FileDropZone accept="video/*"/>
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
accept?: string;
|
accept?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type FileDropZoneProps = FileDropZonePropsWithoutHTML &
|
export type FileDropZoneProps = FileDropZonePropsWithoutHTML &
|
||||||
Omit<HTMLInputAttributes, "multiple" | "files">;
|
Omit<HTMLInputAttributes, "multiple" | "files">;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Root from "./input.svelte";
|
import Root from "./input.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
//
|
//
|
||||||
Root as Input,
|
Root as Input,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,57 +1,51 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {
|
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||||
HTMLInputAttributes,
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
HTMLInputTypeAttribute,
|
|
||||||
} from "svelte/elements";
|
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
|
||||||
|
|
||||||
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||||
|
|
||||||
type Props = WithElementRef<
|
type Props = WithElementRef<
|
||||||
Omit<HTMLInputAttributes, "type"> &
|
Omit<HTMLInputAttributes, "type"> &
|
||||||
(
|
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
|
||||||
| { type: "file"; files?: FileList }
|
>;
|
||||||
| { type?: InputType; files?: undefined }
|
|
||||||
)
|
|
||||||
>;
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
value = $bindable(),
|
value = $bindable(),
|
||||||
type,
|
type,
|
||||||
files = $bindable(),
|
files = $bindable(),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if type === "file"}
|
{#if type === "file"}
|
||||||
<input
|
<input
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
class={cn(
|
class={cn(
|
||||||
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
type="file"
|
type="file"
|
||||||
bind:files
|
bind:files
|
||||||
bind:value
|
bind:value
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<input
|
<input
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
class={cn(
|
class={cn(
|
||||||
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{type}
|
{type}
|
||||||
bind:value
|
bind:value
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Root from "./label.svelte";
|
import Root from "./label.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
//
|
//
|
||||||
Root as Label,
|
Root as Label,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Label as LabelPrimitive } from "bits-ui";
|
import { Label as LabelPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: LabelPrimitive.RootProps = $props();
|
}: LabelPrimitive.RootProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="label"
|
data-slot="label"
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user