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
|
||||||
|
|||||||
26
README.md
26
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:**
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ Every request:
|
|||||||
Assets are transformed on first request and cached as WebP:
|
Assets are transformed on first request and cached as WebP:
|
||||||
|
|
||||||
| Preset | Size | Fit | Use |
|
| Preset | Size | Fit | Use |
|
||||||
|--------|------|-----|-----|
|
| ----------- | ----------- | ------ | ---------------- |
|
||||||
| `mini` | 80×80 | cover | Avatars in lists |
|
| `mini` | 80×80 | cover | Avatars in lists |
|
||||||
| `thumbnail` | 300×300 | cover | Profile photos |
|
| `thumbnail` | 300×300 | cover | Profile photos |
|
||||||
| `preview` | 800px wide | inside | Video teasers |
|
| `preview` | 800px wide | inside | Video teasers |
|
||||||
@@ -277,7 +277,7 @@ graph LR
|
|||||||
### Backend (required)
|
### Backend (required)
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
| --------------- | ----------------------------- |
|
||||||
| `DATABASE_URL` | PostgreSQL connection string |
|
| `DATABASE_URL` | PostgreSQL connection string |
|
||||||
| `REDIS_URL` | Redis connection string |
|
| `REDIS_URL` | Redis connection string |
|
||||||
| `COOKIE_SECRET` | Session cookie signing secret |
|
| `COOKIE_SECRET` | Session cookie signing secret |
|
||||||
@@ -287,7 +287,7 @@ graph LR
|
|||||||
### Backend (optional)
|
### Backend (optional)
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
| ------------ | ------- | ------------------------------ |
|
||||||
| `PORT` | `4000` | Backend listen port |
|
| `PORT` | `4000` | Backend listen port |
|
||||||
| `LOG_LEVEL` | `info` | Fastify log level |
|
| `LOG_LEVEL` | `info` | Fastify log level |
|
||||||
| `SMTP_HOST` | — | Email server for auth flows |
|
| `SMTP_HOST` | — | Email server for auth flows |
|
||||||
@@ -298,7 +298,7 @@ graph LR
|
|||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
| --------------------- | --------------------------------------------- |
|
||||||
| `PUBLIC_API_URL` | Backend URL (e.g. `http://sexy_backend:4000`) |
|
| `PUBLIC_API_URL` | Backend URL (e.g. `http://sexy_backend:4000`) |
|
||||||
| `PUBLIC_URL` | Frontend public URL |
|
| `PUBLIC_URL` | Frontend public URL |
|
||||||
| `PUBLIC_UMAMI_ID` | Umami analytics site ID (optional) |
|
| `PUBLIC_UMAMI_ID` | Umami analytics site ID (optional) |
|
||||||
@@ -314,14 +314,14 @@ graph LR
|
|||||||
|
|
||||||
**[Palina](https://sexy.pivoine.art) & [Valknar](https://sexy.pivoine.art)**
|
**[Palina](https://sexy.pivoine.art) & [Valknar](https://sexy.pivoine.art)**
|
||||||
|
|
||||||
*Für die Mäuse...* 🐭💕
|
_Für die Mäuse..._ 🐭💕
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🙏 Built With
|
### 🙏 Built With
|
||||||
|
|
||||||
| Technology | Purpose |
|
| Technology | Purpose |
|
||||||
|------------|---------|
|
| --------------------------------------------------------- | -------------------- |
|
||||||
| [SvelteKit](https://kit.svelte.dev/) | Frontend framework |
|
| [SvelteKit](https://kit.svelte.dev/) | Frontend framework |
|
||||||
| [Fastify](https://fastify.dev/) | HTTP server |
|
| [Fastify](https://fastify.dev/) | HTTP server |
|
||||||
| [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server) | GraphQL server |
|
| [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server) | GraphQL server |
|
||||||
@@ -339,7 +339,7 @@ graph LR
|
|||||||
Pioneer of sexual liberation (1919-2001)
|
Pioneer of sexual liberation (1919-2001)
|
||||||
Pilot, Entrepreneur, Freedom Fighter
|
Pilot, Entrepreneur, Freedom Fighter
|
||||||
|
|
||||||
*"Eine Frau, die ihre Sexualität selbstbestimmt lebt, ist eine freie Frau."*
|
_"Eine Frau, die ihre Sexualität selbstbestimmt lebt, ist eine freie Frau."_
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"eslint-plugin-svelte": "^3.15.0",
|
"eslint-plugin-svelte": "^3.15.0",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-svelte": "^3.5.1",
|
||||||
"typescript-eslint": "^8.56.1"
|
"typescript-eslint": "^8.56.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
and(
|
||||||
lte(videos.upload_date, new Date()),
|
lte(videos.upload_date, new Date()),
|
||||||
inArray(videos.id, videoIds.map((v: any) => v.video_id)),
|
inArray(
|
||||||
))
|
videos.id,
|
||||||
|
videoIds.map((v: any) => v.video_id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.orderBy(desc(videos.upload_date));
|
.orderBy(desc(videos.upload_date));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +97,7 @@ builder.queryField("videos", (t) =>
|
|||||||
query = ctx.db
|
query = ctx.db
|
||||||
.select({ v: videos })
|
.select({ v: videos })
|
||||||
.from(videos)
|
.from(videos)
|
||||||
.where(and(
|
.where(and(lte(videos.upload_date, new Date()), eq(videos.featured, args.featured)))
|
||||||
lte(videos.upload_date, new Date()),
|
|
||||||
eq(videos.featured, args.featured),
|
|
||||||
))
|
|
||||||
.orderBy(desc(videos.upload_date));
|
.orderBy(desc(videos.upload_date));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +143,9 @@ builder.queryField("videoLikeStatus", (t) =>
|
|||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.select()
|
.select()
|
||||||
.from(video_likes)
|
.from(video_likes)
|
||||||
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
|
.where(
|
||||||
|
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return { liked: existing.length > 0 };
|
return { liked: existing.length > 0 };
|
||||||
},
|
},
|
||||||
@@ -142,7 +164,9 @@ builder.mutationField("likeVideo", (t) =>
|
|||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.select()
|
.select()
|
||||||
.from(video_likes)
|
.from(video_likes)
|
||||||
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
|
.where(
|
||||||
|
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing.length > 0) throw new GraphQLError("Already liked");
|
if (existing.length > 0) throw new GraphQLError("Already liked");
|
||||||
@@ -154,10 +178,22 @@ builder.mutationField("likeVideo", (t) =>
|
|||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(videos)
|
.update(videos)
|
||||||
.set({ likes_count: (await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number + 1 || 1 })
|
.set({
|
||||||
|
likes_count:
|
||||||
|
((
|
||||||
|
await ctx.db
|
||||||
|
.select({ c: videos.likes_count })
|
||||||
|
.from(videos)
|
||||||
|
.where(eq(videos.id, args.videoId))
|
||||||
|
.limit(1)
|
||||||
|
)[0]?.c as number) + 1 || 1,
|
||||||
|
})
|
||||||
.where(eq(videos.id, args.videoId));
|
.where(eq(videos.id, args.videoId));
|
||||||
|
|
||||||
const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId));
|
const likesCount = await ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(video_likes)
|
||||||
|
.where(eq(video_likes.video_id, args.videoId));
|
||||||
return { liked: true, likes_count: likesCount[0]?.count || 1 };
|
return { liked: true, likes_count: likesCount[0]?.count || 1 };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -175,21 +211,39 @@ builder.mutationField("unlikeVideo", (t) =>
|
|||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.select()
|
.select()
|
||||||
.from(video_likes)
|
.from(video_likes)
|
||||||
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
|
.where(
|
||||||
|
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing.length === 0) throw new GraphQLError("Not liked");
|
if (existing.length === 0) throw new GraphQLError("Not liked");
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.delete(video_likes)
|
.delete(video_likes)
|
||||||
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)));
|
.where(
|
||||||
|
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
|
||||||
|
);
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(videos)
|
.update(videos)
|
||||||
.set({ likes_count: Math.max(((await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number || 1) - 1, 0) })
|
.set({
|
||||||
|
likes_count: Math.max(
|
||||||
|
(((
|
||||||
|
await ctx.db
|
||||||
|
.select({ c: videos.likes_count })
|
||||||
|
.from(videos)
|
||||||
|
.where(eq(videos.id, args.videoId))
|
||||||
|
.limit(1)
|
||||||
|
)[0]?.c as number) || 1) - 1,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
})
|
||||||
.where(eq(videos.id, args.videoId));
|
.where(eq(videos.id, args.videoId));
|
||||||
|
|
||||||
const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId));
|
const likesCount = await ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(video_likes)
|
||||||
|
.where(eq(video_likes.video_id, args.videoId));
|
||||||
return { liked: false, likes_count: likesCount[0]?.count || 0 };
|
return { liked: false, likes_count: likesCount[0]?.count || 0 };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -203,13 +257,19 @@ builder.mutationField("recordVideoPlay", (t) =>
|
|||||||
sessionId: t.arg.string(),
|
sessionId: t.arg.string(),
|
||||||
},
|
},
|
||||||
resolve: async (_root, args, ctx) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
const play = await ctx.db.insert(video_plays).values({
|
const play = await ctx.db
|
||||||
|
.insert(video_plays)
|
||||||
|
.values({
|
||||||
video_id: args.videoId,
|
video_id: args.videoId,
|
||||||
user_id: ctx.currentUser?.id || null,
|
user_id: ctx.currentUser?.id || null,
|
||||||
session_id: args.sessionId || null,
|
session_id: args.sessionId || null,
|
||||||
}).returning({ id: video_plays.id });
|
})
|
||||||
|
.returning({ id: video_plays.id });
|
||||||
|
|
||||||
const playsCount = await ctx.db.select({ count: count() }).from(video_plays).where(eq(video_plays.video_id, args.videoId));
|
const playsCount = await ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(video_plays)
|
||||||
|
.where(eq(video_plays.video_id, args.videoId));
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(videos)
|
.update(videos)
|
||||||
@@ -237,7 +297,11 @@ builder.mutationField("updateVideoPlay", (t) =>
|
|||||||
resolve: async (_root, args, ctx) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(video_plays)
|
.update(video_plays)
|
||||||
.set({ duration_watched: args.durationWatched, completed: args.completed, date_updated: new Date() })
|
.set({
|
||||||
|
duration_watched: args.durationWatched,
|
||||||
|
completed: args.completed,
|
||||||
|
date_updated: new Date(),
|
||||||
|
})
|
||||||
.where(eq(video_plays.id, args.playId));
|
.where(eq(video_plays.id, args.playId));
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -262,13 +326,26 @@ builder.queryField("analytics", (t) =>
|
|||||||
.where(eq(video_models.user_id, userId));
|
.where(eq(video_models.user_id, userId));
|
||||||
|
|
||||||
if (modelVideoIds.length === 0) {
|
if (modelVideoIds.length === 0) {
|
||||||
return { total_videos: 0, total_likes: 0, total_plays: 0, plays_by_date: {}, likes_by_date: {}, videos: [] };
|
return {
|
||||||
|
total_videos: 0,
|
||||||
|
total_likes: 0,
|
||||||
|
total_plays: 0,
|
||||||
|
plays_by_date: {},
|
||||||
|
likes_by_date: {},
|
||||||
|
videos: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoIds = modelVideoIds.map((v: any) => v.video_id);
|
const videoIds = modelVideoIds.map((v: any) => v.video_id);
|
||||||
const videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds));
|
const videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds));
|
||||||
const plays = await ctx.db.select().from(video_plays).where(inArray(video_plays.video_id, videoIds));
|
const plays = await ctx.db
|
||||||
const likes = await ctx.db.select().from(video_likes).where(inArray(video_likes.video_id, videoIds));
|
.select()
|
||||||
|
.from(video_plays)
|
||||||
|
.where(inArray(video_plays.video_id, videoIds));
|
||||||
|
const likes = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(video_likes)
|
||||||
|
.where(inArray(video_likes.video_id, videoIds));
|
||||||
|
|
||||||
const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0);
|
const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0);
|
||||||
const totalPlays = videoList.reduce((sum, v) => sum + (v.plays_count || 0), 0);
|
const totalPlays = videoList.reduce((sum, v) => sum + (v.plays_count || 0), 0);
|
||||||
@@ -290,7 +367,8 @@ builder.queryField("analytics", (t) =>
|
|||||||
const videoAnalytics = videoList.map((video) => {
|
const videoAnalytics = videoList.map((video) => {
|
||||||
const vPlays = plays.filter((p) => p.video_id === video.id);
|
const vPlays = plays.filter((p) => p.video_id === video.id);
|
||||||
const completedPlays = vPlays.filter((p) => p.completed).length;
|
const completedPlays = vPlays.filter((p) => p.completed).length;
|
||||||
const avgWatchTime = vPlays.length > 0
|
const avgWatchTime =
|
||||||
|
vPlays.length > 0
|
||||||
? vPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) / vPlays.length
|
? vPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) / vPlays.length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
|
|
||||||
// File type
|
// File type
|
||||||
export const FileType = builder.objectRef<{
|
export const FileType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@@ -11,7 +12,8 @@ export const FileType = builder.objectRef<{
|
|||||||
duration: number | null;
|
duration: number | null;
|
||||||
uploaded_by: string | null;
|
uploaded_by: string | null;
|
||||||
date_created: Date;
|
date_created: Date;
|
||||||
}>("File").implement({
|
}>("File")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
title: t.exposeString("title", { nullable: true }),
|
title: t.exposeString("title", { nullable: true }),
|
||||||
@@ -26,7 +28,8 @@ export const FileType = builder.objectRef<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// User type
|
// User type
|
||||||
export const UserType = builder.objectRef<{
|
export const UserType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
@@ -40,7 +43,8 @@ export const UserType = builder.objectRef<{
|
|||||||
banner: string | null;
|
banner: string | null;
|
||||||
email_verified: boolean;
|
email_verified: boolean;
|
||||||
date_created: Date;
|
date_created: Date;
|
||||||
}>("User").implement({
|
}>("User")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
email: t.exposeString("email"),
|
email: t.exposeString("email"),
|
||||||
@@ -59,7 +63,8 @@ export const UserType = builder.objectRef<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// CurrentUser type (same shape, used for auth context)
|
// CurrentUser type (same shape, used for auth context)
|
||||||
export const CurrentUserType = builder.objectRef<{
|
export const CurrentUserType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
@@ -73,7 +78,8 @@ export const CurrentUserType = builder.objectRef<{
|
|||||||
banner: string | null;
|
banner: string | null;
|
||||||
email_verified: boolean;
|
email_verified: boolean;
|
||||||
date_created: Date;
|
date_created: Date;
|
||||||
}>("CurrentUser").implement({
|
}>("CurrentUser")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
email: t.exposeString("email"),
|
email: t.exposeString("email"),
|
||||||
@@ -92,7 +98,8 @@ export const CurrentUserType = builder.objectRef<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Video type
|
// Video type
|
||||||
export const VideoType = builder.objectRef<{
|
export const VideoType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -105,9 +112,20 @@ export const VideoType = builder.objectRef<{
|
|||||||
featured: boolean | null;
|
featured: boolean | null;
|
||||||
likes_count: number | null;
|
likes_count: number | null;
|
||||||
plays_count: number | null;
|
plays_count: number | null;
|
||||||
models?: { id: string; artist_name: string | null; slug: string | null; avatar: string | null }[];
|
models?: {
|
||||||
movie_file?: { id: string; filename: string; mime_type: string | null; duration: number | null } | null;
|
id: string;
|
||||||
}>("Video").implement({
|
artist_name: string | null;
|
||||||
|
slug: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
}[];
|
||||||
|
movie_file?: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mime_type: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
} | null;
|
||||||
|
}>("Video")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
slug: t.exposeString("slug"),
|
slug: t.exposeString("slug"),
|
||||||
@@ -126,12 +144,14 @@ export const VideoType = builder.objectRef<{
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const VideoModelType = builder.objectRef<{
|
export const VideoModelType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
artist_name: string | null;
|
artist_name: string | null;
|
||||||
slug: string | null;
|
slug: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
}>("VideoModel").implement({
|
}>("VideoModel")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
artist_name: t.exposeString("artist_name", { nullable: true }),
|
artist_name: t.exposeString("artist_name", { nullable: true }),
|
||||||
@@ -140,12 +160,14 @@ export const VideoModelType = builder.objectRef<{
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const VideoFileType = builder.objectRef<{
|
export const VideoFileType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
mime_type: string | null;
|
mime_type: string | null;
|
||||||
duration: number | null;
|
duration: number | null;
|
||||||
}>("VideoFile").implement({
|
}>("VideoFile")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
filename: t.exposeString("filename"),
|
filename: t.exposeString("filename"),
|
||||||
@@ -155,7 +177,8 @@ export const VideoFileType = builder.objectRef<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Model type (model profile, enriched user)
|
// Model type (model profile, enriched user)
|
||||||
export const ModelType = builder.objectRef<{
|
export const ModelType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
slug: string | null;
|
slug: string | null;
|
||||||
artist_name: string | null;
|
artist_name: string | null;
|
||||||
@@ -165,7 +188,8 @@ export const ModelType = builder.objectRef<{
|
|||||||
tags: string[] | null;
|
tags: string[] | null;
|
||||||
date_created: Date;
|
date_created: Date;
|
||||||
photos?: { id: string; filename: string }[];
|
photos?: { id: string; filename: string }[];
|
||||||
}>("Model").implement({
|
}>("Model")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
slug: t.exposeString("slug", { nullable: true }),
|
slug: t.exposeString("slug", { nullable: true }),
|
||||||
@@ -179,10 +203,12 @@ export const ModelType = builder.objectRef<{
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ModelPhotoType = builder.objectRef<{
|
export const ModelPhotoType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
}>("ModelPhoto").implement({
|
}>("ModelPhoto")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
filename: t.exposeString("filename"),
|
filename: t.exposeString("filename"),
|
||||||
@@ -190,7 +216,8 @@ export const ModelPhotoType = builder.objectRef<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Article type
|
// Article type
|
||||||
export const ArticleType = builder.objectRef<{
|
export const ArticleType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -201,8 +228,14 @@ export const ArticleType = builder.objectRef<{
|
|||||||
publish_date: Date;
|
publish_date: Date;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
featured: boolean | null;
|
featured: boolean | null;
|
||||||
author?: { first_name: string | null; last_name: string | null; avatar: string | null; description: string | null } | null;
|
author?: {
|
||||||
}>("Article").implement({
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
description: string | null;
|
||||||
|
} | null;
|
||||||
|
}>("Article")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
slug: t.exposeString("slug"),
|
slug: t.exposeString("slug"),
|
||||||
@@ -218,12 +251,14 @@ export const ArticleType = builder.objectRef<{
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ArticleAuthorType = builder.objectRef<{
|
export const ArticleAuthorType = builder
|
||||||
|
.objectRef<{
|
||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
last_name: string | null;
|
last_name: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
}>("ArticleAuthor").implement({
|
}>("ArticleAuthor")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
first_name: t.exposeString("first_name", { nullable: true }),
|
first_name: t.exposeString("first_name", { nullable: true }),
|
||||||
last_name: t.exposeString("last_name", { nullable: true }),
|
last_name: t.exposeString("last_name", { nullable: true }),
|
||||||
@@ -233,7 +268,8 @@ export const ArticleAuthorType = builder.objectRef<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Recording type
|
// Recording type
|
||||||
export const RecordingType = builder.objectRef<{
|
export const RecordingType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@@ -249,7 +285,8 @@ export const RecordingType = builder.objectRef<{
|
|||||||
public: boolean | null;
|
public: boolean | null;
|
||||||
date_created: Date;
|
date_created: Date;
|
||||||
date_updated: Date | null;
|
date_updated: Date | null;
|
||||||
}>("Recording").implement({
|
}>("Recording")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
title: t.exposeString("title"),
|
title: t.exposeString("title"),
|
||||||
@@ -270,15 +307,22 @@ export const RecordingType = builder.objectRef<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Comment type
|
// Comment type
|
||||||
export const CommentType = builder.objectRef<{
|
export const CommentType = builder
|
||||||
|
.objectRef<{
|
||||||
id: number;
|
id: number;
|
||||||
collection: string;
|
collection: string;
|
||||||
item_id: string;
|
item_id: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
date_created: Date;
|
date_created: Date;
|
||||||
user?: { id: string; first_name: string | null; last_name: string | null; avatar: string | null } | null;
|
user?: {
|
||||||
}>("Comment").implement({
|
id: string;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
} | null;
|
||||||
|
}>("Comment")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeInt("id"),
|
id: t.exposeInt("id"),
|
||||||
collection: t.exposeString("collection"),
|
collection: t.exposeString("collection"),
|
||||||
@@ -290,12 +334,14 @@ export const CommentType = builder.objectRef<{
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CommentUserType = builder.objectRef<{
|
export const CommentUserType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
last_name: string | null;
|
last_name: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
}>("CommentUser").implement({
|
}>("CommentUser")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
first_name: t.exposeString("first_name", { nullable: true }),
|
first_name: t.exposeString("first_name", { nullable: true }),
|
||||||
@@ -305,11 +351,13 @@ export const CommentUserType = builder.objectRef<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Stats type
|
// Stats type
|
||||||
export const StatsType = builder.objectRef<{
|
export const StatsType = builder
|
||||||
|
.objectRef<{
|
||||||
videos_count: number;
|
videos_count: number;
|
||||||
models_count: number;
|
models_count: number;
|
||||||
viewers_count: number;
|
viewers_count: number;
|
||||||
}>("Stats").implement({
|
}>("Stats")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
videos_count: t.exposeInt("videos_count"),
|
videos_count: t.exposeInt("videos_count"),
|
||||||
models_count: t.exposeInt("models_count"),
|
models_count: t.exposeInt("models_count"),
|
||||||
@@ -318,7 +366,8 @@ export const StatsType = builder.objectRef<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Gamification types
|
// Gamification types
|
||||||
export const LeaderboardEntryType = builder.objectRef<{
|
export const LeaderboardEntryType = builder
|
||||||
|
.objectRef<{
|
||||||
user_id: string;
|
user_id: string;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
@@ -328,7 +377,8 @@ export const LeaderboardEntryType = builder.objectRef<{
|
|||||||
playbacks_count: number | null;
|
playbacks_count: number | null;
|
||||||
achievements_count: number | null;
|
achievements_count: number | null;
|
||||||
rank: number;
|
rank: number;
|
||||||
}>("LeaderboardEntry").implement({
|
}>("LeaderboardEntry")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
user_id: t.exposeString("user_id"),
|
user_id: t.exposeString("user_id"),
|
||||||
display_name: t.exposeString("display_name", { nullable: true }),
|
display_name: t.exposeString("display_name", { nullable: true }),
|
||||||
@@ -342,7 +392,8 @@ export const LeaderboardEntryType = builder.objectRef<{
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AchievementType = builder.objectRef<{
|
export const AchievementType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -351,7 +402,8 @@ export const AchievementType = builder.objectRef<{
|
|||||||
category: string | null;
|
category: string | null;
|
||||||
required_count: number;
|
required_count: number;
|
||||||
points_reward: number;
|
points_reward: number;
|
||||||
}>("Achievement").implement({
|
}>("Achievement")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
code: t.exposeString("code"),
|
code: t.exposeString("code"),
|
||||||
@@ -364,7 +416,8 @@ export const AchievementType = builder.objectRef<{
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UserGamificationType = builder.objectRef<{
|
export const UserGamificationType = builder
|
||||||
|
.objectRef<{
|
||||||
stats: {
|
stats: {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
total_raw_points: number | null;
|
total_raw_points: number | null;
|
||||||
@@ -392,7 +445,8 @@ export const UserGamificationType = builder.objectRef<{
|
|||||||
date_created: Date;
|
date_created: Date;
|
||||||
recording_id: string | null;
|
recording_id: string | null;
|
||||||
}[];
|
}[];
|
||||||
}>("UserGamification").implement({
|
}>("UserGamification")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
stats: t.expose("stats", { type: UserStatsType, nullable: true }),
|
stats: t.expose("stats", { type: UserStatsType, nullable: true }),
|
||||||
achievements: t.expose("achievements", { type: [UserAchievementType] }),
|
achievements: t.expose("achievements", { type: [UserAchievementType] }),
|
||||||
@@ -400,7 +454,8 @@ export const UserGamificationType = builder.objectRef<{
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UserStatsType = builder.objectRef<{
|
export const UserStatsType = builder
|
||||||
|
.objectRef<{
|
||||||
user_id: string;
|
user_id: string;
|
||||||
total_raw_points: number | null;
|
total_raw_points: number | null;
|
||||||
total_weighted_points: number | null;
|
total_weighted_points: number | null;
|
||||||
@@ -409,7 +464,8 @@ export const UserStatsType = builder.objectRef<{
|
|||||||
comments_count: number | null;
|
comments_count: number | null;
|
||||||
achievements_count: number | null;
|
achievements_count: number | null;
|
||||||
rank: number;
|
rank: number;
|
||||||
}>("UserStats").implement({
|
}>("UserStats")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
user_id: t.exposeString("user_id"),
|
user_id: t.exposeString("user_id"),
|
||||||
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
|
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
|
||||||
@@ -422,7 +478,8 @@ export const UserStatsType = builder.objectRef<{
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UserAchievementType = builder.objectRef<{
|
export const UserAchievementType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -432,7 +489,8 @@ export const UserAchievementType = builder.objectRef<{
|
|||||||
date_unlocked: Date;
|
date_unlocked: Date;
|
||||||
progress: number | null;
|
progress: number | null;
|
||||||
required_count: number;
|
required_count: number;
|
||||||
}>("UserAchievement").implement({
|
}>("UserAchievement")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
code: t.exposeString("code"),
|
code: t.exposeString("code"),
|
||||||
@@ -446,12 +504,14 @@ export const UserAchievementType = builder.objectRef<{
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const RecentPointType = builder.objectRef<{
|
export const RecentPointType = builder
|
||||||
|
.objectRef<{
|
||||||
action: string;
|
action: string;
|
||||||
points: number;
|
points: number;
|
||||||
date_created: Date;
|
date_created: Date;
|
||||||
recording_id: string | null;
|
recording_id: string | null;
|
||||||
}>("RecentPoint").implement({
|
}>("RecentPoint")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
action: t.exposeString("action"),
|
action: t.exposeString("action"),
|
||||||
points: t.exposeInt("points"),
|
points: t.exposeInt("points"),
|
||||||
@@ -461,7 +521,8 @@ export const RecentPointType = builder.objectRef<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Analytics types
|
// Analytics types
|
||||||
export const AnalyticsType = builder.objectRef<{
|
export const AnalyticsType = builder
|
||||||
|
.objectRef<{
|
||||||
total_videos: number;
|
total_videos: number;
|
||||||
total_likes: number;
|
total_likes: number;
|
||||||
total_plays: number;
|
total_plays: number;
|
||||||
@@ -478,7 +539,8 @@ export const AnalyticsType = builder.objectRef<{
|
|||||||
completion_rate: number;
|
completion_rate: number;
|
||||||
avg_watch_time: number;
|
avg_watch_time: number;
|
||||||
}[];
|
}[];
|
||||||
}>("Analytics").implement({
|
}>("Analytics")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
total_videos: t.exposeInt("total_videos"),
|
total_videos: t.exposeInt("total_videos"),
|
||||||
total_likes: t.exposeInt("total_likes"),
|
total_likes: t.exposeInt("total_likes"),
|
||||||
@@ -489,7 +551,8 @@ export const AnalyticsType = builder.objectRef<{
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const VideoAnalyticsType = builder.objectRef<{
|
export const VideoAnalyticsType = builder
|
||||||
|
.objectRef<{
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -499,7 +562,8 @@ export const VideoAnalyticsType = builder.objectRef<{
|
|||||||
completed_plays: number;
|
completed_plays: number;
|
||||||
completion_rate: number;
|
completion_rate: number;
|
||||||
avg_watch_time: number;
|
avg_watch_time: number;
|
||||||
}>("VideoAnalytics").implement({
|
}>("VideoAnalytics")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
id: t.exposeString("id"),
|
id: t.exposeString("id"),
|
||||||
title: t.exposeString("title"),
|
title: t.exposeString("title"),
|
||||||
@@ -514,21 +578,25 @@ export const VideoAnalyticsType = builder.objectRef<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Response types
|
// Response types
|
||||||
export const VideoLikeResponseType = builder.objectRef<{
|
export const VideoLikeResponseType = builder
|
||||||
|
.objectRef<{
|
||||||
liked: boolean;
|
liked: boolean;
|
||||||
likes_count: number;
|
likes_count: number;
|
||||||
}>("VideoLikeResponse").implement({
|
}>("VideoLikeResponse")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
liked: t.exposeBoolean("liked"),
|
liked: t.exposeBoolean("liked"),
|
||||||
likes_count: t.exposeInt("likes_count"),
|
likes_count: t.exposeInt("likes_count"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const VideoPlayResponseType = builder.objectRef<{
|
export const VideoPlayResponseType = builder
|
||||||
|
.objectRef<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
play_id: string;
|
play_id: string;
|
||||||
plays_count: number;
|
plays_count: number;
|
||||||
}>("VideoPlayResponse").implement({
|
}>("VideoPlayResponse")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
success: t.exposeBoolean("success"),
|
success: t.exposeBoolean("success"),
|
||||||
play_id: t.exposeString("play_id"),
|
play_id: t.exposeString("play_id"),
|
||||||
@@ -536,9 +604,11 @@ export const VideoPlayResponseType = builder.objectRef<{
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const VideoLikeStatusType = builder.objectRef<{
|
export const VideoLikeStatusType = builder
|
||||||
|
.objectRef<{
|
||||||
liked: boolean;
|
liked: boolean;
|
||||||
}>("VideoLikeStatus").implement({
|
}>("VideoLikeStatus")
|
||||||
|
.implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
liked: t.exposeBoolean("liked"),
|
liked: t.exposeBoolean("liked"),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,
|
user: process.env.SMTP_USER,
|
||||||
pass: process.env.SMTP_PASS,
|
pass: process.env.SMTP_PASS,
|
||||||
} : undefined,
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const FROM = process.env.EMAIL_FROM || "noreply@sexy.pivoine.art";
|
const FROM = process.env.EMAIL_FROM || "noreply@sexy.pivoine.art";
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -6,27 +6,24 @@
|
|||||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ButtplugMessage } from './core/Messages';
|
import { ButtplugMessage } from "./core/Messages";
|
||||||
import { IButtplugClientConnector } from './client/IButtplugClientConnector';
|
import { IButtplugClientConnector } from "./client/IButtplugClientConnector";
|
||||||
import { EventEmitter } from 'eventemitter3';
|
import { EventEmitter } from "eventemitter3";
|
||||||
|
|
||||||
export * from './client/ButtplugClient';
|
export * from "./client/ButtplugClient";
|
||||||
export * from './client/ButtplugClientDevice';
|
export * from "./client/ButtplugClientDevice";
|
||||||
export * from './client/ButtplugBrowserWebsocketClientConnector';
|
export * from "./client/ButtplugBrowserWebsocketClientConnector";
|
||||||
export * from './client/ButtplugNodeWebsocketClientConnector';
|
export * from "./client/ButtplugNodeWebsocketClientConnector";
|
||||||
export * from './client/ButtplugClientConnectorException';
|
export * from "./client/ButtplugClientConnectorException";
|
||||||
export * from './utils/ButtplugMessageSorter';
|
export * from "./utils/ButtplugMessageSorter";
|
||||||
export * from './client/ButtplugClientDeviceCommand';
|
export * from "./client/ButtplugClientDeviceCommand";
|
||||||
export * from './client/ButtplugClientDeviceFeature';
|
export * from "./client/ButtplugClientDeviceFeature";
|
||||||
export * from './client/IButtplugClientConnector';
|
export * from "./client/IButtplugClientConnector";
|
||||||
export * from './core/Messages';
|
export * from "./core/Messages";
|
||||||
export * from './core/Logging';
|
export * from "./core/Logging";
|
||||||
export * from './core/Exceptions';
|
export * from "./core/Exceptions";
|
||||||
|
|
||||||
export class ButtplugWasmClientConnector
|
export class ButtplugWasmClientConnector extends EventEmitter implements IButtplugClientConnector {
|
||||||
extends EventEmitter
|
|
||||||
implements IButtplugClientConnector
|
|
||||||
{
|
|
||||||
private static _loggingActivated = false;
|
private static _loggingActivated = false;
|
||||||
private static wasmInstance;
|
private static wasmInstance;
|
||||||
private _connected: boolean = false;
|
private _connected: boolean = false;
|
||||||
@@ -43,30 +40,25 @@ export class ButtplugWasmClientConnector
|
|||||||
|
|
||||||
private static maybeLoadWasm = async () => {
|
private static maybeLoadWasm = async () => {
|
||||||
if (ButtplugWasmClientConnector.wasmInstance == undefined) {
|
if (ButtplugWasmClientConnector.wasmInstance == undefined) {
|
||||||
ButtplugWasmClientConnector.wasmInstance = await import(
|
ButtplugWasmClientConnector.wasmInstance = await import("../wasm/index.js");
|
||||||
'../wasm/index.js'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public static activateLogging = async (logLevel: string = 'debug') => {
|
public static activateLogging = async (logLevel: string = "debug") => {
|
||||||
await ButtplugWasmClientConnector.maybeLoadWasm();
|
await ButtplugWasmClientConnector.maybeLoadWasm();
|
||||||
if (this._loggingActivated) {
|
if (this._loggingActivated) {
|
||||||
console.log('Logging already activated, ignoring.');
|
console.log("Logging already activated, ignoring.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('Turning on logging.');
|
console.log("Turning on logging.");
|
||||||
ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger(
|
ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger(logLevel);
|
||||||
logLevel,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public initialize = async (): Promise<void> => {};
|
public initialize = async (): Promise<void> => {};
|
||||||
|
|
||||||
public connect = async (): Promise<void> => {
|
public connect = async (): Promise<void> => {
|
||||||
await ButtplugWasmClientConnector.maybeLoadWasm();
|
await ButtplugWasmClientConnector.maybeLoadWasm();
|
||||||
this.client =
|
this.client = ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server(
|
||||||
ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server(
|
|
||||||
(msgs) => {
|
(msgs) => {
|
||||||
this.emitMessage(msgs);
|
this.emitMessage(msgs);
|
||||||
},
|
},
|
||||||
@@ -80,7 +72,7 @@ export class ButtplugWasmClientConnector
|
|||||||
public send = (msg: ButtplugMessage): void => {
|
public send = (msg: ButtplugMessage): void => {
|
||||||
ButtplugWasmClientConnector.wasmInstance.buttplug_client_send_json_message(
|
ButtplugWasmClientConnector.wasmInstance.buttplug_client_send_json_message(
|
||||||
this.client,
|
this.client,
|
||||||
new TextEncoder().encode('[' + JSON.stringify(msg) + ']'),
|
new TextEncoder().encode("[" + JSON.stringify(msg) + "]"),
|
||||||
(output) => {
|
(output) => {
|
||||||
this.emitMessage(output);
|
this.emitMessage(output);
|
||||||
},
|
},
|
||||||
@@ -90,6 +82,6 @@ export class ButtplugWasmClientConnector
|
|||||||
private emitMessage = (msg: Uint8Array) => {
|
private emitMessage = (msg: Uint8Array) => {
|
||||||
const str = new TextDecoder().decode(msg);
|
const str = new TextDecoder().decode(msg);
|
||||||
const msgs: ButtplugMessage[] = JSON.parse(str);
|
const msgs: ButtplugMessage[] = JSON.parse(str);
|
||||||
this.emit('message', msgs);
|
this.emit("message", msgs);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -77,11 +77,11 @@
|
|||||||
@keyframes pulseGlow {
|
@keyframes pulseGlow {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
boxShadow: 0 0 20px rgba(183, 0, 217, 0.3);
|
boxshadow: 0 0 20px rgba(183, 0, 217, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
boxShadow: 0 0 40px rgba(183, 0, 217, 0.6);
|
boxshadow: 0 0 40px rgba(183, 0, 217, 0.6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<!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%
|
||||||
@@ -20,5 +20,4 @@
|
|||||||
<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>
|
||||||
@@ -19,9 +19,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
logger.request(request.method, url.pathname, {
|
logger.request(request.method, url.pathname, {
|
||||||
requestId,
|
requestId,
|
||||||
context: {
|
context: {
|
||||||
userAgent: request.headers.get('user-agent')?.substring(0, 100),
|
userAgent: request.headers.get("user-agent")?.substring(0, 100),
|
||||||
referer: request.headers.get('referer'),
|
referer: request.headers.get("referer"),
|
||||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
|
ip: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
locals.authStatus = await isAuthenticated(token);
|
locals.authStatus = await isAuthenticated(token);
|
||||||
|
|
||||||
if (locals.authStatus.authenticated) {
|
if (locals.authStatus.authenticated) {
|
||||||
logger.auth('Token validated', true, {
|
logger.auth("Token validated", true, {
|
||||||
requestId,
|
requestId,
|
||||||
userId: locals.authStatus.user?.id,
|
userId: locals.authStatus.user?.id,
|
||||||
context: {
|
context: {
|
||||||
@@ -42,17 +42,17 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.auth('Token invalid', false, { requestId });
|
logger.auth("Token invalid", false, { requestId });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Authentication check failed', {
|
logger.error("Authentication check failed", {
|
||||||
requestId,
|
requestId,
|
||||||
error: error instanceof Error ? error : new Error(String(error)),
|
error: error instanceof Error ? error : new Error(String(error)),
|
||||||
});
|
});
|
||||||
locals.authStatus = { authenticated: false };
|
locals.authStatus = { authenticated: false };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.debug('No session token found', { requestId });
|
logger.debug("No session token found", { requestId });
|
||||||
locals.authStatus = { authenticated: false };
|
locals.authStatus = { authenticated: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
logger.error('Request handler error', {
|
logger.error("Request handler error", {
|
||||||
requestId,
|
requestId,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
path: url.pathname,
|
path: url.pathname,
|
||||||
@@ -82,12 +82,12 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
requestId,
|
requestId,
|
||||||
userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined,
|
userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined,
|
||||||
context: {
|
context: {
|
||||||
cached: response.headers.get('x-sveltekit-page') === 'true',
|
cached: response.headers.get("x-sveltekit-page") === "true",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add request ID to response headers (useful for debugging)
|
// Add request ID to response headers (useful for debugging)
|
||||||
response.headers.set('x-request-id', requestId);
|
response.headers.set("x-request-id", requestId);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,9 +40,7 @@ onMount(() => {
|
|||||||
<div
|
<div
|
||||||
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
|
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<span class="text-primary-foreground text-sm"
|
<span class="text-primary-foreground text-sm">{$_("age_verification_dialog.age")}</span>
|
||||||
>{$_("age_verification_dialog.age")}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div class="">
|
||||||
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
||||||
@@ -63,12 +61,7 @@ onMount(() => {
|
|||||||
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
|
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
|
||||||
{$_("age_verification_dialog.exit")}
|
{$_("age_verification_dialog.exit")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="default" size="sm" onclick={handleAgeConfirmation} class="cursor-pointer">
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onclick={handleAgeConfirmation}
|
|
||||||
class="cursor-pointer"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--check-line]"></span>
|
<span class="icon-[ri--check-line]"></span>
|
||||||
{$_("age_verification_dialog.confirm")}
|
{$_("age_verification_dialog.confirm")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,11 +2,7 @@
|
|||||||
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>
|
||||||
|
|||||||
@@ -52,12 +52,12 @@ function isActive() {
|
|||||||
<div
|
<div
|
||||||
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
|
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
|
||||||
>
|
>
|
||||||
<span class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}></span>
|
<span
|
||||||
|
class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
|
||||||
class="font-semibold text-card-foreground group-hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{device.name}
|
{device.name}
|
||||||
</h3>
|
</h3>
|
||||||
<!-- <p class="text-sm text-muted-foreground">
|
<!-- <p class="text-sm text-muted-foreground">
|
||||||
@@ -65,27 +65,20 @@ function isActive() {
|
|||||||
</p> -->
|
</p> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`} onclick={() => isActive() && onStop()}>
|
<button
|
||||||
|
class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`}
|
||||||
|
onclick={() => isActive() && onStop()}
|
||||||
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div
|
<div class="w-2 h-2 rounded-full {isActive() ? 'bg-green-400' : 'bg-red-400'}"></div>
|
||||||
class="w-2 h-2 rounded-full {isActive()
|
|
||||||
? 'bg-green-400'
|
|
||||||
: 'bg-red-400'}"
|
|
||||||
></div>
|
|
||||||
{#if isActive()}
|
{#if isActive()}
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
|
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
|
||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span class="text-xs font-medium {isActive() ? 'text-green-400' : 'text-red-400'}">
|
||||||
class="text-xs font-medium {isActive()
|
{isActive() ? $_("device_card.active") : $_("device_card.paused")}
|
||||||
? 'text-green-400'
|
|
||||||
: 'text-red-400'}"
|
|
||||||
>
|
|
||||||
{isActive()
|
|
||||||
? $_("device_card.active")
|
|
||||||
: $_("device_card.paused")}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,9 +101,7 @@ function isActive() {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(
|
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(device.batteryLevel)}"
|
||||||
device.batteryLevel,
|
|
||||||
)}"
|
|
||||||
></span>
|
></span>
|
||||||
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
|
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,9 +133,7 @@ function isActive() {
|
|||||||
{#each device.actuators as actuator, idx (idx)}
|
{#each device.actuators as actuator, idx (idx)}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
||||||
>{$_(
|
>{$_(`device_card.actuator_types.${actuator.outputType.toLowerCase()}`)}</Label
|
||||||
`device_card.actuator_types.${actuator.outputType.toLowerCase()}`,
|
|
||||||
)}</Label
|
|
||||||
>
|
>
|
||||||
<Slider
|
<Slider
|
||||||
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 +154,7 @@ 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">
|
||||||
@@ -168,13 +167,11 @@ function isActiveLink(link: any) {
|
|||||||
<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}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ let { class: className = "", size = "24" }: Props = $props();
|
|||||||
d="M418.813 30.625c-21.178 26.27-49.712 50.982-84.125 70.844-36.778 21.225-75.064 33.62-110.313 38.06a310.317 310.317 0 0 0 6.813 18.25c16.01.277 29.366-.434 36.406-1.5l9.47-1.53 8.436-1.28.22 10.186a307.48 307.48 0 0 1-1.095 18.72l56.625 8.843c.86-.095 1.713-.15 2.563-.157 11.188-.114 21.44 7.29 24.468 18.593.657 2.448.922 4.903.845 7.313 5.972-2.075 11.753-4.305 17.28-6.72l9.595-4.188 2.313 10.22a340.211 340.211 0 0 1 7.375 48.062C438.29 247.836 468.438 225.71 493 197.5c-3.22-36.73-16.154-78.04-39.125-117.813a290.509 290.509 0 0 0-2.22-3.78l-27.56 71.374c5.154.762 10.123 3.158 14.092 7.126 9.81 9.807 9.813 25.69 0 35.5-9.812 9.81-25.722 9.807-35.53 0-8.86-8.858-9.69-22.68-2.532-32.5l38.938-100.844a322.02 322.02 0 0 0-20.25-25.937zM51.842 118.72c-8.46 17.373-15.76 36.198-21.187 56.436-14.108 52.617-13.96 103.682-2.812 143.438 13.3-2.605 26.442-3.96 39.312-4.03 1.855-.012 3.688.02 5.53.06 20.857.48 40.98 4.332 59.97 11.5a355.064 355.064 0 0 1-1.656-34.218c0-27.8 3.135-54.377 9-78.937l2.47-10.407 9.655 4.562c29.467 13.98 66.194 23.424 106.28 25.22 5.136-20.05 8.19-39.78 9.408-58.75-35.198 4.83-75.387 2.766-116.407-8.22-38.363-10.272-72.314-26.78-99.562-46.656zm230.594 82.218c-1.535 10.452-3.615 21.03-6.218 31.687a312.754 312.754 0 0 0 46-3.97 24.98 24.98 0 0 1-1.532-21.748l-38.25-5.97zM105 201.375l4.156 18.22-21.594 4.905c8.75 5.174 13.353 15.703 10.594 26-3.32 12.394-16.045 19.758-28.437 16.438-12.394-3.32-19.76-16.075-16.44-28.47a23.235 23.235 0 0 1 3.126-6.874l-21.062 4.78-4.125-18.218 73.78-16.78zm388.594 22.813c-25.53 25.46-55.306 45.445-86.906 60.5.05 2.397.093 4.8.093 7.218 0 9.188-.354 18.232-1.03 27.125 16.635 1.33 32.045-1.7 45.344-9.374 25.925-14.962 40.608-45.694 42.5-85.47zm-338.844 3c-4.03 19.993-6.33 41.31-6.406 63.593l.125-.342c30.568 10.174 62.622 17.572 95.25 21.375l7.5.875.718 7.5 5.687 60.125-18.625 1.75-2.53-26.75a23.117 23.117 0 0 1-14.845.968c-12.393-3.32-19.76-16.042-16.438-28.436.285-1.06.647-2.08 1.063-3.063a496.627 496.627 0 0 1-57.406-14.53c2.69 49.62 16.154 94.04 36.094 126.656 22.366 36.588 52.13 57.78 83.968 57.78 31.838.003 61.602-21.19 83.97-57.78 19.536-31.96 32.846-75.244 35.905-123.656a499.132 499.132 0 0 1-48.25 11.656c1.914 4.57 2.415 9.78 1.033 14.938-3.322 12.394-16.045 19.758-28.438 16.437a23.01 23.01 0 0 1-2.125-.686l-2.5 26.47-18.594-1.752 5.688-60.125.72-7.5 7.498-.875c29.245-3.407 57.995-9.717 85.657-18.312v-1.594c0-21.573-2.27-42.23-6.064-61.75C351.132 242.653 313.092 250 272.312 250c-43.59 0-83.986-8.658-117.562-22.813zm-87.5 105.968c-10.87.102-21.995 1.22-33.375 3.313 12.695 31.62 33.117 53.07 59 60 16.9 4.523 34.896 2.536 52.813-5.25-4.382-13.89-7.874-28.606-10.344-43.97-21.115-9.623-43.934-14.32-68.094-14.094zm137.5 80.22h130.813c-40.082 44.594-92.623 42.844-130.813 0z"
|
d="M418.813 30.625c-21.178 26.27-49.712 50.982-84.125 70.844-36.778 21.225-75.064 33.62-110.313 38.06a310.317 310.317 0 0 0 6.813 18.25c16.01.277 29.366-.434 36.406-1.5l9.47-1.53 8.436-1.28.22 10.186a307.48 307.48 0 0 1-1.095 18.72l56.625 8.843c.86-.095 1.713-.15 2.563-.157 11.188-.114 21.44 7.29 24.468 18.593.657 2.448.922 4.903.845 7.313 5.972-2.075 11.753-4.305 17.28-6.72l9.595-4.188 2.313 10.22a340.211 340.211 0 0 1 7.375 48.062C438.29 247.836 468.438 225.71 493 197.5c-3.22-36.73-16.154-78.04-39.125-117.813a290.509 290.509 0 0 0-2.22-3.78l-27.56 71.374c5.154.762 10.123 3.158 14.092 7.126 9.81 9.807 9.813 25.69 0 35.5-9.812 9.81-25.722 9.807-35.53 0-8.86-8.858-9.69-22.68-2.532-32.5l38.938-100.844a322.02 322.02 0 0 0-20.25-25.937zM51.842 118.72c-8.46 17.373-15.76 36.198-21.187 56.436-14.108 52.617-13.96 103.682-2.812 143.438 13.3-2.605 26.442-3.96 39.312-4.03 1.855-.012 3.688.02 5.53.06 20.857.48 40.98 4.332 59.97 11.5a355.064 355.064 0 0 1-1.656-34.218c0-27.8 3.135-54.377 9-78.937l2.47-10.407 9.655 4.562c29.467 13.98 66.194 23.424 106.28 25.22 5.136-20.05 8.19-39.78 9.408-58.75-35.198 4.83-75.387 2.766-116.407-8.22-38.363-10.272-72.314-26.78-99.562-46.656zm230.594 82.218c-1.535 10.452-3.615 21.03-6.218 31.687a312.754 312.754 0 0 0 46-3.97 24.98 24.98 0 0 1-1.532-21.748l-38.25-5.97zM105 201.375l4.156 18.22-21.594 4.905c8.75 5.174 13.353 15.703 10.594 26-3.32 12.394-16.045 19.758-28.437 16.438-12.394-3.32-19.76-16.075-16.44-28.47a23.235 23.235 0 0 1 3.126-6.874l-21.062 4.78-4.125-18.218 73.78-16.78zm388.594 22.813c-25.53 25.46-55.306 45.445-86.906 60.5.05 2.397.093 4.8.093 7.218 0 9.188-.354 18.232-1.03 27.125 16.635 1.33 32.045-1.7 45.344-9.374 25.925-14.962 40.608-45.694 42.5-85.47zm-338.844 3c-4.03 19.993-6.33 41.31-6.406 63.593l.125-.342c30.568 10.174 62.622 17.572 95.25 21.375l7.5.875.718 7.5 5.687 60.125-18.625 1.75-2.53-26.75a23.117 23.117 0 0 1-14.845.968c-12.393-3.32-19.76-16.042-16.438-28.436.285-1.06.647-2.08 1.063-3.063a496.627 496.627 0 0 1-57.406-14.53c2.69 49.62 16.154 94.04 36.094 126.656 22.366 36.588 52.13 57.78 83.968 57.78 31.838.003 61.602-21.19 83.97-57.78 19.536-31.96 32.846-75.244 35.905-123.656a499.132 499.132 0 0 1-48.25 11.656c1.914 4.57 2.415 9.78 1.033 14.938-3.322 12.394-16.045 19.758-28.438 16.437a23.01 23.01 0 0 1-2.125-.686l-2.5 26.47-18.594-1.752 5.688-60.125.72-7.5 7.498-.875c29.245-3.407 57.995-9.717 85.657-18.312v-1.594c0-21.573-2.27-42.23-6.064-61.75C351.132 242.653 313.092 250 272.312 250c-43.59 0-83.986-8.658-117.562-22.813zm-87.5 105.968c-10.87.102-21.995 1.22-33.375 3.313 12.695 31.62 33.117 53.07 59 60 16.9 4.523 34.896 2.536 52.813-5.25-4.382-13.89-7.874-28.606-10.344-43.97-21.115-9.623-43.934-14.32-68.094-14.094zm137.5 80.22h130.813c-40.082 44.594-92.623 42.844-130.813 0z"
|
||||||
fill-opacity="1"
|
fill-opacity="1"
|
||||||
style="fill: currentColor; stroke: #ce47eb; stroke-width: 10px;"
|
style="fill: currentColor; stroke: #ce47eb; stroke-width: 10px;"
|
||||||
|
|
||||||
></path></g
|
></path></g
|
||||||
></svg
|
></svg
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -101,9 +101,7 @@ onDestroy(() => {
|
|||||||
|
|
||||||
<!-- Gallery Grid -->
|
<!-- Gallery Grid -->
|
||||||
<div class="w-full mx-auto">
|
<div class="w-full mx-auto">
|
||||||
<div
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 animate-fade-in">
|
||||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 animate-fade-in"
|
|
||||||
>
|
|
||||||
{#each images as image, index (index)}
|
{#each images as image, index (index)}
|
||||||
<button
|
<button
|
||||||
onclick={() => openViewer(index)}
|
onclick={() => openViewer(index)}
|
||||||
@@ -145,14 +143,9 @@ onDestroy(() => {
|
|||||||
|
|
||||||
<!-- Image Viewer Modal -->
|
<!-- Image Viewer Modal -->
|
||||||
{#if isViewerOpen}
|
{#if isViewerOpen}
|
||||||
<div
|
<div class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in"
|
|
||||||
>
|
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
<div
|
<div class="absolute inset-0 bg-black/95 backdrop-blur-xl" onclick={closeViewer}></div>
|
||||||
class="absolute inset-0 bg-black/95 backdrop-blur-xl"
|
|
||||||
onclick={closeViewer}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Viewer Content -->
|
<!-- Viewer Content -->
|
||||||
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">
|
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">
|
||||||
@@ -167,8 +160,8 @@ onDestroy(() => {
|
|||||||
{$_("image_viewer.index", {
|
{$_("image_viewer.index", {
|
||||||
values: {
|
values: {
|
||||||
index: currentImageIndex + 1,
|
index: currentImageIndex + 1,
|
||||||
size: images.length
|
size: images.length,
|
||||||
}
|
},
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-zinc-400 max-w-2xl">
|
<p class="text-zinc-400 max-w-2xl">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ $effect(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative h-10 w-40 rounded-full bg-muted/30 overflow-hidden select-none transition-all duration-300 bg-muted/40 shadow-lg shadow-accent/10 {isDragging ? 'cursor-grabbing' : ''}"
|
class="relative h-10 w-40 rounded-full bg-muted/30 overflow-hidden select-none transition-all duration-300 bg-muted/40 shadow-lg shadow-accent/10 {isDragging
|
||||||
|
? 'cursor-grabbing'
|
||||||
|
: ''}"
|
||||||
style="background: linear-gradient(90deg,
|
style="background: linear-gradient(90deg,
|
||||||
oklch(var(--primary) / 0.3) 0%,
|
oklch(var(--primary) / 0.3) 0%,
|
||||||
oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%,
|
oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%,
|
||||||
@@ -122,27 +124,61 @@ $effect(() => {
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Sliding user info -->
|
<!-- Sliding user info -->
|
||||||
<button class="cursor-grab absolute left-0 top-0 h-full flex items-center gap-3 px-2 transition-all duration-200 ease-out rounded-full bg-background/80 backdrop-blur-sm border border-border/50 bg-background/90 border-primary/20 {isDragging ? '' : 'transition-all duration-300 ease-out'}" style="transform: translateX({slidePosition}px); width: calc(100% - {slidePosition}px);" onmousedown={handleMouseDown} ontouchstart={handleTouchStart}>
|
<button
|
||||||
<Avatar class="h-7 w-7 ring-2 ring-accent/20 transition-all duration-200 {isNearThreshold ? 'ring-destructive/40' : ''}" style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}">
|
class="cursor-grab absolute left-0 top-0 h-full flex items-center gap-3 px-2 transition-all duration-200 ease-out rounded-full bg-background/80 backdrop-blur-sm border border-border/50 bg-background/90 border-primary/20 {isDragging
|
||||||
|
? ''
|
||||||
|
: 'transition-all duration-300 ease-out'}"
|
||||||
|
style="transform: translateX({slidePosition}px); width: calc(100% - {slidePosition}px);"
|
||||||
|
onmousedown={handleMouseDown}
|
||||||
|
ontouchstart={handleTouchStart}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
class="h-7 w-7 ring-2 ring-accent/20 transition-all duration-200 {isNearThreshold
|
||||||
|
? 'ring-destructive/40'
|
||||||
|
: ''}"
|
||||||
|
style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}"
|
||||||
|
>
|
||||||
<AvatarImage src={user.avatar} alt={user.name || user.email} />
|
<AvatarImage src={user.avatar} alt={user.name || user.email} />
|
||||||
<AvatarFallback class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200 {isNearThreshold ? 'from-destructive to-destructive/80' : ''}">
|
<AvatarFallback
|
||||||
|
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200 {isNearThreshold
|
||||||
|
? 'from-destructive to-destructive/80'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
{getUserInitials(user.name || user.email)}
|
{getUserInitials(user.name || user.email)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div class="text-left flex flex-col min-w-0 flex-1">
|
<div class="text-left flex flex-col min-w-0 flex-1">
|
||||||
<span class="text-sm font-medium text-foreground leading-none truncate transition-all duration-200 {isNearThreshold ? 'text-destructive' : ''}" style="opacity: {Math.max(0.15, 1 - slideProgress * 1.5)}">{user?.name ? user.name.split(" ")[0] : "User"}</span>
|
<span
|
||||||
<span class="text-xs text-muted-foreground leading-none transition-all duration-200 {isNearThreshold ? 'text-destructive/70' : ''}" style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}">
|
class="text-sm font-medium text-foreground leading-none truncate transition-all duration-200 {isNearThreshold
|
||||||
|
? 'text-destructive'
|
||||||
|
: ''}"
|
||||||
|
style="opacity: {Math.max(0.15, 1 - slideProgress * 1.5)}"
|
||||||
|
>{user?.name ? user.name.split(" ")[0] : "User"}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-xs text-muted-foreground leading-none transition-all duration-200 {isNearThreshold
|
||||||
|
? 'text-destructive/70'
|
||||||
|
: ''}"
|
||||||
|
style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}"
|
||||||
|
>
|
||||||
{slideProgress > 0.3 ? "Logout" : "Online"}
|
{slideProgress > 0.3 ? "Logout" : "Online"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Logout icon area -->
|
<!-- Logout icon area -->
|
||||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 {isNearThreshold ? 'bg-destructive text-destructive-foreground scale-110' : 'bg-transparent text-foreground'}">
|
<div
|
||||||
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 transition-transform duration-200 {isNearThreshold ? 'scale-110' : ''}" ></span>
|
class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 {isNearThreshold
|
||||||
|
? 'bg-destructive text-destructive-foreground scale-110'
|
||||||
|
: 'bg-transparent text-foreground'}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--logout-circle-r-line] h-4 w-4 transition-transform duration-200 {isNearThreshold
|
||||||
|
? 'scale-110'
|
||||||
|
: ''}"
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Progress indicator -->
|
<!-- Progress indicator -->
|
||||||
<!-- <div class="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-200 rounded-full" style="width: {slideProgress * 100}%"></div> -->
|
<!-- <div class="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-200 rounded-full" style="width: {slideProgress * 100}%"></div> -->
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,10 +18,7 @@
|
|||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$_("head.title", { values: { title } })}</title>
|
<title>{$_("head.title", { values: { title } })}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<meta
|
<meta property="og:title" content={$_("head.title", { values: { title } })} />
|
||||||
property="og:title"
|
|
||||||
content={$_("head.title", { values: { title } })}
|
|
||||||
/>
|
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
<meta property="og:image" content={image} />
|
<meta property="og:image" content={image} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|||||||
@@ -41,17 +41,10 @@ function getStatusColor(status: string): string {
|
|||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<h3
|
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
|
||||||
class="font-semibold text-card-foreground group-hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{recording.title}
|
{recording.title}
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
<span class={cn("text-xs px-2 py-0.5 rounded-full", getStatusColor(recording.status))}>
|
||||||
class={cn(
|
|
||||||
"text-xs px-2 py-0.5 rounded-full",
|
|
||||||
getStatusColor(recording.status),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{$_(`recording_card.status_${recording.status}`)}
|
{$_(`recording_card.status_${recording.status}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,25 +60,17 @@ function getStatusColor(status: string): string {
|
|||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<!-- Stats Grid -->
|
<!-- Stats Grid -->
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<div
|
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
|
||||||
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span>
|
<span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span>
|
||||||
<span class="text-xs text-muted-foreground"
|
<span class="text-xs text-muted-foreground">{$_("recording_card.duration")}</span>
|
||||||
>{$_("recording_card.duration")}</span
|
|
||||||
>
|
|
||||||
<span class="font-medium text-sm">{formatDuration(recording.duration)}</span>
|
<span class="font-medium text-sm">{formatDuration(recording.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
|
||||||
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--pulse-line] w-4 h-4 text-accent mb-1"></span>
|
<span class="icon-[ri--pulse-line] w-4 h-4 text-accent mb-1"></span>
|
||||||
<span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span>
|
<span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span>
|
||||||
<span class="font-medium text-sm">{recording.events.length}</span>
|
<span class="font-medium text-sm">{recording.events.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
|
||||||
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span>
|
<span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span>
|
||||||
<span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
|
<span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
|
||||||
<span class="font-medium text-sm">{recording.device_info.length}</span>
|
<span class="font-medium text-sm">{recording.device_info.length}</span>
|
||||||
@@ -105,9 +90,7 @@ function getStatusColor(status: string): string {
|
|||||||
{/each}
|
{/each}
|
||||||
{#if recording.device_info.length > 2}
|
{#if recording.device_info.length > 2}
|
||||||
<div class="text-xs text-muted-foreground/60 px-2">
|
<div class="text-xs text-muted-foreground/60 px-2">
|
||||||
+{recording.device_info.length - 2} more device{recording.device_info.length -
|
+{recording.device_info.length - 2} more device{recording.device_info.length - 2 > 1
|
||||||
2 >
|
|
||||||
1
|
|
||||||
? "s"
|
? "s"
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,25 +1,19 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
import type {
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||||
HTMLAnchorAttributes,
|
|
||||||
HTMLButtonAttributes,
|
|
||||||
} from "svelte/elements";
|
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
export const buttonVariants = tv({
|
||||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||||
outline:
|
outline:
|
||||||
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||||
secondary:
|
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
ghost:
|
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -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,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} />
|
||||||
|
|||||||
@@ -62,15 +62,10 @@ const change = async (
|
|||||||
(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;
|
||||||
|
|
||||||
@@ -125,11 +120,7 @@ const upload = async (uploadFiles: File[]) => {
|
|||||||
const canUploadFiles = $derived(
|
const canUploadFiles = $derived(
|
||||||
!disabled &&
|
!disabled &&
|
||||||
!uploading &&
|
!uploading &&
|
||||||
!(
|
!(maxFiles !== undefined && fileCount !== undefined && fileCount >= maxFiles),
|
||||||
maxFiles !== undefined &&
|
|
||||||
fileCount !== undefined &&
|
|
||||||
fileCount >= maxFiles
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {
|
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||||
HTMLInputAttributes,
|
|
||||||
HTMLInputTypeAttribute,
|
|
||||||
} from "svelte/elements";
|
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
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 {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
import { Select as SelectPrimitive } from "bits-ui";
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
|
||||||
// eslint-disable-next-line no-useless-assignment
|
// eslint-disable-next-line no-useless-assignment
|
||||||
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps =
|
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||||
$props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SelectPrimitive.Group data-slot="select-group" {...restProps} />
|
<SelectPrimitive.Group data-slot="select-group" {...restProps} />
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
|
||||||
Toaster as Sonner,
|
|
||||||
type ToasterProps as SonnerProps,
|
|
||||||
} from "svelte-sonner";
|
|
||||||
import { mode } from "mode-watcher";
|
import { mode } from "mode-watcher";
|
||||||
|
|
||||||
let { ...restProps }: SonnerProps = $props();
|
let { ...restProps }: SonnerProps = $props();
|
||||||
|
|||||||
@@ -2,11 +2,7 @@
|
|||||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let { ref = $bindable(null), class: className, ...restProps }: TabsPrimitive.ListProps = $props();
|
||||||
ref = $bindable(null),
|
|
||||||
class: className,
|
|
||||||
...restProps
|
|
||||||
}: TabsPrimitive.ListProps = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
|
|||||||
@@ -79,8 +79,7 @@ const keydown = (e: KeyboardEvent) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAtBeginning =
|
const isAtBeginning = target.selectionStart === 0 && target.selectionEnd === 0;
|
||||||
target.selectionStart === 0 && target.selectionEnd === 0;
|
|
||||||
|
|
||||||
let shouldResetIndex = true;
|
let shouldResetIndex = true;
|
||||||
|
|
||||||
|
|||||||
@@ -9,5 +9,4 @@ export type TagsInputPropsWithoutHTML = {
|
|||||||
validate?: (val: string, tags: string[]) => string | undefined;
|
validate?: (val: string, tags: string[]) => string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TagsInputProps = TagsInputPropsWithoutHTML &
|
export type TagsInputProps = TagsInputPropsWithoutHTML & Omit<HTMLInputAttributes, "value">;
|
||||||
Omit<HTMLInputAttributes, "value">;
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
// Re-export from api.ts for backwards compatibility
|
// Re-export from api.ts for backwards compatibility
|
||||||
// All components that import from $lib/directus continue to work
|
// All components that import from $lib/directus continue to work
|
||||||
export { apiUrl as directusApiUrl, getAssetUrl, isModel, getGraphQLClient as getDirectusInstance } from "./api.js";
|
export {
|
||||||
|
apiUrl as directusApiUrl,
|
||||||
|
getAssetUrl,
|
||||||
|
isModel,
|
||||||
|
getGraphQLClient as getDirectusInstance,
|
||||||
|
} from "./api.js";
|
||||||
|
|||||||
@@ -186,8 +186,7 @@ export default {
|
|||||||
password_placeholder: "Create a strong password",
|
password_placeholder: "Create a strong password",
|
||||||
confirm_password: "Confirm Password",
|
confirm_password: "Confirm Password",
|
||||||
confirm_password_placeholder: "Confirm your password",
|
confirm_password_placeholder: "Confirm your password",
|
||||||
terms_agreement:
|
terms_agreement: "I agree to the {terms} and {privacy}. I confirm I am 18+ years old.",
|
||||||
"I agree to the {terms} and {privacy}. I confirm I am 18+ years old.",
|
|
||||||
terms_of_service: "Terms of Service",
|
terms_of_service: "Terms of Service",
|
||||||
privacy_policy: "Privacy Policy",
|
privacy_policy: "Privacy Policy",
|
||||||
creating_account: "Creating account...",
|
creating_account: "Creating account...",
|
||||||
@@ -264,8 +263,7 @@ export default {
|
|||||||
},
|
},
|
||||||
videos: {
|
videos: {
|
||||||
title: "Your Videos",
|
title: "Your Videos",
|
||||||
description:
|
description: "Explore our curated collection of intimate and artistic video content",
|
||||||
"Explore our curated collection of intimate and artistic video content",
|
|
||||||
search_placeholder: "Search videos or models...",
|
search_placeholder: "Search videos or models...",
|
||||||
categories: {
|
categories: {
|
||||||
all: "All Categories",
|
all: "All Categories",
|
||||||
@@ -406,8 +404,7 @@ export default {
|
|||||||
},
|
},
|
||||||
values: {
|
values: {
|
||||||
title: "Our Values",
|
title: "Our Values",
|
||||||
subtitle:
|
subtitle: "The principles that guide everything we do and shape our community",
|
||||||
"The principles that guide everything we do and shape our community",
|
|
||||||
authentic_expression: {
|
authentic_expression: {
|
||||||
title: "Authentic Expression",
|
title: "Authentic Expression",
|
||||||
description:
|
description:
|
||||||
@@ -470,8 +467,7 @@ export default {
|
|||||||
},
|
},
|
||||||
faq: {
|
faq: {
|
||||||
title: "Frequently Asked Questions",
|
title: "Frequently Asked Questions",
|
||||||
description:
|
description: "Find answers to common questions about SexyArt, our platform, and services",
|
||||||
"Find answers to common questions about SexyArt, our platform, and services",
|
|
||||||
search_placeholder: "Search frequently asked questions...",
|
search_placeholder: "Search frequently asked questions...",
|
||||||
search_results: "Search Results ({count})",
|
search_results: "Search Results ({count})",
|
||||||
no_results: "No questions found matching your search.",
|
no_results: "No questions found matching your search.",
|
||||||
@@ -863,8 +859,7 @@ export default {
|
|||||||
},
|
},
|
||||||
age_verification_dialog: {
|
age_verification_dialog: {
|
||||||
title: "Age Verification",
|
title: "Age Verification",
|
||||||
description:
|
description: 'By clicking "Confirm", you verify that you are 18 years or older.',
|
||||||
'By clicking "Confirm", you verify that you are 18 years or older.',
|
|
||||||
age: "18+",
|
age: "18+",
|
||||||
confirm: "Confirm",
|
confirm: "Confirm",
|
||||||
exit: "Exit",
|
exit: "Exit",
|
||||||
@@ -914,7 +909,8 @@ export default {
|
|||||||
rank: "Rank",
|
rank: "Rank",
|
||||||
stats: "Stats",
|
stats: "Stats",
|
||||||
how_it_works: "How It Works",
|
how_it_works: "How It Works",
|
||||||
how_it_works_description: "Points are awarded for creating recordings, playing others' recordings, and engaging with the community. Rankings use time-weighted scoring to keep things dynamic.",
|
how_it_works_description:
|
||||||
|
"Points are awarded for creating recordings, playing others' recordings, and engaging with the community. Rankings use time-weighted scoring to keep things dynamic.",
|
||||||
earn_by_creating: "Create Recordings",
|
earn_by_creating: "Create Recordings",
|
||||||
earn_by_creating_desc: "Earn 50 points per published recording",
|
earn_by_creating_desc: "Earn 50 points per published recording",
|
||||||
earn_by_playing: "Play & Complete",
|
earn_by_playing: "Play & Complete",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Provides structured logging with context and request tracing
|
* Provides structured logging with context and request tracing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
interface LogContext {
|
interface LogContext {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -19,11 +19,12 @@ interface LogContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
private isDev = process.env.NODE_ENV === 'development';
|
private isDev = process.env.NODE_ENV === "development";
|
||||||
private serviceName = 'sexy.pivoine.art';
|
private serviceName = "sexy.pivoine.art";
|
||||||
|
|
||||||
private formatLog(ctx: LogContext): string {
|
private formatLog(ctx: LogContext): string {
|
||||||
const { timestamp, level, message, context, requestId, userId, path, method, duration, error } = ctx;
|
const { timestamp, level, message, context, requestId, userId, path, method, duration, error } =
|
||||||
|
ctx;
|
||||||
|
|
||||||
const parts = [
|
const parts = [
|
||||||
`[${timestamp}]`,
|
`[${timestamp}]`,
|
||||||
@@ -35,10 +36,10 @@ class Logger {
|
|||||||
duration !== undefined ? `${duration}ms` : null,
|
duration !== undefined ? `${duration}ms` : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
let logString = parts.join(' ');
|
let logString = parts.join(" ");
|
||||||
|
|
||||||
if (context && Object.keys(context).length > 0) {
|
if (context && Object.keys(context).length > 0) {
|
||||||
logString += ' ' + JSON.stringify(context);
|
logString += " " + JSON.stringify(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -60,44 +61,40 @@ class Logger {
|
|||||||
const formattedLog = this.formatLog(logContext);
|
const formattedLog = this.formatLog(logContext);
|
||||||
|
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case 'debug':
|
case "debug":
|
||||||
if (this.isDev) console.debug(formattedLog);
|
if (this.isDev) console.debug(formattedLog);
|
||||||
break;
|
break;
|
||||||
case 'info':
|
case "info":
|
||||||
console.info(formattedLog);
|
console.info(formattedLog);
|
||||||
break;
|
break;
|
||||||
case 'warn':
|
case "warn":
|
||||||
console.warn(formattedLog);
|
console.warn(formattedLog);
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case "error":
|
||||||
console.error(formattedLog);
|
console.error(formattedLog);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug(message: string, meta?: Partial<LogContext>) {
|
debug(message: string, meta?: Partial<LogContext>) {
|
||||||
this.log('debug', message, meta);
|
this.log("debug", message, meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
info(message: string, meta?: Partial<LogContext>) {
|
info(message: string, meta?: Partial<LogContext>) {
|
||||||
this.log('info', message, meta);
|
this.log("info", message, meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
warn(message: string, meta?: Partial<LogContext>) {
|
warn(message: string, meta?: Partial<LogContext>) {
|
||||||
this.log('warn', message, meta);
|
this.log("warn", message, meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
error(message: string, meta?: Partial<LogContext>) {
|
error(message: string, meta?: Partial<LogContext>) {
|
||||||
this.log('error', message, meta);
|
this.log("error", message, meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request logging helper
|
// Request logging helper
|
||||||
request(
|
request(method: string, path: string, meta: Partial<LogContext> = {}) {
|
||||||
method: string,
|
this.info("→ Request received", { method, path, ...meta });
|
||||||
path: string,
|
|
||||||
meta: Partial<LogContext> = {}
|
|
||||||
) {
|
|
||||||
this.info('→ Request received', { method, path, ...meta });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response(
|
response(
|
||||||
@@ -105,15 +102,15 @@ class Logger {
|
|||||||
path: string,
|
path: string,
|
||||||
status: number,
|
status: number,
|
||||||
duration: number,
|
duration: number,
|
||||||
meta: Partial<LogContext> = {}
|
meta: Partial<LogContext> = {},
|
||||||
) {
|
) {
|
||||||
const level = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info';
|
const level = status >= 500 ? "error" : status >= 400 ? "warn" : "info";
|
||||||
this.log(level, `← Response ${status}`, { method, path, duration, ...meta });
|
this.log(level, `← Response ${status}`, { method, path, duration, ...meta });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authentication logging
|
// Authentication logging
|
||||||
auth(action: string, success: boolean, meta: Partial<LogContext> = {}) {
|
auth(action: string, success: boolean, meta: Partial<LogContext> = {}) {
|
||||||
this.info(`🔐 Auth: ${action} ${success ? 'success' : 'failed'}`, meta);
|
this.info(`🔐 Auth: ${action} ${success ? "success" : "failed"}`, meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Startup logging
|
// Startup logging
|
||||||
@@ -122,20 +119,20 @@ class Logger {
|
|||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
PUBLIC_API_URL: process.env.PUBLIC_API_URL,
|
PUBLIC_API_URL: process.env.PUBLIC_API_URL,
|
||||||
PUBLIC_URL: process.env.PUBLIC_URL,
|
PUBLIC_URL: process.env.PUBLIC_URL,
|
||||||
PUBLIC_UMAMI_ID: process.env.PUBLIC_UMAMI_ID ? '***set***' : 'not set',
|
PUBLIC_UMAMI_ID: process.env.PUBLIC_UMAMI_ID ? "***set***" : "not set",
|
||||||
PUBLIC_UMAMI_SCRIPT: process.env.PUBLIC_UMAMI_SCRIPT || 'not set',
|
PUBLIC_UMAMI_SCRIPT: process.env.PUBLIC_UMAMI_SCRIPT || "not set",
|
||||||
PORT: process.env.PORT || '3000',
|
PORT: process.env.PORT || "3000",
|
||||||
HOST: process.env.HOST || '0.0.0.0',
|
HOST: process.env.HOST || "0.0.0.0",
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('\n' + '='.repeat(60));
|
console.log("\n" + "=".repeat(60));
|
||||||
console.log('🍑 sexy.pivoine.art - Server Starting 💜');
|
console.log("🍑 sexy.pivoine.art - Server Starting 💜");
|
||||||
console.log('='.repeat(60));
|
console.log("=".repeat(60));
|
||||||
console.log('\n📋 Environment Configuration:');
|
console.log("\n📋 Environment Configuration:");
|
||||||
Object.entries(env).forEach(([key, value]) => {
|
Object.entries(env).forEach(([key, value]) => {
|
||||||
console.log(` ${key}: ${value}`);
|
console.log(` ${key}: ${value}`);
|
||||||
});
|
});
|
||||||
console.log('\n' + '='.repeat(60) + '\n');
|
console.log("\n" + "=".repeat(60) + "\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { logger } from "$lib/logger";
|
|||||||
async function loggedApiCall<T>(
|
async function loggedApiCall<T>(
|
||||||
operationName: string,
|
operationName: string,
|
||||||
operation: () => Promise<T>,
|
operation: () => Promise<T>,
|
||||||
context?: Record<string, unknown>
|
context?: Record<string, unknown>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
@@ -53,8 +53,19 @@ function getAuthClient(token: string, fetchFn?: typeof globalThis.fetch) {
|
|||||||
const ME_QUERY = gql`
|
const ME_QUERY = gql`
|
||||||
query Me {
|
query Me {
|
||||||
me {
|
me {
|
||||||
id email first_name last_name artist_name slug description tags
|
id
|
||||||
role avatar banner email_verified date_created
|
email
|
||||||
|
first_name
|
||||||
|
last_name
|
||||||
|
artist_name
|
||||||
|
slug
|
||||||
|
description
|
||||||
|
tags
|
||||||
|
role
|
||||||
|
avatar
|
||||||
|
banner
|
||||||
|
email_verified
|
||||||
|
date_created
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -81,8 +92,19 @@ export async function isAuthenticated(token: string, fetchFn?: typeof globalThis
|
|||||||
const LOGIN_MUTATION = gql`
|
const LOGIN_MUTATION = gql`
|
||||||
mutation Login($email: String!, $password: String!) {
|
mutation Login($email: String!, $password: String!) {
|
||||||
login(email: $email, password: $password) {
|
login(email: $email, password: $password) {
|
||||||
id email first_name last_name artist_name slug description tags
|
id
|
||||||
role avatar banner email_verified date_created
|
email
|
||||||
|
first_name
|
||||||
|
last_name
|
||||||
|
artist_name
|
||||||
|
slug
|
||||||
|
description
|
||||||
|
tags
|
||||||
|
role
|
||||||
|
avatar
|
||||||
|
banner
|
||||||
|
email_verified
|
||||||
|
date_created
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -101,7 +123,11 @@ export async function login(email: string, password: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOGOUT_MUTATION = gql`mutation Logout { logout }`;
|
const LOGOUT_MUTATION = gql`
|
||||||
|
mutation Logout {
|
||||||
|
logout
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
return loggedApiCall("logout", async () => {
|
return loggedApiCall("logout", async () => {
|
||||||
@@ -191,8 +217,22 @@ export async function resetPassword(token: string, password: string) {
|
|||||||
const ARTICLES_QUERY = gql`
|
const ARTICLES_QUERY = gql`
|
||||||
query GetArticles {
|
query GetArticles {
|
||||||
articles {
|
articles {
|
||||||
id slug title excerpt content image tags publish_date category featured
|
id
|
||||||
author { first_name last_name avatar description }
|
slug
|
||||||
|
title
|
||||||
|
excerpt
|
||||||
|
content
|
||||||
|
image
|
||||||
|
tags
|
||||||
|
publish_date
|
||||||
|
category
|
||||||
|
featured
|
||||||
|
author {
|
||||||
|
first_name
|
||||||
|
last_name
|
||||||
|
avatar
|
||||||
|
description
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -207,8 +247,22 @@ export async function getArticles(fetchFn?: typeof globalThis.fetch) {
|
|||||||
const ARTICLE_BY_SLUG_QUERY = gql`
|
const ARTICLE_BY_SLUG_QUERY = gql`
|
||||||
query GetArticleBySlug($slug: String!) {
|
query GetArticleBySlug($slug: String!) {
|
||||||
article(slug: $slug) {
|
article(slug: $slug) {
|
||||||
id slug title excerpt content image tags publish_date category featured
|
id
|
||||||
author { first_name last_name avatar description }
|
slug
|
||||||
|
title
|
||||||
|
excerpt
|
||||||
|
content
|
||||||
|
image
|
||||||
|
tags
|
||||||
|
publish_date
|
||||||
|
category
|
||||||
|
featured
|
||||||
|
author {
|
||||||
|
first_name
|
||||||
|
last_name
|
||||||
|
avatar
|
||||||
|
description
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -233,10 +287,30 @@ export async function getArticleBySlug(slug: string, fetchFn?: typeof globalThis
|
|||||||
const VIDEOS_QUERY = gql`
|
const VIDEOS_QUERY = gql`
|
||||||
query GetVideos($modelId: String, $featured: Boolean, $limit: Int) {
|
query GetVideos($modelId: String, $featured: Boolean, $limit: Int) {
|
||||||
videos(modelId: $modelId, featured: $featured, limit: $limit) {
|
videos(modelId: $modelId, featured: $featured, limit: $limit) {
|
||||||
id slug title description image movie tags upload_date premium featured
|
id
|
||||||
likes_count plays_count
|
slug
|
||||||
models { id artist_name slug avatar }
|
title
|
||||||
movie_file { id filename mime_type duration }
|
description
|
||||||
|
image
|
||||||
|
movie
|
||||||
|
tags
|
||||||
|
upload_date
|
||||||
|
premium
|
||||||
|
featured
|
||||||
|
likes_count
|
||||||
|
plays_count
|
||||||
|
models {
|
||||||
|
id
|
||||||
|
artist_name
|
||||||
|
slug
|
||||||
|
avatar
|
||||||
|
}
|
||||||
|
movie_file {
|
||||||
|
id
|
||||||
|
filename
|
||||||
|
mime_type
|
||||||
|
duration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -281,10 +355,30 @@ export async function getFeaturedVideos(
|
|||||||
const VIDEO_BY_SLUG_QUERY = gql`
|
const VIDEO_BY_SLUG_QUERY = gql`
|
||||||
query GetVideoBySlug($slug: String!) {
|
query GetVideoBySlug($slug: String!) {
|
||||||
video(slug: $slug) {
|
video(slug: $slug) {
|
||||||
id slug title description image movie tags upload_date premium featured
|
id
|
||||||
likes_count plays_count
|
slug
|
||||||
models { id artist_name slug avatar }
|
title
|
||||||
movie_file { id filename mime_type duration }
|
description
|
||||||
|
image
|
||||||
|
movie
|
||||||
|
tags
|
||||||
|
upload_date
|
||||||
|
premium
|
||||||
|
featured
|
||||||
|
likes_count
|
||||||
|
plays_count
|
||||||
|
models {
|
||||||
|
id
|
||||||
|
artist_name
|
||||||
|
slug
|
||||||
|
avatar
|
||||||
|
}
|
||||||
|
movie_file {
|
||||||
|
id
|
||||||
|
filename
|
||||||
|
mime_type
|
||||||
|
duration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -309,8 +403,18 @@ export async function getVideoBySlug(slug: string, fetchFn?: typeof globalThis.f
|
|||||||
const MODELS_QUERY = gql`
|
const MODELS_QUERY = gql`
|
||||||
query GetModels($featured: Boolean, $limit: Int) {
|
query GetModels($featured: Boolean, $limit: Int) {
|
||||||
models(featured: $featured, limit: $limit) {
|
models(featured: $featured, limit: $limit) {
|
||||||
id slug artist_name description avatar banner tags date_created
|
id
|
||||||
photos { id filename }
|
slug
|
||||||
|
artist_name
|
||||||
|
description
|
||||||
|
avatar
|
||||||
|
banner
|
||||||
|
tags
|
||||||
|
date_created
|
||||||
|
photos {
|
||||||
|
id
|
||||||
|
filename
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -342,8 +446,18 @@ export async function getFeaturedModels(
|
|||||||
const MODEL_BY_SLUG_QUERY = gql`
|
const MODEL_BY_SLUG_QUERY = gql`
|
||||||
query GetModelBySlug($slug: String!) {
|
query GetModelBySlug($slug: String!) {
|
||||||
model(slug: $slug) {
|
model(slug: $slug) {
|
||||||
id slug artist_name description avatar banner tags date_created
|
id
|
||||||
photos { id filename }
|
slug
|
||||||
|
artist_name
|
||||||
|
description
|
||||||
|
avatar
|
||||||
|
banner
|
||||||
|
tags
|
||||||
|
date_created
|
||||||
|
photos {
|
||||||
|
id
|
||||||
|
filename
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -380,8 +494,18 @@ const UPDATE_PROFILE_MUTATION = gql`
|
|||||||
description: $description
|
description: $description
|
||||||
tags: $tags
|
tags: $tags
|
||||||
) {
|
) {
|
||||||
id email first_name last_name artist_name slug description tags
|
id
|
||||||
role avatar banner date_created
|
email
|
||||||
|
first_name
|
||||||
|
last_name
|
||||||
|
artist_name
|
||||||
|
slug
|
||||||
|
description
|
||||||
|
tags
|
||||||
|
role
|
||||||
|
avatar
|
||||||
|
banner
|
||||||
|
date_created
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -410,7 +534,11 @@ export async function updateProfile(user: Partial<User>) {
|
|||||||
|
|
||||||
const STATS_QUERY = gql`
|
const STATS_QUERY = gql`
|
||||||
query GetStats {
|
query GetStats {
|
||||||
stats { videos_count models_count viewers_count }
|
stats {
|
||||||
|
videos_count
|
||||||
|
models_count
|
||||||
|
viewers_count
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -433,7 +561,10 @@ export async function removeFile(id: string) {
|
|||||||
"removeFile",
|
"removeFile",
|
||||||
async () => {
|
async () => {
|
||||||
// File deletion via REST DELETE /assets/:id (backend handles it)
|
// File deletion via REST DELETE /assets/:id (backend handles it)
|
||||||
const response = await fetch(`${apiUrl}/assets/${id}`, { method: "DELETE", credentials: "include" });
|
const response = await fetch(`${apiUrl}/assets/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
if (!response.ok) throw new Error(`Failed to delete file: ${response.statusText}`);
|
if (!response.ok) throw new Error(`Failed to delete file: ${response.statusText}`);
|
||||||
},
|
},
|
||||||
{ fileId: id },
|
{ fileId: id },
|
||||||
@@ -457,8 +588,17 @@ export async function uploadFile(data: FormData) {
|
|||||||
const COMMENTS_FOR_VIDEO_QUERY = gql`
|
const COMMENTS_FOR_VIDEO_QUERY = gql`
|
||||||
query CommentsForVideo($videoId: String!) {
|
query CommentsForVideo($videoId: String!) {
|
||||||
commentsForVideo(videoId: $videoId) {
|
commentsForVideo(videoId: $videoId) {
|
||||||
id comment item_id user_id date_created
|
id
|
||||||
user { id first_name last_name avatar }
|
comment
|
||||||
|
item_id
|
||||||
|
user_id
|
||||||
|
date_created
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
first_name
|
||||||
|
last_name
|
||||||
|
avatar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -468,7 +608,19 @@ export async function getCommentsForVideo(item: string, fetchFn?: typeof globalT
|
|||||||
"getCommentsForVideo",
|
"getCommentsForVideo",
|
||||||
async () => {
|
async () => {
|
||||||
const data = await getGraphQLClient(fetchFn).request<{
|
const data = await getGraphQLClient(fetchFn).request<{
|
||||||
commentsForVideo: { id: number; comment: string; item_id: string; user_id: string; date_created: string; user: { id: string; first_name: string | null; last_name: string | null; avatar: string | null } | null }[];
|
commentsForVideo: {
|
||||||
|
id: number;
|
||||||
|
comment: string;
|
||||||
|
item_id: string;
|
||||||
|
user_id: string;
|
||||||
|
date_created: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
} | null;
|
||||||
|
}[];
|
||||||
}>(COMMENTS_FOR_VIDEO_QUERY, { videoId: item });
|
}>(COMMENTS_FOR_VIDEO_QUERY, { videoId: item });
|
||||||
return data.commentsForVideo;
|
return data.commentsForVideo;
|
||||||
},
|
},
|
||||||
@@ -479,7 +631,11 @@ export async function getCommentsForVideo(item: string, fetchFn?: typeof globalT
|
|||||||
const CREATE_COMMENT_MUTATION = gql`
|
const CREATE_COMMENT_MUTATION = gql`
|
||||||
mutation CreateCommentForVideo($videoId: String!, $comment: String!) {
|
mutation CreateCommentForVideo($videoId: String!, $comment: String!) {
|
||||||
createCommentForVideo(videoId: $videoId, comment: $comment) {
|
createCommentForVideo(videoId: $videoId, comment: $comment) {
|
||||||
id comment item_id user_id date_created
|
id
|
||||||
|
comment
|
||||||
|
item_id
|
||||||
|
user_id
|
||||||
|
date_created
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -532,10 +688,35 @@ export async function getItemsByTag(
|
|||||||
// ─── Recordings ──────────────────────────────────────────────────────────────
|
// ─── Recordings ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const RECORDINGS_QUERY = gql`
|
const RECORDINGS_QUERY = gql`
|
||||||
query GetRecordings($status: String, $tags: String, $linkedVideoId: String, $limit: Int, $page: Int) {
|
query GetRecordings(
|
||||||
recordings(status: $status, tags: $tags, linkedVideoId: $linkedVideoId, limit: $limit, page: $page) {
|
$status: String
|
||||||
id title description slug duration events device_info user_id status
|
$tags: String
|
||||||
tags linked_video featured public date_created date_updated
|
$linkedVideoId: String
|
||||||
|
$limit: Int
|
||||||
|
$page: Int
|
||||||
|
) {
|
||||||
|
recordings(
|
||||||
|
status: $status
|
||||||
|
tags: $tags
|
||||||
|
linkedVideoId: $linkedVideoId
|
||||||
|
limit: $limit
|
||||||
|
page: $page
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
description
|
||||||
|
slug
|
||||||
|
duration
|
||||||
|
events
|
||||||
|
device_info
|
||||||
|
user_id
|
||||||
|
status
|
||||||
|
tags
|
||||||
|
linked_video
|
||||||
|
featured
|
||||||
|
public
|
||||||
|
date_created
|
||||||
|
date_updated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -574,8 +755,21 @@ const CREATE_RECORDING_MUTATION = gql`
|
|||||||
status: $status
|
status: $status
|
||||||
linkedVideoId: $linkedVideoId
|
linkedVideoId: $linkedVideoId
|
||||||
) {
|
) {
|
||||||
id title description slug duration events device_info user_id status
|
id
|
||||||
tags linked_video featured public date_created date_updated
|
title
|
||||||
|
description
|
||||||
|
slug
|
||||||
|
duration
|
||||||
|
events
|
||||||
|
device_info
|
||||||
|
user_id
|
||||||
|
status
|
||||||
|
tags
|
||||||
|
linked_video
|
||||||
|
featured
|
||||||
|
public
|
||||||
|
date_created
|
||||||
|
date_updated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -632,8 +826,21 @@ export async function deleteRecording(id: string) {
|
|||||||
const RECORDING_QUERY = gql`
|
const RECORDING_QUERY = gql`
|
||||||
query GetRecording($id: String!) {
|
query GetRecording($id: String!) {
|
||||||
recording(id: $id) {
|
recording(id: $id) {
|
||||||
id title description slug duration events device_info user_id status
|
id
|
||||||
tags linked_video featured public date_created date_updated
|
title
|
||||||
|
description
|
||||||
|
slug
|
||||||
|
duration
|
||||||
|
events
|
||||||
|
device_info
|
||||||
|
user_id
|
||||||
|
status
|
||||||
|
tags
|
||||||
|
linked_video
|
||||||
|
featured
|
||||||
|
public
|
||||||
|
date_created
|
||||||
|
date_updated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -656,7 +863,10 @@ export async function getRecording(id: string, fetchFn?: typeof globalThis.fetch
|
|||||||
|
|
||||||
const LIKE_VIDEO_MUTATION = gql`
|
const LIKE_VIDEO_MUTATION = gql`
|
||||||
mutation LikeVideo($videoId: String!) {
|
mutation LikeVideo($videoId: String!) {
|
||||||
likeVideo(videoId: $videoId) { liked likes_count }
|
likeVideo(videoId: $videoId) {
|
||||||
|
liked
|
||||||
|
likes_count
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -676,7 +886,10 @@ export async function likeVideo(videoId: string) {
|
|||||||
|
|
||||||
const UNLIKE_VIDEO_MUTATION = gql`
|
const UNLIKE_VIDEO_MUTATION = gql`
|
||||||
mutation UnlikeVideo($videoId: String!) {
|
mutation UnlikeVideo($videoId: String!) {
|
||||||
unlikeVideo(videoId: $videoId) { liked likes_count }
|
unlikeVideo(videoId: $videoId) {
|
||||||
|
liked
|
||||||
|
likes_count
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -696,7 +909,9 @@ export async function unlikeVideo(videoId: string) {
|
|||||||
|
|
||||||
const VIDEO_LIKE_STATUS_QUERY = gql`
|
const VIDEO_LIKE_STATUS_QUERY = gql`
|
||||||
query VideoLikeStatus($videoId: String!) {
|
query VideoLikeStatus($videoId: String!) {
|
||||||
videoLikeStatus(videoId: $videoId) { liked }
|
videoLikeStatus(videoId: $videoId) {
|
||||||
|
liked
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -717,7 +932,9 @@ export async function getVideoLikeStatus(videoId: string, fetchFn?: typeof globa
|
|||||||
const RECORD_VIDEO_PLAY_MUTATION = gql`
|
const RECORD_VIDEO_PLAY_MUTATION = gql`
|
||||||
mutation RecordVideoPlay($videoId: String!, $sessionId: String) {
|
mutation RecordVideoPlay($videoId: String!, $sessionId: String) {
|
||||||
recordVideoPlay(videoId: $videoId, sessionId: $sessionId) {
|
recordVideoPlay(videoId: $videoId, sessionId: $sessionId) {
|
||||||
success play_id plays_count
|
success
|
||||||
|
play_id
|
||||||
|
plays_count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -737,8 +954,18 @@ export async function recordVideoPlay(videoId: string, sessionId?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const UPDATE_VIDEO_PLAY_MUTATION = gql`
|
const UPDATE_VIDEO_PLAY_MUTATION = gql`
|
||||||
mutation UpdateVideoPlay($videoId: String!, $playId: String!, $durationWatched: Int!, $completed: Boolean!) {
|
mutation UpdateVideoPlay(
|
||||||
updateVideoPlay(videoId: $videoId, playId: $playId, durationWatched: $durationWatched, completed: $completed)
|
$videoId: String!
|
||||||
|
$playId: String!
|
||||||
|
$durationWatched: Int!
|
||||||
|
$completed: Boolean!
|
||||||
|
) {
|
||||||
|
updateVideoPlay(
|
||||||
|
videoId: $videoId
|
||||||
|
playId: $playId
|
||||||
|
durationWatched: $durationWatched
|
||||||
|
completed: $completed
|
||||||
|
)
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type WithoutChildren<T> = T extends { children?: any }
|
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||||
? Omit<T, "children">
|
|
||||||
: T;
|
|
||||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
|
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
|
||||||
ref?: U | null;
|
ref?: U | null;
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type WithoutChildren<T> = T extends { children?: any }
|
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||||
? Omit<T, "children">
|
|
||||||
: T;
|
|
||||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
|
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
|
||||||
ref?: U | null;
|
ref?: U | null;
|
||||||
|
|||||||
@@ -100,23 +100,19 @@ import Meta from "$lib/components/meta/meta.svelte";
|
|||||||
<!-- Floating Hearts Animation -->
|
<!-- Floating Hearts Animation -->
|
||||||
<div class="absolute inset-0 pointer-events-none">
|
<div class="absolute inset-0 pointer-events-none">
|
||||||
<div class="absolute top-1/4 left-1/4 opacity-20">
|
<div class="absolute top-1/4 left-1/4 opacity-20">
|
||||||
<span
|
<span class="icon-[ri--heart-3-line] w-6 h-6 text-primary animate-float animation-delay-1000"
|
||||||
class="icon-[ri--heart-3-line] w-6 h-6 text-primary animate-float animation-delay-1000"
|
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-1/3 right-1/3 opacity-15">
|
<div class="absolute top-1/3 right-1/3 opacity-15">
|
||||||
<span
|
<span class="icon-[ri--heart-3-line] w-4 h-4 text-accent animate-float animation-delay-3000"
|
||||||
class="icon-[ri--heart-3-line] w-4 h-4 text-accent animate-float animation-delay-3000"
|
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-1/4 left-1/3 opacity-25">
|
<div class="absolute bottom-1/4 left-1/3 opacity-25">
|
||||||
<span
|
<span class="icon-[ri--heart-3-line] w-5 h-5 text-primary animate-float animation-delay-5000"
|
||||||
class="icon-[ri--heart-3-line] w-5 h-5 text-primary animate-float animation-delay-5000"
|
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-1/3 right-1/4 opacity-20">
|
<div class="absolute bottom-1/3 right-1/4 opacity-20">
|
||||||
<span
|
<span class="icon-[ri--heart-3-line] w-3 h-3 text-accent animate-float animation-delay-7000"
|
||||||
class="icon-[ri--heart-3-line] w-3 h-3 text-accent animate-float animation-delay-7000"
|
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,11 +18,7 @@ let { children, data } = $props();
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
{#if import.meta.env.PROD && env.PUBLIC_UMAMI_ID && env.PUBLIC_UMAMI_SCRIPT}
|
{#if import.meta.env.PROD && env.PUBLIC_UMAMI_ID && env.PUBLIC_UMAMI_SCRIPT}
|
||||||
<script
|
<script defer src={env.PUBLIC_UMAMI_SCRIPT} data-website-id={env.PUBLIC_UMAMI_ID}></script>
|
||||||
defer
|
|
||||||
src={env.PUBLIC_UMAMI_SCRIPT}
|
|
||||||
data-website-id={env.PUBLIC_UMAMI_ID}
|
|
||||||
></script>
|
|
||||||
{/if}
|
{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,12 @@ import { formatVideoDuration } from "$lib/utils.js";
|
|||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Meta title={$_('home.hero.title')} description={$_('home.hero.description')} />
|
<Meta title={$_("home.hero.title")} description={$_("home.hero.description")} />
|
||||||
|
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<section
|
<section class="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||||
class="relative min-h-screen flex items-center justify-center overflow-hidden"
|
|
||||||
>
|
|
||||||
<!-- Background Gradient -->
|
<!-- Background Gradient -->
|
||||||
<div
|
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 via-accent/10 to-background"></div>
|
||||||
class="absolute inset-0 bg-gradient-to-br from-primary/20 via-accent/10 to-background"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="relative z-10 container mx-auto px-4 text-center">
|
<div class="relative z-10 container mx-auto px-4 text-center">
|
||||||
@@ -26,13 +22,11 @@ const { data } = $props();
|
|||||||
<h1
|
<h1
|
||||||
class="text-6xl md:text-8xl font-bold bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent leading-tight mb-8"
|
class="text-6xl md:text-8xl font-bold bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent leading-tight mb-8"
|
||||||
>
|
>
|
||||||
{$_('home.hero.title')}
|
{$_("home.hero.title")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p
|
<p class="text-xl md:text-2xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
|
||||||
class="text-xl md:text-2xl text-muted-foreground max-w-3xl mx-auto leading-relaxed"
|
{$_("home.hero.description")}
|
||||||
>
|
|
||||||
{$_('home.hero.description')}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
@@ -42,13 +36,13 @@ const { data } = $props();
|
|||||||
href="/videos"
|
href="/videos"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--play-large-fill]"></span>
|
<span class="icon-[ri--play-large-fill]"></span>
|
||||||
{$_('home.hero.cta_videos')}
|
{$_("home.hero.cta_videos")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="text-lg px-8 py-6 border-primary/50 hover:bg-primary/10"
|
class="text-lg px-8 py-6 border-primary/50 hover:bg-primary/10"
|
||||||
href="/models">{$_('home.hero.cta_models')}</Button
|
href="/models">{$_("home.hero.cta_models")}</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,10 +62,10 @@ const { data } = $props();
|
|||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
<div class="text-center mb-12">
|
<div class="text-center mb-12">
|
||||||
<h2 class="text-3xl md:text-4xl font-bold mb-4">
|
<h2 class="text-3xl md:text-4xl font-bold mb-4">
|
||||||
{$_('home.featured_models.title')}
|
{$_("home.featured_models.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-muted-foreground text-lg">
|
<p class="text-muted-foreground text-lg">
|
||||||
{$_('home.featured_models.description')}
|
{$_("home.featured_models.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,7 +77,7 @@ const { data } = $props();
|
|||||||
<CardContent class="p-6 text-center">
|
<CardContent class="p-6 text-center">
|
||||||
<div class="relative mb-4">
|
<div class="relative mb-4">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(model.avatar, 'mini')}
|
src={getAssetUrl(model.avatar, "mini")}
|
||||||
alt={model.artist_name}
|
alt={model.artist_name}
|
||||||
class="w-24 h-24 rounded-full mx-auto object-cover ring-4 ring-primary/20 group-hover:ring-primary/40 transition-all"
|
class="w-24 h-24 rounded-full mx-auto object-cover ring-4 ring-primary/20 group-hover:ring-primary/40 transition-all"
|
||||||
/>
|
/>
|
||||||
@@ -107,8 +101,7 @@ const { data } = $props();
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="mt-4 w-full group-hover:bg-primary/10"
|
class="mt-4 w-full group-hover:bg-primary/10"
|
||||||
href="/models/{model.slug}"
|
href="/models/{model.slug}">{$_("home.featured_models.view_profile")}</Button
|
||||||
>{$_('home.featured_models.view_profile')}</Button
|
|
||||||
>
|
>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -122,7 +115,7 @@ const { data } = $props();
|
|||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
<div class="text-center mb-12">
|
<div class="text-center mb-12">
|
||||||
<h2 class="text-3xl md:text-4xl font-bold mb-4">
|
<h2 class="text-3xl md:text-4xl font-bold mb-4">
|
||||||
{$_('home.trending.title')}
|
{$_("home.trending.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<!-- <p class="text-muted-foreground text-lg">Most watched romantic content</p> -->
|
<!-- <p class="text-muted-foreground text-lg">Most watched romantic content</p> -->
|
||||||
</div>
|
</div>
|
||||||
@@ -134,16 +127,14 @@ const { data } = $props();
|
|||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(video.image, 'preview')}
|
src={getAssetUrl(video.image, "preview")}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent group-hover:scale-105 transition-transform duration-300"
|
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent group-hover:scale-105 transition-transform duration-300"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div class="absolute bottom-2 left-2 text-white text-sm font-medium">
|
||||||
class="absolute bottom-2 left-2 text-white text-sm font-medium"
|
|
||||||
>
|
|
||||||
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
|
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- <div
|
<!-- <div
|
||||||
@@ -160,21 +151,18 @@ const { data } = $props();
|
|||||||
href="/videos/{video.slug}"
|
href="/videos/{video.slug}"
|
||||||
aria-label={video.title}
|
aria-label={video.title}
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"
|
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
|
||||||
></span>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardContent class="px-4 pb-4 pt-0">
|
<CardContent class="px-4 pb-4 pt-0">
|
||||||
<h3
|
<h3 class="font-semibold mb-2 group-hover:text-primary transition-colors">
|
||||||
class="font-semibold mb-2 group-hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{video.title}
|
{video.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<span class="icon-[ri--fire-line] w-4 h-4"></span>
|
<span class="icon-[ri--fire-line] w-4 h-4"></span>
|
||||||
{$_('home.trending.trending')}
|
{$_("home.trending.trending")}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -184,29 +172,27 @@ const { data } = $props();
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- CTA Section -->
|
<!-- CTA Section -->
|
||||||
<section
|
<section class="py-20 bg-gradient-to-r from-primary/10 via-accent/10 to-primary/10">
|
||||||
class="py-20 bg-gradient-to-r from-primary/10 via-accent/10 to-primary/10"
|
|
||||||
>
|
|
||||||
<div class="container mx-auto px-4 text-center">
|
<div class="container mx-auto px-4 text-center">
|
||||||
<div class="max-w-3xl mx-auto space-y-8">
|
<div class="max-w-3xl mx-auto space-y-8">
|
||||||
<h2 class="text-3xl md:text-4xl font-bold">
|
<h2 class="text-3xl md:text-4xl font-bold">
|
||||||
{$_('home.featured_models.join_community')}
|
{$_("home.featured_models.join_community")}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-lg text-muted-foreground">
|
<p class="text-lg text-muted-foreground">
|
||||||
{$_('home.featured_models.join_community_description')}
|
{$_("home.featured_models.join_community_description")}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
<Button
|
<Button
|
||||||
href="/signup"
|
href="/signup"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-lg px-8 py-6"
|
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-lg px-8 py-6"
|
||||||
>{$_('home.community.cta_join')}</Button
|
>{$_("home.community.cta_join")}</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="text-lg px-8 py-6 border-primary/50 hover:bg-primary/10"
|
class="text-lg px-8 py-6 border-primary/50 hover:bg-primary/10"
|
||||||
href="/magazine">{$_('home.community.cta_magazine')}</Button
|
href="/magazine">{$_("home.community.cta_magazine")}</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -285,8 +285,7 @@ const values = [
|
|||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="mailto:{$_('about.contact.general.mailto')}"
|
href="mailto:{$_('about.contact.general.mailto')}"
|
||||||
class="text-primary hover:underline"
|
class="text-primary hover:underline">{$_("about.contact.general.mailto")}</a
|
||||||
>{$_("about.contact.general.mailto")}</a
|
|
||||||
>
|
>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -300,8 +299,7 @@ const values = [
|
|||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="mailto:{$_('about.contact.creators.mailto')}"
|
href="mailto:{$_('about.contact.creators.mailto')}"
|
||||||
class="text-primary hover:underline"
|
class="text-primary hover:underline">{$_("about.contact.creators.mailto")}</a
|
||||||
>{$_("about.contact.creators.mailto")}</a
|
|
||||||
>
|
>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { SvelteSet } from "svelte/reactivity";
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "$lib/components/ui/card";
|
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||||
@@ -205,8 +200,7 @@ function toggleExpanded(id: number) {
|
|||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="max-w-2xl mx-auto mb-12">
|
<div class="max-w-2xl mx-auto mb-12">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<span
|
<span class="icon-[ri--search-line] absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5"
|
||||||
class="icon-[ri--search-line] absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5"
|
|
||||||
></span>
|
></span>
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_("faq.search_placeholder")}
|
placeholder={$_("faq.search_placeholder")}
|
||||||
@@ -283,8 +277,7 @@ function toggleExpanded(id: number) {
|
|||||||
<div
|
<div
|
||||||
class="w-10 h-10 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center"
|
class="w-10 h-10 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<span class={category.icon + " w-5 h-5 text-primary"}
|
<span class={category.icon + " w-5 h-5 text-primary"}></span>
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
{category.title}
|
{category.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -292,9 +285,7 @@ function toggleExpanded(id: number) {
|
|||||||
<CardContent class="pt-0">
|
<CardContent class="pt-0">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each category.questions as question (question.id)}
|
{#each category.questions as question (question.id)}
|
||||||
<div
|
<div class="border border-border/50 rounded-lg overflow-hidden">
|
||||||
class="border border-border/50 rounded-lg overflow-hidden"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
onclick={() => toggleExpanded(question.id)}
|
onclick={() => toggleExpanded(question.id)}
|
||||||
class="w-full p-4 text-left hover:bg-primary/5 transition-colors flex items-center justify-between"
|
class="w-full p-4 text-left hover:bg-primary/5 transition-colors flex items-center justify-between"
|
||||||
@@ -314,9 +305,7 @@ function toggleExpanded(id: number) {
|
|||||||
</button>
|
</button>
|
||||||
{#if expandedItems.has(question.id)}
|
{#if expandedItems.has(question.id)}
|
||||||
<div class="p-4 border-t border-border/50">
|
<div class="p-4 border-t border-border/50">
|
||||||
<p
|
<p class="text-muted-foreground text-sm leading-relaxed">
|
||||||
class="text-muted-foreground text-sm leading-relaxed"
|
|
||||||
>
|
|
||||||
{question.answer}
|
{question.answer}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,8 +331,7 @@ function toggleExpanded(id: number) {
|
|||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
<Button
|
<Button
|
||||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
href="mailto:{$_('faq.support.contact_email')}"
|
href="mailto:{$_('faq.support.contact_email')}">{$_("faq.support.contact")}</Button
|
||||||
>{$_("faq.support.contact")}</Button
|
|
||||||
>
|
>
|
||||||
<!-- <Button
|
<!-- <Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "$lib/components/ui/card";
|
|
||||||
import { Separator } from "$lib/components/ui/separator";
|
import { Separator } from "$lib/components/ui/separator";
|
||||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ import { getGraphQLClient } from "$lib/api";
|
|||||||
const LEADERBOARD_QUERY = gql`
|
const LEADERBOARD_QUERY = gql`
|
||||||
query Leaderboard($limit: Int, $offset: Int) {
|
query Leaderboard($limit: Int, $offset: Int) {
|
||||||
leaderboard(limit: $limit, offset: $offset) {
|
leaderboard(limit: $limit, offset: $offset) {
|
||||||
user_id display_name avatar
|
user_id
|
||||||
total_weighted_points total_raw_points
|
display_name
|
||||||
recordings_count playbacks_count achievements_count rank
|
avatar
|
||||||
|
total_weighted_points
|
||||||
|
total_raw_points
|
||||||
|
recordings_count
|
||||||
|
playbacks_count
|
||||||
|
achievements_count
|
||||||
|
rank
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ function getUserInitials(name: string): string {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Meta title={$_("gamification.leaderboard")} description={$_("gamification.leaderboard_description")} />
|
<Meta
|
||||||
|
title={$_("gamification.leaderboard")}
|
||||||
|
description={$_("gamification.leaderboard_description")}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
|
<div class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
|
||||||
<PeonyBackground />
|
<PeonyBackground />
|
||||||
@@ -83,18 +86,24 @@ function getUserInitials(name: string): string {
|
|||||||
{#if entry.rank <= 3}
|
{#if entry.rank <= 3}
|
||||||
<span class="text-3xl">{getMedalEmoji(entry.rank)}</span>
|
<span class="text-3xl">{getMedalEmoji(entry.rank)}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-xl font-bold text-muted-foreground group-hover:text-foreground transition-colors">
|
<span
|
||||||
|
class="text-xl font-bold text-muted-foreground group-hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
#{entry.rank}
|
#{entry.rank}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Avatar -->
|
<!-- Avatar -->
|
||||||
<Avatar class="h-12 w-12 ring-2 ring-accent/20 group-hover:ring-primary/40 transition-all">
|
<Avatar
|
||||||
|
class="h-12 w-12 ring-2 ring-accent/20 group-hover:ring-primary/40 transition-all"
|
||||||
|
>
|
||||||
{#if entry.avatar}
|
{#if entry.avatar}
|
||||||
<AvatarImage src={getAssetUrl(entry.avatar, "mini")} alt={entry.display_name} />
|
<AvatarImage src={getAssetUrl(entry.avatar, "mini")} alt={entry.display_name} />
|
||||||
{/if}
|
{/if}
|
||||||
<AvatarFallback class="bg-gradient-to-br from-primary to-accent text-primary-foreground font-semibold">
|
<AvatarFallback
|
||||||
|
class="bg-gradient-to-br from-primary to-accent text-primary-foreground font-semibold"
|
||||||
|
>
|
||||||
{getUserInitials(entry.display_name)}
|
{getUserInitials(entry.display_name)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@@ -143,7 +152,8 @@ function getUserInitials(name: string): string {
|
|||||||
<div class="mt-6 text-center">
|
<div class="mt-6 text-center">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
href="/leaderboard?offset={data.pagination.offset + data.pagination.limit}&limit={data.pagination.limit}"
|
href="/leaderboard?offset={data.pagination.offset +
|
||||||
|
data.pagination.limit}&limit={data.pagination.limit}"
|
||||||
>
|
>
|
||||||
{$_("common.load_more")}
|
{$_("common.load_more")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -165,14 +175,16 @@ function getUserInitials(name: string): string {
|
|||||||
</p>
|
</p>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<span class="icon-[ri--video-add-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"></span>
|
<span class="icon-[ri--video-add-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"
|
||||||
|
></span>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium">{$_("gamification.earn_by_creating")}</div>
|
<div class="font-medium">{$_("gamification.earn_by_creating")}</div>
|
||||||
<div class="text-muted-foreground">{$_("gamification.earn_by_creating_desc")}</div>
|
<div class="text-muted-foreground">{$_("gamification.earn_by_creating_desc")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<span class="icon-[ri--play-circle-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"></span>
|
<span class="icon-[ri--play-circle-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"
|
||||||
|
></span>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium">{$_("gamification.earn_by_playing")}</div>
|
<div class="font-medium">{$_("gamification.earn_by_playing")}</div>
|
||||||
<div class="text-muted-foreground">{$_("gamification.earn_by_playing_desc")}</div>
|
<div class="text-muted-foreground">{$_("gamification.earn_by_playing_desc")}</div>
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||||
Card,
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs";
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "$lib/components/ui/card";
|
|
||||||
import {
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "$lib/components/ui/tabs";
|
|
||||||
import { Separator } from "$lib/components/ui/separator";
|
import { Separator } from "$lib/components/ui/separator";
|
||||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
@@ -161,8 +151,7 @@ let activeTab = $state("privacy");
|
|||||||
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
<Card class="bg-gradient-to-br from-card to-card/50 border-primary/20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="flex items-center gap-2">
|
<CardTitle class="flex items-center gap-2">
|
||||||
<span class="icon-[ri--file-list-3-line] w-5 h-5 text-primary"
|
<span class="icon-[ri--file-list-3-line] w-5 h-5 text-primary"></span>
|
||||||
></span>
|
|
||||||
{$_("legal.terms.title")}
|
{$_("legal.terms.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p class="text-muted-foreground">
|
<p class="text-muted-foreground">
|
||||||
@@ -426,9 +415,8 @@ let activeTab = $state("privacy");
|
|||||||
<p class="text-muted-foreground mb-4">
|
<p class="text-muted-foreground mb-4">
|
||||||
{$_("legal.questions_description")}
|
{$_("legal.questions_description")}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a href="mailto:{$_('legal.questions_email')}" class="text-primary hover:underline"
|
||||||
href="mailto:{$_('legal.questions_email')}"
|
>{$_("legal.questions_email")}</a
|
||||||
class="text-primary hover:underline">{$_("legal.questions_email")}</a
|
|
||||||
>
|
>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ onMount(() => {
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
||||||
|
|
||||||
<Logo />
|
<Logo />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted-foreground">{$_("auth.login.welcome")}</p>
|
<p class="text-muted-foreground">{$_("auth.login.welcome")}</p>
|
||||||
@@ -131,8 +130,9 @@ onMount(() => {
|
|||||||
<div class="grid w-full max-w-xl items-start gap-4">
|
<div class="grid w-full max-w-xl items-start gap-4">
|
||||||
<Alert.Root variant="destructive">
|
<Alert.Root variant="destructive">
|
||||||
<Alert.Title class="items-center flex"
|
<Alert.Title class="items-center flex"
|
||||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
||||||
></span>{$_("auth.login.error")}</Alert.Title
|
"auth.login.error",
|
||||||
|
)}</Alert.Title
|
||||||
>
|
>
|
||||||
<Alert.Description>{error}</Alert.Description>
|
<Alert.Description>{error}</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import { Button } from "$lib/components/ui/button";
|
|||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
} from "$lib/components/ui/select";
|
|
||||||
|
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
import type { Article } from "$lib/types";
|
import type { Article } from "$lib/types";
|
||||||
@@ -32,32 +27,25 @@ const filteredArticles = $derived(() => {
|
|||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
article.excerpt.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
article.excerpt.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
article.author.first_name
|
article.author.first_name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
.toLowerCase()
|
const matchesCategory = categoryFilter === "all" || article.category === categoryFilter;
|
||||||
.includes(searchQuery.toLowerCase());
|
|
||||||
const matchesCategory =
|
|
||||||
categoryFilter === "all" || article.category === categoryFilter;
|
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (sortBy === "recent")
|
if (sortBy === "recent")
|
||||||
return (
|
return new Date(b.publish_date).getTime() - new Date(a.publish_date).getTime();
|
||||||
new Date(b.publish_date).getTime() -
|
|
||||||
new Date(a.publish_date).getTime()
|
|
||||||
);
|
|
||||||
// if (sortBy === "popular")
|
// if (sortBy === "popular")
|
||||||
// return (
|
// return (
|
||||||
// parseInt(b.views.replace(/[^\d]/g, "")) -
|
// parseInt(b.views.replace(/[^\d]/g, "")) -
|
||||||
// parseInt(a.views.replace(/[^\d]/g, ""))
|
// parseInt(a.views.replace(/[^\d]/g, ""))
|
||||||
// );
|
// );
|
||||||
if (sortBy === "featured")
|
if (sortBy === "featured") return (b.featured ? 1 : 0) - (a.featured ? 1 : 0);
|
||||||
return (b.featured ? 1 : 0) - (a.featured ? 1 : 0);
|
|
||||||
return a.title.localeCompare(b.title);
|
return a.title.localeCompare(b.title);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Meta title={$_('magazine.title')} description={$_('magazine.description')} />
|
<Meta title={$_("magazine.title")} description={$_("magazine.description")} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||||
@@ -84,12 +72,12 @@ const filteredArticles = $derived(() => {
|
|||||||
<h1
|
<h1
|
||||||
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||||
>
|
>
|
||||||
{$_('magazine.title')}
|
{$_("magazine.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
||||||
>
|
>
|
||||||
{$_('magazine.description')}
|
{$_("magazine.description")}
|
||||||
</p>
|
</p>
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
||||||
@@ -99,7 +87,7 @@ const filteredArticles = $derived(() => {
|
|||||||
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
||||||
></span>
|
></span>
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_('magazine.search_placeholder')}
|
placeholder={$_("magazine.search_placeholder")}
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
@@ -111,42 +99,28 @@ const filteredArticles = $derived(() => {
|
|||||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
||||||
{categoryFilter === 'all'
|
{categoryFilter === "all"
|
||||||
? $_('magazine.categories.all')
|
? $_("magazine.categories.all")
|
||||||
: categoryFilter === 'photography'
|
: categoryFilter === "photography"
|
||||||
? $_('magazine.categories.photography')
|
? $_("magazine.categories.photography")
|
||||||
: categoryFilter === 'production'
|
: categoryFilter === "production"
|
||||||
? $_('magazine.categories.production')
|
? $_("magazine.categories.production")
|
||||||
: categoryFilter === 'interview'
|
: categoryFilter === "interview"
|
||||||
? $_('magazine.categories.interview')
|
? $_("magazine.categories.interview")
|
||||||
: categoryFilter === 'psychology'
|
: categoryFilter === "psychology"
|
||||||
? $_('magazine.categories.psychology')
|
? $_("magazine.categories.psychology")
|
||||||
: categoryFilter === 'trends'
|
: categoryFilter === "trends"
|
||||||
? $_('magazine.categories.trends')
|
? $_("magazine.categories.trends")
|
||||||
: $_('magazine.categories.spotlight')}
|
: $_("magazine.categories.spotlight")}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all"
|
<SelectItem value="all">{$_("magazine.categories.all")}</SelectItem>
|
||||||
>{$_('magazine.categories.all')}</SelectItem
|
<SelectItem value="photography">{$_("magazine.categories.photography")}</SelectItem>
|
||||||
>
|
<SelectItem value="production">{$_("magazine.categories.production")}</SelectItem>
|
||||||
<SelectItem value="photography"
|
<SelectItem value="interview">{$_("magazine.categories.interview")}</SelectItem>
|
||||||
>{$_('magazine.categories.photography')}</SelectItem
|
<SelectItem value="psychology">{$_("magazine.categories.psychology")}</SelectItem>
|
||||||
>
|
<SelectItem value="trends">{$_("magazine.categories.trends")}</SelectItem>
|
||||||
<SelectItem value="production"
|
<SelectItem value="spotlight">{$_("magazine.categories.spotlight")}</SelectItem>
|
||||||
>{$_('magazine.categories.production')}</SelectItem
|
|
||||||
>
|
|
||||||
<SelectItem value="interview"
|
|
||||||
>{$_('magazine.categories.interview')}</SelectItem
|
|
||||||
>
|
|
||||||
<SelectItem value="psychology"
|
|
||||||
>{$_('magazine.categories.psychology')}</SelectItem
|
|
||||||
>
|
|
||||||
<SelectItem value="trends"
|
|
||||||
>{$_('magazine.categories.trends')}</SelectItem
|
|
||||||
>
|
|
||||||
<SelectItem value="spotlight"
|
|
||||||
>{$_('magazine.categories.spotlight')}</SelectItem
|
|
||||||
>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -155,25 +129,21 @@ const filteredArticles = $derived(() => {
|
|||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
>
|
>
|
||||||
{sortBy === 'recent'
|
{sortBy === "recent"
|
||||||
? $_('magazine.sort.recent')
|
? $_("magazine.sort.recent")
|
||||||
: sortBy === 'popular'
|
: sortBy === "popular"
|
||||||
? $_('magazine.sort.popular')
|
? $_("magazine.sort.popular")
|
||||||
: sortBy === 'featured'
|
: sortBy === "featured"
|
||||||
? $_('magazine.sort.featured')
|
? $_("magazine.sort.featured")
|
||||||
: $_('magazine.sort.name')}
|
: $_("magazine.sort.name")}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="recent"
|
<SelectItem value="recent">{$_("magazine.sort.recent")}</SelectItem>
|
||||||
>{$_('magazine.sort.recent')}</SelectItem
|
|
||||||
>
|
|
||||||
<!-- <SelectItem value="popular"
|
<!-- <SelectItem value="popular"
|
||||||
>{$_("magazine.sort.popular")}</SelectItem
|
>{$_("magazine.sort.popular")}</SelectItem
|
||||||
> -->
|
> -->
|
||||||
<SelectItem value="featured"
|
<SelectItem value="featured">{$_("magazine.sort.featured")}</SelectItem>
|
||||||
>{$_('magazine.sort.featured')}</SelectItem
|
<SelectItem value="name">{$_("magazine.sort.name")}</SelectItem>
|
||||||
>
|
|
||||||
<SelectItem value="name">{$_('magazine.sort.name')}</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,21 +153,21 @@ const filteredArticles = $derived(() => {
|
|||||||
|
|
||||||
<div class="container mx-auto px-4 py-12">
|
<div class="container mx-auto px-4 py-12">
|
||||||
<!-- Featured Article -->
|
<!-- Featured Article -->
|
||||||
{#if featuredArticle && categoryFilter === 'all' && !searchQuery}
|
{#if featuredArticle && categoryFilter === "all" && !searchQuery}
|
||||||
<Card
|
<Card
|
||||||
class="py-0 mb-12 overflow-hidden bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
class="py-0 mb-12 overflow-hidden bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2">
|
<div class="grid grid-cols-1 lg:grid-cols-2">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(featuredArticle.image, 'medium')}
|
src={getAssetUrl(featuredArticle.image, "medium")}
|
||||||
alt={featuredArticle.title}
|
alt={featuredArticle.title}
|
||||||
class="w-full h-96 object-cover"
|
class="w-full h-96 object-cover"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute top-4 left-4 bg-gradient-to-r from-primary to-accent text-white px-3 py-1 rounded-full text-sm font-medium"
|
class="absolute top-4 left-4 bg-gradient-to-r from-primary to-accent text-white px-3 py-1 rounded-full text-sm font-medium"
|
||||||
>
|
>
|
||||||
{$_('magazine.featured')}
|
{$_("magazine.featured")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardContent class="p-8 flex flex-col justify-center">
|
<CardContent class="p-8 flex flex-col justify-center">
|
||||||
@@ -208,13 +178,9 @@ const filteredArticles = $derived(() => {
|
|||||||
{featuredArticle.category}
|
{featuredArticle.category}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h2
|
<h2 class="text-2xl md:text-3xl font-bold mb-4 hover:text-primary transition-colors">
|
||||||
class="text-2xl md:text-3xl font-bold mb-4 hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<button class="text-left">
|
<button class="text-left">
|
||||||
<a href="/article/{featuredArticle.slug}"
|
<a href="/article/{featuredArticle.slug}">{featuredArticle.title}</a>
|
||||||
>{featuredArticle.title}</a
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-muted-foreground mb-6 text-lg leading-relaxed">
|
<p class="text-muted-foreground mb-6 text-lg leading-relaxed">
|
||||||
@@ -223,26 +189,20 @@ const filteredArticles = $derived(() => {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(featuredArticle.author.avatar, 'mini')}
|
src={getAssetUrl(featuredArticle.author.avatar, "mini")}
|
||||||
alt={featuredArticle.author.first_name}
|
alt={featuredArticle.author.first_name}
|
||||||
class="w-10 h-10 rounded-full object-cover"
|
class="w-10 h-10 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{featuredArticle.author.first_name}</p>
|
<p class="font-medium">{featuredArticle.author.first_name}</p>
|
||||||
<div
|
<div class="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
class="flex items-center gap-3 text-sm text-muted-foreground"
|
<span>{timeAgo.format(new Date(featuredArticle.publish_date))}</span>
|
||||||
>
|
|
||||||
<span
|
|
||||||
>{timeAgo.format(
|
|
||||||
new Date(featuredArticle.publish_date)
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span
|
<span
|
||||||
>{$_('magazine.read_time', {
|
>{$_("magazine.read_time", {
|
||||||
values: {
|
values: {
|
||||||
time: calcReadingTime(featuredArticle.content)
|
time: calcReadingTime(featuredArticle.content),
|
||||||
}
|
},
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,8 +210,7 @@ const filteredArticles = $derived(() => {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
href="/magazine/{featuredArticle.slug}"
|
href="/magazine/{featuredArticle.slug}">{$_("magazine.read_article")}</Button
|
||||||
>{$_('magazine.read_article')}</Button
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -267,7 +226,7 @@ const filteredArticles = $derived(() => {
|
|||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(article.image, 'preview')}
|
src={getAssetUrl(article.image, "preview")}
|
||||||
alt={article.title}
|
alt={article.title}
|
||||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
/>
|
/>
|
||||||
@@ -287,7 +246,7 @@ const filteredArticles = $derived(() => {
|
|||||||
<div
|
<div
|
||||||
class="absolute top-3 right-3 bg-gradient-to-r from-primary to-accent text-white text-xs px-2 py-1 rounded-full"
|
class="absolute top-3 right-3 bg-gradient-to-r from-primary to-accent text-white text-xs px-2 py-1 rounded-full"
|
||||||
>
|
>
|
||||||
{$_('magazine.featured')}
|
{$_("magazine.featured")}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -307,9 +266,7 @@ const filteredArticles = $derived(() => {
|
|||||||
>
|
>
|
||||||
<a href="/magazine/{article.slug}">{article.title}</a>
|
<a href="/magazine/{article.slug}">{article.title}</a>
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p class="text-muted-foreground text-sm line-clamp-3 leading-relaxed">
|
||||||
class="text-muted-foreground text-sm line-clamp-3 leading-relaxed"
|
|
||||||
>
|
|
||||||
{article.excerpt}
|
{article.excerpt}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,23 +287,21 @@ const filteredArticles = $derived(() => {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(article.author.avatar, 'mini')}
|
src={getAssetUrl(article.author.avatar, "mini")}
|
||||||
alt={article.author.first_name}
|
alt={article.author.first_name}
|
||||||
class="w-8 h-8 rounded-full object-cover"
|
class="w-8 h-8 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium">{article.author.first_name}</p>
|
<p class="text-sm font-medium">{article.author.first_name}</p>
|
||||||
<div
|
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
class="flex items-center gap-2 text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||||
{timeAgo.format(new Date(article.publish_date))}
|
{timeAgo.format(new Date(article.publish_date))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-muted-foreground">
|
<div class="text-xs text-muted-foreground">
|
||||||
{$_('magazine.read_time', {
|
{$_("magazine.read_time", {
|
||||||
values: { time: calcReadingTime(article.content) }
|
values: { time: calcReadingTime(article.content) },
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,8 +311,7 @@ const filteredArticles = $derived(() => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-full mt-4 border-primary/20 hover:bg-primary/10"
|
class="w-full mt-4 border-primary/20 hover:bg-primary/10"
|
||||||
href="/magazine/{article.slug}"
|
href="/magazine/{article.slug}">{$_("magazine.read_article")}</Button
|
||||||
>{$_('magazine.read_article')}</Button
|
|
||||||
>
|
>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -367,17 +321,17 @@ const filteredArticles = $derived(() => {
|
|||||||
{#if filteredArticles().length === 0}
|
{#if filteredArticles().length === 0}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<p class="text-muted-foreground text-lg mb-4">
|
<p class="text-muted-foreground text-lg mb-4">
|
||||||
{$_('magazine.no_results')}
|
{$_("magazine.no_results")}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
searchQuery = '';
|
searchQuery = "";
|
||||||
categoryFilter = 'all';
|
categoryFilter = "all";
|
||||||
}}
|
}}
|
||||||
class="border-primary/20 hover:bg-primary/10"
|
class="border-primary/20 hover:bg-primary/10"
|
||||||
>
|
>
|
||||||
{$_('magazine.clear_filters')}
|
{$_("magazine.clear_filters")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -58,9 +58,7 @@ const timeAgo = new TimeAgo("en");
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Meta Information -->
|
<!-- Meta Information -->
|
||||||
<div
|
<div class="flex flex-wrap items-center gap-6 text-sm text-muted-foreground mb-6">
|
||||||
class="flex flex-wrap items-center gap-6 text-sm text-muted-foreground mb-6"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||||
{timeAgo.format(new Date(data.article.publish_date))}
|
{timeAgo.format(new Date(data.article.publish_date))}
|
||||||
@@ -92,13 +90,14 @@ const timeAgo = new TimeAgo("en");
|
|||||||
<HeartIcon class="w-4 h-4 {isLiked ? 'fill-current' : ''}" />
|
<HeartIcon class="w-4 h-4 {isLiked ? 'fill-current' : ''}" />
|
||||||
{data.article.likes}
|
{data.article.likes}
|
||||||
</Button> -->
|
</Button> -->
|
||||||
<SharingPopupButton content={{
|
<SharingPopupButton
|
||||||
|
content={{
|
||||||
title: data.article.title,
|
title: data.article.title,
|
||||||
description: data.article.excerpt,
|
description: data.article.excerpt,
|
||||||
url: page.url.href,
|
url: page.url.href,
|
||||||
type: "article" as const,
|
type: "article" as const,
|
||||||
}} />
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Featured Image -->
|
<!-- Featured Image -->
|
||||||
@@ -129,7 +128,10 @@ const timeAgo = new TimeAgo("en");
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each data.article.tags as tag (tag)}
|
{#each data.article.tags as tag (tag)}
|
||||||
<a class="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm" href="/tags/{tag}">
|
<a
|
||||||
|
class="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm"
|
||||||
|
href="/tags/{tag}"
|
||||||
|
>
|
||||||
#{tag}
|
#{tag}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -8,12 +8,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "$lib/components/ui/card";
|
} from "$lib/components/ui/card";
|
||||||
import {
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs";
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "$lib/components/ui/tabs";
|
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||||
@@ -26,11 +21,7 @@ import { deleteRecording, removeFile, updateProfile, uploadFile } from "$lib/ser
|
|||||||
import { Textarea } from "$lib/components/ui/textarea";
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
import {
|
import { displaySize, FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
displaySize,
|
|
||||||
FileDropZone,
|
|
||||||
MEGABYTE,
|
|
||||||
} from "$lib/components/ui/file-drop-zone";
|
|
||||||
import RecordingCard from "$lib/components/recording-card/recording-card.svelte";
|
import RecordingCard from "$lib/components/recording-card/recording-card.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
@@ -75,10 +66,7 @@ async function handleProfileSubmit(e: Event) {
|
|||||||
|
|
||||||
if (avatar?.file) {
|
if (avatar?.file) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append(
|
formData.append("folder", data.folders.find((f) => f.name === "avatars")!.id);
|
||||||
"folder",
|
|
||||||
data.folders.find((f) => f.name === "avatars")!.id,
|
|
||||||
);
|
|
||||||
formData.append("file", avatar.file!);
|
formData.append("file", avatar.file!);
|
||||||
const result = await uploadFile(formData);
|
const result = await uploadFile(formData);
|
||||||
avatarId = result.id;
|
avatarId = result.id;
|
||||||
@@ -224,8 +212,7 @@ onMount(() => {
|
|||||||
<Button
|
<Button
|
||||||
href={`/models/${data.authStatus.user!.slug}`}
|
href={`/models/${data.authStatus.user!.slug}`}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="border-primary/20 hover:bg-primary/10"
|
class="border-primary/20 hover:bg-primary/10">{$_("me.view_profile")}</Button
|
||||||
>{$_("me.view_profile")}</Button
|
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -294,8 +281,7 @@ onMount(() => {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onclick={handleAvatarRemove}
|
onclick={handleAvatarRemove}
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
><span class="icon-[ri--delete-bin-line]"
|
><span class="icon-[ri--delete-bin-line]"></span></Button
|
||||||
></span></Button
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,8 +290,7 @@ onMount(() => {
|
|||||||
<!-- Name Fields -->
|
<!-- Name Fields -->
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="firstName">{$_("me.settings.first_name")}</Label
|
<Label for="firstName">{$_("me.settings.first_name")}</Label>
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
id="firstName"
|
id="firstName"
|
||||||
placeholder={$_("me.settings.first_name_placeholder")}
|
placeholder={$_("me.settings.first_name_placeholder")}
|
||||||
@@ -358,9 +343,9 @@ onMount(() => {
|
|||||||
<div class="grid w-full items-start gap-4">
|
<div class="grid w-full items-start gap-4">
|
||||||
<Alert.Root variant="destructive">
|
<Alert.Root variant="destructive">
|
||||||
<Alert.Title class="items-center flex"
|
<Alert.Title class="items-center flex"
|
||||||
><span
|
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
||||||
class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
"me.settings.error",
|
||||||
></span>{$_("me.settings.error")}</Alert.Title
|
)}</Alert.Title
|
||||||
>
|
>
|
||||||
<Alert.Description>{profileError}</Alert.Description>
|
<Alert.Description>{profileError}</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
@@ -423,8 +408,7 @@ onMount(() => {
|
|||||||
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
{#if showPassword}
|
{#if showPassword}
|
||||||
<span class="icon-[ri--eye-off-line] w-4 h-4"
|
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
||||||
></span>
|
|
||||||
{:else}
|
{:else}
|
||||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -434,29 +418,23 @@ onMount(() => {
|
|||||||
|
|
||||||
<!-- Confirm Password -->
|
<!-- Confirm Password -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="confirmPassword"
|
<Label for="confirmPassword">{$_("me.settings.confirm_password")}</Label>
|
||||||
>{$_("me.settings.confirm_password")}</Label
|
|
||||||
>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type={showConfirmPassword ? "text" : "password"}
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
placeholder={$_(
|
placeholder={$_("me.settings.confirm_password_placeholder")}
|
||||||
"me.settings.confirm_password_placeholder",
|
|
||||||
)}
|
|
||||||
bind:value={confirmPassword}
|
bind:value={confirmPassword}
|
||||||
required
|
required
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() =>
|
onclick={() => (showConfirmPassword = !showConfirmPassword)}
|
||||||
(showConfirmPassword = !showConfirmPassword)}
|
|
||||||
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
{#if showConfirmPassword}
|
{#if showConfirmPassword}
|
||||||
<span class="icon-[ri--eye-off-line] w-4 h-4"
|
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
||||||
></span>
|
|
||||||
{:else}
|
{:else}
|
||||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -467,9 +445,9 @@ onMount(() => {
|
|||||||
<div class="grid w-full items-start gap-4">
|
<div class="grid w-full items-start gap-4">
|
||||||
<Alert.Root variant="destructive">
|
<Alert.Root variant="destructive">
|
||||||
<Alert.Title class="items-center flex"
|
<Alert.Title class="items-center flex"
|
||||||
><span
|
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
||||||
class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
"me.settings.error",
|
||||||
></span>{$_("me.settings.error")}</Alert.Title
|
)}</Alert.Title
|
||||||
>
|
>
|
||||||
<Alert.Description>{securityError}</Alert.Description>
|
<Alert.Description>{securityError}</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
@@ -520,12 +498,8 @@ onMount(() => {
|
|||||||
<Card class="bg-card/50 border-primary/20">
|
<Card class="bg-card/50 border-primary/20">
|
||||||
<CardContent class="py-12">
|
<CardContent class="py-12">
|
||||||
<div class="flex flex-col items-center justify-center text-center">
|
<div class="flex flex-col items-center justify-center text-center">
|
||||||
<div
|
<div class="mb-4 p-4 rounded-full bg-muted/30 border border-border/30">
|
||||||
class="mb-4 p-4 rounded-full bg-muted/30 border border-border/30"
|
<span class="icon-[ri--play-list-2-line] w-12 h-12 text-muted-foreground"></span>
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="icon-[ri--play-list-2-line] w-12 h-12 text-muted-foreground"
|
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-semibold mb-2">
|
<h3 class="text-xl font-semibold mb-2">
|
||||||
{$_("me.recordings.no_recordings")}
|
{$_("me.recordings.no_recordings")}
|
||||||
@@ -560,9 +534,7 @@ onMount(() => {
|
|||||||
{#if data.analytics}
|
{#if data.analytics}
|
||||||
<TabsContent value="analytics" class="space-y-6">
|
<TabsContent value="analytics" class="space-y-6">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h2 class="text-2xl font-bold text-card-foreground">
|
<h2 class="text-2xl font-bold text-card-foreground">Analytics Dashboard</h2>
|
||||||
Analytics Dashboard
|
|
||||||
</h2>
|
|
||||||
<p class="text-muted-foreground">
|
<p class="text-muted-foreground">
|
||||||
Track your content performance and audience engagement
|
Track your content performance and audience engagement
|
||||||
</p>
|
</p>
|
||||||
@@ -627,7 +599,10 @@ onMount(() => {
|
|||||||
{#each data.analytics.videos as video (video.slug)}
|
{#each data.analytics.videos as video (video.slug)}
|
||||||
<tr class="border-b border-border/50 hover:bg-primary/5 transition-colors">
|
<tr class="border-b border-border/50 hover:bg-primary/5 transition-colors">
|
||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<a href="/videos/{video.slug}" class="hover:text-primary transition-colors">
|
<a
|
||||||
|
href="/videos/{video.slug}"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
{video.title}
|
{video.title}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -638,12 +613,21 @@ onMount(() => {
|
|||||||
{video.plays}
|
{video.plays}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right p-3">
|
<td class="text-right p-3">
|
||||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs {video.completion_rate >= 70 ? 'bg-green-500/20 text-green-500' : video.completion_rate >= 40 ? 'bg-yellow-500/20 text-yellow-500' : 'bg-red-500/20 text-red-500'}">
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-1 rounded-full text-xs {video.completion_rate >=
|
||||||
|
70
|
||||||
|
? 'bg-green-500/20 text-green-500'
|
||||||
|
: video.completion_rate >= 40
|
||||||
|
? 'bg-yellow-500/20 text-yellow-500'
|
||||||
|
: 'bg-red-500/20 text-red-500'}"
|
||||||
|
>
|
||||||
{video.completion_rate.toFixed(1)}%
|
{video.completion_rate.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right p-3 text-muted-foreground">
|
<td class="text-right p-3 text-muted-foreground">
|
||||||
{Math.floor(video.avg_watch_time / 60)}:{(video.avg_watch_time % 60).toString().padStart(2, '0')}
|
{Math.floor(video.avg_watch_time / 60)}:{(video.avg_watch_time % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ import { _ } from "svelte-i18n";
|
|||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
} from "$lib/components/ui/select";
|
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/directus";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
@@ -24,11 +19,8 @@ const filteredModels = $derived(() => {
|
|||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
searchQuery === "" ||
|
searchQuery === "" ||
|
||||||
model.artist_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
model.artist_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
model.tags.some((tag) =>
|
model.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
tag.toLowerCase().includes(searchQuery.toLowerCase()),
|
const matchesCategory = categoryFilter === "all" || model.category === categoryFilter;
|
||||||
);
|
|
||||||
const matchesCategory =
|
|
||||||
categoryFilter === "all" || model.category === categoryFilter;
|
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -105,15 +97,9 @@ const filteredModels = $derived(() => {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{$_("models.categories.all")}</SelectItem>
|
<SelectItem value="all">{$_("models.categories.all")}</SelectItem>
|
||||||
<SelectItem value="romantic"
|
<SelectItem value="romantic">{$_("models.categories.romantic")}</SelectItem>
|
||||||
>{$_("models.categories.romantic")}</SelectItem
|
<SelectItem value="artistic">{$_("models.categories.artistic")}</SelectItem>
|
||||||
>
|
<SelectItem value="intimate">{$_("models.categories.intimate")}</SelectItem>
|
||||||
<SelectItem value="artistic"
|
|
||||||
>{$_("models.categories.artistic")}</SelectItem
|
|
||||||
>
|
|
||||||
<SelectItem value="intimate"
|
|
||||||
>{$_("models.categories.intimate")}</SelectItem
|
|
||||||
>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -180,11 +166,8 @@ const filteredModels = $derived(() => {
|
|||||||
aria-label={model.artist_name}
|
aria-label={model.artist_name}
|
||||||
class="absolute inset-0 group-hover:scale-105 transition bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 flex items-center justify-center"
|
class="absolute inset-0 group-hover:scale-105 transition bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<div
|
<div class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center">
|
||||||
class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center"
|
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white ml-1"></span>
|
||||||
>
|
|
||||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white ml-1"
|
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,9 +175,7 @@ const filteredModels = $derived(() => {
|
|||||||
<CardContent class="p-6">
|
<CardContent class="p-6">
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3 class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors">
|
||||||
class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{model.artist_name}
|
{model.artist_name}
|
||||||
</h3>
|
</h3>
|
||||||
<!-- <div
|
<!-- <div
|
||||||
@@ -222,9 +203,7 @@ const filteredModels = $derived(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div
|
<div class="flex items-center justify-between text-sm text-muted-foreground mb-4">
|
||||||
class="flex items-center justify-between text-sm text-muted-foreground mb-4"
|
|
||||||
>
|
|
||||||
<!-- <span>{model.videos} videos</span> -->
|
<!-- <span>{model.videos} videos</span> -->
|
||||||
<span class="capitalize">{model.category}</span>
|
<span class="capitalize">{model.category}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
import {
|
import { countCommentsForModel, getModelBySlug, getVideosForModel } from "$lib/services.js";
|
||||||
countCommentsForModel,
|
|
||||||
getModelBySlug,
|
|
||||||
getVideosForModel,
|
|
||||||
} from "$lib/services.js";
|
|
||||||
export async function load({ fetch, params }) {
|
export async function load({ fetch, params }) {
|
||||||
try {
|
try {
|
||||||
const model = await getModelBySlug(params.slug, fetch);
|
const model = await getModelBySlug(params.slug, fetch);
|
||||||
|
|||||||
@@ -2,12 +2,7 @@
|
|||||||
import { _, locale } from "svelte-i18n";
|
import { _, locale } from "svelte-i18n";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import {
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs";
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "$lib/components/ui/tabs";
|
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/directus";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||||
@@ -29,18 +24,14 @@ let images = $derived(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Calculate total likes and plays from all videos
|
// Calculate total likes and plays from all videos
|
||||||
let totalLikes = $derived(
|
let totalLikes = $derived(data.videos.reduce((sum, video) => sum + (video.likes_count || 0), 0));
|
||||||
data.videos.reduce((sum, video) => sum + (video.likes_count || 0), 0)
|
let totalPlays = $derived(data.videos.reduce((sum, video) => sum + (video.plays_count || 0), 0));
|
||||||
);
|
|
||||||
let totalPlays = $derived(
|
|
||||||
data.videos.reduce((sum, video) => sum + (video.plays_count || 0), 0)
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Meta
|
<Meta
|
||||||
title={data.model.artist_name}
|
title={data.model.artist_name}
|
||||||
description={data.model.description}
|
description={data.model.description}
|
||||||
image={getAssetUrl(data.model.avatar, 'medium')!}
|
image={getAssetUrl(data.model.avatar, "medium")!}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -65,22 +56,18 @@ let totalPlays = $derived(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="absolute top-4 left-4 bg-black/50 hover:bg-black/70 text-white"
|
class="absolute top-4 left-4 bg-black/50 hover:bg-black/70 text-white"
|
||||||
href="/models"
|
href="/models"
|
||||||
><span class="icon-[ri--arrow-left-long-line] w-4 h-4 mr-1"></span>{$_(
|
><span class="icon-[ri--arrow-left-long-line] w-4 h-4 mr-1"></span>{$_("models.back")}</Button
|
||||||
'models.back'
|
|
||||||
)}</Button
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Profile Header -->
|
<!-- Profile Header -->
|
||||||
<div class="container mx-auto px-4 -mt-20 relative z-10">
|
<div class="container mx-auto px-4 -mt-20 relative z-10">
|
||||||
<div
|
<div class="bg-card/90 backdrop-blur-sm rounded-2xl border border-border/50 p-6 shadow-2xl">
|
||||||
class="bg-card/90 backdrop-blur-sm rounded-2xl border border-border/50 p-6 shadow-2xl"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col md:flex-row gap-6">
|
<div class="flex flex-col md:flex-row gap-6">
|
||||||
<!-- Profile Image -->
|
<!-- Profile Image -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(data.model.avatar, 'thumbnail')}
|
src={getAssetUrl(data.model.avatar, "thumbnail")}
|
||||||
alt="${data.model.artist_name}"
|
alt="${data.model.artist_name}"
|
||||||
class="w-32 h-32 rounded-2xl object-cover ring-4 ring-primary/20"
|
class="w-32 h-32 rounded-2xl object-cover ring-4 ring-primary/20"
|
||||||
/>
|
/>
|
||||||
@@ -96,9 +83,7 @@ let totalPlays = $derived(
|
|||||||
|
|
||||||
<!-- Profile Info -->
|
<!-- Profile Info -->
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div
|
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||||
class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold mb-2">{data.model.artist_name}</h1>
|
<h1 class="text-3xl font-bold mb-2">{data.model.artist_name}</h1>
|
||||||
<div class="flex items-center gap-4 text-muted-foreground mb-3">
|
<div class="flex items-center gap-4 text-muted-foreground mb-3">
|
||||||
@@ -112,16 +97,14 @@ let totalPlays = $derived(
|
|||||||
</div> -->
|
</div> -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||||
{$_('models.joined', {
|
{$_("models.joined", {
|
||||||
values: {
|
values: {
|
||||||
join_date: new Date(
|
join_date: new Date(data.model.date_created).toLocaleDateString($locale!, {
|
||||||
data.model.date_created
|
day: "numeric",
|
||||||
).toLocaleDateString($locale!, {
|
month: "long",
|
||||||
day: 'numeric',
|
year: "numeric",
|
||||||
month: 'long',
|
}),
|
||||||
year: 'numeric'
|
},
|
||||||
})
|
|
||||||
}
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,7 +152,7 @@ let totalPlays = $derived(
|
|||||||
title: data.model.artist_name,
|
title: data.model.artist_name,
|
||||||
description: data.model.description,
|
description: data.model.description,
|
||||||
url: page.url,
|
url: page.url,
|
||||||
type: 'model'
|
type: "model",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,14 +161,12 @@ let totalPlays = $derived(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6 pt-6 border-t border-border/50">
|
||||||
class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6 pt-6 border-t border-border/50"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-2xl font-bold text-primary">
|
<div class="text-2xl font-bold text-primary">
|
||||||
{data.videos.length}
|
{data.videos.length}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-muted-foreground">{$_('models.videos')}</div>
|
<div class="text-sm text-muted-foreground">{$_("models.videos")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-2xl font-bold text-primary">
|
<div class="text-2xl font-bold text-primary">
|
||||||
@@ -204,7 +185,7 @@ let totalPlays = $derived(
|
|||||||
{data.commentsCount}
|
{data.commentsCount}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="text-sm text-muted-foreground">
|
||||||
{$_('models.comments')}
|
{$_("models.comments")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,11 +198,11 @@ let totalPlays = $derived(
|
|||||||
<TabsList class="grid w-full grid-cols-2 max-w-md mx-auto mb-8">
|
<TabsList class="grid w-full grid-cols-2 max-w-md mx-auto mb-8">
|
||||||
<TabsTrigger value="videos" class="flex items-center gap-2">
|
<TabsTrigger value="videos" class="flex items-center gap-2">
|
||||||
<span class="icon-[ri--play-large-fill] w-4 h-4"></span>
|
<span class="icon-[ri--play-large-fill] w-4 h-4"></span>
|
||||||
{$_('models.videos')}
|
{$_("models.videos")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="photos" class="flex items-center gap-2">
|
<TabsTrigger value="photos" class="flex items-center gap-2">
|
||||||
<span class="icon-[ri--camera-fill] w-4 h-4"></span>
|
<span class="icon-[ri--camera-fill] w-4 h-4"></span>
|
||||||
{$_('models.photos')}
|
{$_("models.photos")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -233,17 +214,17 @@ let totalPlays = $derived(
|
|||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(video.image, 'preview')}
|
src={getAssetUrl(video.image, "preview")}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent transition-transform group-hover:scale-105 duration-300"
|
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent transition-transform group-hover:scale-105 duration-300"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div class="absolute bottom-2 left-2 text-white text-sm font-medium">
|
||||||
class="absolute bottom-2 left-2 text-white text-sm font-medium"
|
{#if video.movie_file?.duration}{formatVideoDuration(
|
||||||
>
|
video.movie_file.duration,
|
||||||
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
|
)}{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- <div
|
<!-- <div
|
||||||
class="absolute top-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded-full"
|
class="absolute top-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded-full"
|
||||||
@@ -258,20 +239,15 @@ let totalPlays = $derived(
|
|||||||
<div
|
<div
|
||||||
class="w-16 h-16 bg-primary/90 rounded-full flex flex-col items-center justify-center shadow-2xl"
|
class="w-16 h-16 bg-primary/90 rounded-full flex flex-col items-center justify-center shadow-2xl"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"
|
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<CardContent class="px-4 pb-4 pt-0">
|
<CardContent class="px-4 pb-4 pt-0">
|
||||||
<h3
|
<h3 class="font-semibold mb-2 group-hover:text-primary transition-colors">
|
||||||
class="font-semibold mb-2 group-hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{video.title}
|
{video.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div
|
<div class="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
class="flex items-center justify-between text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="icon-[ri--play-fill] w-4 h-4"></span>
|
<span class="icon-[ri--play-fill] w-4 h-4"></span>
|
||||||
{video.plays_count || 0}
|
{video.plays_count || 0}
|
||||||
@@ -290,7 +266,6 @@ let totalPlays = $derived(
|
|||||||
<TabsContent value="photos">
|
<TabsContent value="photos">
|
||||||
<ImageViewer {images} />
|
<ImageViewer {images} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ async function handleSubmit(e: Event) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
await requestPassword(email);
|
await requestPassword(email);
|
||||||
toast.success(
|
toast.success($_("auth.password_request.toast_request", { values: { email } }));
|
||||||
$_("auth.password_request.toast_request", { values: { email } }),
|
|
||||||
);
|
|
||||||
goto("/login");
|
goto("/login");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
@@ -64,7 +62,6 @@ onMount(() => {
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
||||||
|
|
||||||
<Logo />
|
<Logo />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted-foreground">{$_("auth.password_request.welcome")}</p>
|
<p class="text-muted-foreground">{$_("auth.password_request.welcome")}</p>
|
||||||
@@ -96,8 +93,9 @@ onMount(() => {
|
|||||||
<div class="grid w-full max-w-xl items-start gap-4">
|
<div class="grid w-full max-w-xl items-start gap-4">
|
||||||
<Alert.Root variant="destructive">
|
<Alert.Root variant="destructive">
|
||||||
<Alert.Title class="items-center flex"
|
<Alert.Title class="items-center flex"
|
||||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
||||||
></span>{$_("auth.password_request.error")}</Alert.Title
|
"auth.password_request.error",
|
||||||
|
)}</Alert.Title
|
||||||
>
|
>
|
||||||
<Alert.Description>{error}</Alert.Description>
|
<Alert.Description>{error}</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ onMount(() => {
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
||||||
|
|
||||||
<Logo />
|
<Logo />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted-foreground">{$_("auth.password_reset.welcome")}</p>
|
<p class="text-muted-foreground">{$_("auth.password_reset.welcome")}</p>
|
||||||
@@ -111,9 +110,7 @@ onMount(() => {
|
|||||||
|
|
||||||
<!-- Confirm Password -->
|
<!-- Confirm Password -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="confirmPassword"
|
<Label for="confirmPassword">{$_("auth.password_reset.confirm_password")}</Label>
|
||||||
>{$_("auth.password_reset.confirm_password")}</Label
|
|
||||||
>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
@@ -141,8 +138,9 @@ onMount(() => {
|
|||||||
<div class="grid w-full max-w-xl items-start gap-4">
|
<div class="grid w-full max-w-xl items-start gap-4">
|
||||||
<Alert.Root variant="destructive">
|
<Alert.Root variant="destructive">
|
||||||
<Alert.Title class="items-center flex"
|
<Alert.Title class="items-center flex"
|
||||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
||||||
></span>{$_("auth.password_reset.error")}</Alert.Title
|
"auth.password_reset.error",
|
||||||
|
)}</Alert.Title
|
||||||
>
|
>
|
||||||
<Alert.Description>{error}</Alert.Description>
|
<Alert.Description>{error}</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
|
|||||||
@@ -86,11 +86,7 @@ function handleInputReading(msg: ButtplugMessage) {
|
|||||||
device.lastSeen = new Date();
|
device.lastSeen = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleChange(
|
async function handleChange(device: BluetoothDevice, actuatorIdx: number, value: number) {
|
||||||
device: BluetoothDevice,
|
|
||||||
actuatorIdx: number,
|
|
||||||
value: number,
|
|
||||||
) {
|
|
||||||
const actuator = device.actuators[actuatorIdx];
|
const actuator = device.actuators[actuatorIdx];
|
||||||
const feature = device.info.features.get(actuator.featureIndex);
|
const feature = device.info.features.get(actuator.featureIndex);
|
||||||
if (!feature) return;
|
if (!feature) return;
|
||||||
@@ -123,11 +119,7 @@ function stopRecording() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureEvent(
|
function captureEvent(device: BluetoothDevice, actuatorIdx: number, value: number) {
|
||||||
device: BluetoothDevice,
|
|
||||||
actuatorIdx: number,
|
|
||||||
value: number,
|
|
||||||
) {
|
|
||||||
if (!recordingStartTime) return;
|
if (!recordingStartTime) return;
|
||||||
|
|
||||||
const timestamp = performance.now() - recordingStartTime;
|
const timestamp = performance.now() - recordingStartTime;
|
||||||
@@ -174,11 +166,7 @@ function convertDevice(device: ButtplugClientDevice): BluetoothDevice {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveRecording(data: {
|
async function handleSaveRecording(data: { title: string; description: string; tags: string[] }) {
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
tags: string[];
|
|
||||||
}) {
|
|
||||||
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
|
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
|
||||||
name: d.name,
|
name: d.name,
|
||||||
index: d.info.index,
|
index: d.info.index,
|
||||||
@@ -332,9 +320,7 @@ function executeEvent(event: RecordedEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find matching actuator by type
|
// Find matching actuator by type
|
||||||
const actuator = device.actuators.find(
|
const actuator = device.actuators.find((a) => a.outputType === event.actuatorType);
|
||||||
(a) => a.outputType === event.actuatorType,
|
|
||||||
);
|
|
||||||
if (!actuator) {
|
if (!actuator) {
|
||||||
console.warn(`Actuator type ${event.actuatorType} not found on ${device.name}`);
|
console.warn(`Actuator type ${event.actuatorType} not found on ${device.name}`);
|
||||||
return;
|
return;
|
||||||
@@ -361,7 +347,7 @@ function seek(percentage: number) {
|
|||||||
playbackProgress = targetTime;
|
playbackProgress = targetTime;
|
||||||
|
|
||||||
// Find the event index at this time
|
// Find the event index at this time
|
||||||
currentEventIndex = data.recording.events.findIndex(e => e.timestamp >= targetTime);
|
currentEventIndex = data.recording.events.findIndex((e) => e.timestamp >= targetTime);
|
||||||
if (currentEventIndex === -1) {
|
if (currentEventIndex === -1) {
|
||||||
currentEventIndex = data.recording.events.length;
|
currentEventIndex = data.recording.events.length;
|
||||||
}
|
}
|
||||||
@@ -496,19 +482,31 @@ onMount(() => {
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
<span class="text-sm text-muted-foreground min-w-[50px]">
|
<span class="text-sm text-muted-foreground min-w-[50px]">
|
||||||
{Math.floor(playbackProgress / 1000 / 60)}:{(Math.floor(playbackProgress / 1000) % 60).toString().padStart(2, '0')}
|
{Math.floor(playbackProgress / 1000 / 60)}:{(
|
||||||
|
Math.floor(playbackProgress / 1000) % 60
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex-1 h-2 bg-muted rounded-full overflow-hidden cursor-pointer relative"
|
<div
|
||||||
|
class="flex-1 h-2 bg-muted rounded-full overflow-hidden cursor-pointer relative"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const percentage = ((e.clientX - rect.left) / rect.width) * 100;
|
const percentage = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
seek(percentage);
|
seek(percentage);
|
||||||
}}>
|
}}
|
||||||
<div class="absolute inset-0 bg-gradient-to-r from-primary to-accent transition-all duration-150"
|
>
|
||||||
style="width: {(playbackProgress / data.recording.duration) * 100}%"></div>
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-r from-primary to-accent transition-all duration-150"
|
||||||
|
style="width: {(playbackProgress / data.recording.duration) * 100}%"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-muted-foreground min-w-[50px] text-right">
|
<span class="text-sm text-muted-foreground min-w-[50px] text-right">
|
||||||
{Math.floor(data.recording.duration / 1000 / 60)}:{(Math.floor(data.recording.duration / 1000) % 60).toString().padStart(2, '0')}
|
{Math.floor(data.recording.duration / 1000 / 60)}:{(
|
||||||
|
Math.floor(data.recording.duration / 1000) % 60
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -532,7 +530,7 @@ onMount(() => {
|
|||||||
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
|
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--play-fill] w-5 h-5 mr-2"></span>
|
<span class="icon-[ri--play-fill] w-5 h-5 mr-2"></span>
|
||||||
{playbackProgress > 0 ? 'Resume' : 'Play'}
|
{playbackProgress > 0 ? "Resume" : "Play"}
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -20,19 +20,17 @@ let mappings = new SvelteMap<string, BluetoothDevice>();
|
|||||||
|
|
||||||
// Check if a connected device is compatible with a recorded device
|
// Check if a connected device is compatible with a recorded device
|
||||||
function isCompatible(recordedDevice: DeviceInfo, connectedDevice: BluetoothDevice): boolean {
|
function isCompatible(recordedDevice: DeviceInfo, connectedDevice: BluetoothDevice): boolean {
|
||||||
const connectedActuators = connectedDevice.actuators.map(
|
const connectedActuators = connectedDevice.actuators.map((a) => a.outputType);
|
||||||
(a) => a.outputType,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if all required actuator types from recording exist on connected device
|
// Check if all required actuator types from recording exist on connected device
|
||||||
return recordedDevice.capabilities.every(requiredType =>
|
return recordedDevice.capabilities.every((requiredType) =>
|
||||||
connectedActuators.includes(requiredType)
|
connectedActuators.includes(requiredType),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get compatible devices for a recorded device
|
// Get compatible devices for a recorded device
|
||||||
function getCompatibleDevices(recordedDevice: DeviceInfo): BluetoothDevice[] {
|
function getCompatibleDevices(recordedDevice: DeviceInfo): BluetoothDevice[] {
|
||||||
return connectedDevices.filter(device => isCompatible(recordedDevice, device));
|
return connectedDevices.filter((device) => isCompatible(recordedDevice, device));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-map devices on open
|
// Auto-map devices on open
|
||||||
@@ -40,9 +38,9 @@ $effect(() => {
|
|||||||
if (open && recordedDevices.length > 0 && connectedDevices.length > 0) {
|
if (open && recordedDevices.length > 0 && connectedDevices.length > 0) {
|
||||||
const newMappings = new SvelteMap<string, BluetoothDevice>();
|
const newMappings = new SvelteMap<string, BluetoothDevice>();
|
||||||
|
|
||||||
recordedDevices.forEach(recordedDevice => {
|
recordedDevices.forEach((recordedDevice) => {
|
||||||
// Try to find exact name match first
|
// Try to find exact name match first
|
||||||
let match = connectedDevices.find(d => d.name === recordedDevice.name);
|
let match = connectedDevices.find((d) => d.name === recordedDevice.name);
|
||||||
|
|
||||||
// If no exact match, find first compatible device
|
// If no exact match, find first compatible device
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -63,7 +61,7 @@ $effect(() => {
|
|||||||
|
|
||||||
function handleConfirm() {
|
function handleConfirm() {
|
||||||
// Validate that all devices are mapped
|
// Validate that all devices are mapped
|
||||||
const allMapped = recordedDevices.every(rd => mappings.has(rd.name));
|
const allMapped = recordedDevices.every((rd) => mappings.has(rd.name));
|
||||||
if (!allMapped) {
|
if (!allMapped) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -73,7 +71,7 @@ function handleConfirm() {
|
|||||||
function handleDeviceSelect(recordedDeviceName: string, deviceId: string) {
|
function handleDeviceSelect(recordedDeviceName: string, deviceId: string) {
|
||||||
if (!deviceId) return;
|
if (!deviceId) return;
|
||||||
|
|
||||||
const device = connectedDevices.find(d => d.id === deviceId);
|
const device = connectedDevices.find((d) => d.id === deviceId);
|
||||||
if (device) {
|
if (device) {
|
||||||
const newMappings = new SvelteMap(mappings);
|
const newMappings = new SvelteMap(mappings);
|
||||||
newMappings.set(recordedDeviceName, device);
|
newMappings.set(recordedDeviceName, device);
|
||||||
@@ -81,9 +79,7 @@ function handleDeviceSelect(recordedDeviceName: string, deviceId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allDevicesMapped = $derived(
|
const allDevicesMapped = $derived(recordedDevices.every((rd) => mappings.has(rd.name)));
|
||||||
recordedDevices.every(rd => mappings.has(rd.name))
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root {open}>
|
<Dialog.Root {open}>
|
||||||
@@ -91,7 +87,8 @@ const allDevicesMapped = $derived(
|
|||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>Map Devices for Playback</Dialog.Title>
|
<Dialog.Title>Map Devices for Playback</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
Assign your connected devices to match the recorded devices. Only compatible devices are shown.
|
Assign your connected devices to match the recorded devices. Only compatible devices are
|
||||||
|
shown.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
@@ -108,7 +105,9 @@ const allDevicesMapped = $derived(
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#each recordedDevice.capabilities as capability (capability)}
|
{#each recordedDevice.capabilities as capability (capability)}
|
||||||
<span class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20">
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"
|
||||||
|
>
|
||||||
{capability}
|
{capability}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -126,7 +125,7 @@ const allDevicesMapped = $derived(
|
|||||||
{:else}
|
{:else}
|
||||||
<select
|
<select
|
||||||
class="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
class="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
value={currentMapping?.id || ''}
|
value={currentMapping?.id || ""}
|
||||||
onchange={(e) => handleDeviceSelect(recordedDevice.name, e.currentTarget.value)}
|
onchange={(e) => handleDeviceSelect(recordedDevice.name, e.currentTarget.value)}
|
||||||
>
|
>
|
||||||
<option value="" disabled>Select device...</option>
|
<option value="" disabled>Select device...</option>
|
||||||
@@ -143,16 +142,12 @@ const allDevicesMapped = $derived(
|
|||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if recordedDevices.length === 0}
|
{#if recordedDevices.length === 0}
|
||||||
<div class="text-center py-8 text-muted-foreground">
|
<div class="text-center py-8 text-muted-foreground">No devices in this recording</div>
|
||||||
No devices in this recording
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog.Footer class="flex gap-2">
|
<Dialog.Footer class="flex gap-2">
|
||||||
<Button variant="outline" onclick={onCancel} class="cursor-pointer">
|
<Button variant="outline" onclick={onCancel} class="cursor-pointer">Cancel</Button>
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onclick={handleConfirm}
|
onclick={handleConfirm}
|
||||||
disabled={!allDevicesMapped}
|
disabled={!allDevicesMapped}
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ interface Props {
|
|||||||
events: RecordedEvent[];
|
events: RecordedEvent[];
|
||||||
deviceInfo: DeviceInfo[];
|
deviceInfo: DeviceInfo[];
|
||||||
duration: number;
|
duration: number;
|
||||||
onSave: (data: {
|
onSave: (data: { title: string; description: string; tags: string[] }) => Promise<void>;
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
tags: string[];
|
|
||||||
}) => Promise<void>;
|
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,23 +66,17 @@ function handleCancel() {
|
|||||||
<div class="space-y-6 py-4">
|
<div class="space-y-6 py-4">
|
||||||
<!-- Recording Stats -->
|
<!-- Recording Stats -->
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<div
|
<div class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30">
|
||||||
class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--time-line] w-5 h-5 text-primary mb-2"></span>
|
<span class="icon-[ri--time-line] w-5 h-5 text-primary mb-2"></span>
|
||||||
<span class="text-xs text-muted-foreground mb-1">Duration</span>
|
<span class="text-xs text-muted-foreground mb-1">Duration</span>
|
||||||
<span class="font-semibold">{formatDuration(duration)}</span>
|
<span class="font-semibold">{formatDuration(duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30">
|
||||||
class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--pulse-line] w-5 h-5 text-accent mb-2"></span>
|
<span class="icon-[ri--pulse-line] w-5 h-5 text-accent mb-2"></span>
|
||||||
<span class="text-xs text-muted-foreground mb-1">Events</span>
|
<span class="text-xs text-muted-foreground mb-1">Events</span>
|
||||||
<span class="font-semibold">{events.length}</span>
|
<span class="font-semibold">{events.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30">
|
||||||
class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--gamepad-line] w-5 h-5 text-primary mb-2"></span>
|
<span class="icon-[ri--gamepad-line] w-5 h-5 text-primary mb-2"></span>
|
||||||
<span class="text-xs text-muted-foreground mb-1">Devices</span>
|
<span class="text-xs text-muted-foreground mb-1">Devices</span>
|
||||||
<span class="font-semibold">{deviceInfo.length}</span>
|
<span class="font-semibold">{deviceInfo.length}</span>
|
||||||
@@ -97,9 +87,7 @@ function handleCancel() {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>Devices Used</Label>
|
<Label>Devices Used</Label>
|
||||||
{#each deviceInfo as device (device.name)}
|
{#each deviceInfo as device (device.name)}
|
||||||
<div
|
<div class="flex items-center gap-2 text-sm bg-muted/20 rounded px-3 py-2">
|
||||||
class="flex items-center gap-2 text-sm bg-muted/20 rounded px-3 py-2"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--rocket-line] w-4 h-4"></span>
|
<span class="icon-[ri--rocket-line] w-4 h-4"></span>
|
||||||
<span class="font-medium">{device.name}</span>
|
<span class="font-medium">{device.name}</span>
|
||||||
<span class="text-muted-foreground text-xs">
|
<span class="text-muted-foreground text-xs">
|
||||||
@@ -146,12 +134,7 @@ function handleCancel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog.Footer>
|
<Dialog.Footer>
|
||||||
<Button
|
<Button variant="outline" onclick={handleCancel} disabled={isSaving} class="cursor-pointer">
|
||||||
variant="outline"
|
|
||||||
onclick={handleCancel}
|
|
||||||
disabled={isSaving}
|
|
||||||
class="cursor-pointer"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -65,10 +65,7 @@ onMount(() => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Meta
|
<Meta title={$_("auth.signup.title")} description={$_("auth.signup.description")} />
|
||||||
title={$_('auth.signup.title')}
|
|
||||||
description={$_('auth.signup.description')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-accent/5 to-background p-4 overflow-hidden"
|
class="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-accent/5 to-background p-4 overflow-hidden"
|
||||||
@@ -78,40 +75,38 @@ onMount(() => {
|
|||||||
<div class="w-full max-w-md">
|
<div class="w-full max-w-md">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<div
|
<div class="flex items-center justify-center gap-3 text-2xl font-bold mb-2">
|
||||||
class="flex items-center justify-center gap-3 text-2xl font-bold mb-2"
|
|
||||||
>
|
|
||||||
<Logo />
|
<Logo />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted-foreground">{$_('auth.signup.welcome')}</p>
|
<p class="text-muted-foreground">{$_("auth.signup.welcome")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
class="bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
class="bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||||
>
|
>
|
||||||
<CardHeader class="text-center">
|
<CardHeader class="text-center">
|
||||||
<CardTitle class="text-2xl">{$_('auth.signup.title')}</CardTitle>
|
<CardTitle class="text-2xl">{$_("auth.signup.title")}</CardTitle>
|
||||||
<CardDescription>{$_('auth.signup.description')}</CardDescription>
|
<CardDescription>{$_("auth.signup.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-6">
|
<CardContent class="space-y-6">
|
||||||
<form onsubmit={handleSubmit} class="space-y-4">
|
<form onsubmit={handleSubmit} class="space-y-4">
|
||||||
<!-- Name Fields -->
|
<!-- Name Fields -->
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="firstName">{$_('auth.signup.first_name')}</Label>
|
<Label for="firstName">{$_("auth.signup.first_name")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="firstName"
|
id="firstName"
|
||||||
placeholder={$_('auth.signup.first_name_placeholder')}
|
placeholder={$_("auth.signup.first_name_placeholder")}
|
||||||
bind:value={firstName}
|
bind:value={firstName}
|
||||||
required
|
required
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="lastName">{$_('auth.signup.last_name')}</Label>
|
<Label for="lastName">{$_("auth.signup.last_name")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
placeholder={$_('auth.signup.last_name_placeholder')}
|
placeholder={$_("auth.signup.last_name_placeholder")}
|
||||||
bind:value={lastName}
|
bind:value={lastName}
|
||||||
required
|
required
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
@@ -121,11 +116,11 @@ onMount(() => {
|
|||||||
|
|
||||||
<!-- Email -->
|
<!-- Email -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="email">{$_('auth.signup.email')}</Label>
|
<Label for="email">{$_("auth.signup.email")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder={$_('auth.signup.email_placeholder')}
|
placeholder={$_("auth.signup.email_placeholder")}
|
||||||
bind:value={email}
|
bind:value={email}
|
||||||
required
|
required
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
@@ -134,12 +129,12 @@ onMount(() => {
|
|||||||
|
|
||||||
<!-- Password -->
|
<!-- Password -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="password">{$_('auth.signup.password')}</Label>
|
<Label for="password">{$_("auth.signup.password")}</Label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder={$_('auth.signup.password_placeholder')}
|
placeholder={$_("auth.signup.password_placeholder")}
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
required
|
required
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||||
@@ -160,14 +155,12 @@ onMount(() => {
|
|||||||
|
|
||||||
<!-- Confirm Password -->
|
<!-- Confirm Password -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="confirmPassword"
|
<Label for="confirmPassword">{$_("auth.signup.confirm_password")}</Label>
|
||||||
>{$_('auth.signup.confirm_password')}</Label
|
|
||||||
>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type={showConfirmPassword ? 'text' : 'password'}
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
placeholder={$_('auth.signup.confirm_password_placeholder')}
|
placeholder={$_("auth.signup.confirm_password_placeholder")}
|
||||||
bind:value={confirmPassword}
|
bind:value={confirmPassword}
|
||||||
required
|
required
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||||
@@ -190,11 +183,11 @@ onMount(() => {
|
|||||||
<div class="flex items-start space-x-2">
|
<div class="flex items-start space-x-2">
|
||||||
<Checkbox id="terms" bind:checked={agreeTerms} class="mt-1" />
|
<Checkbox id="terms" bind:checked={agreeTerms} class="mt-1" />
|
||||||
<Label for="terms" class="text-sm leading-relaxed">
|
<Label for="terms" class="text-sm leading-relaxed">
|
||||||
{$_('auth.signup.terms_agreement', {
|
{$_("auth.signup.terms_agreement", {
|
||||||
values: {
|
values: {
|
||||||
terms: $_('auth.signup.terms_of_service'),
|
terms: $_("auth.signup.terms_of_service"),
|
||||||
privacy: $_('auth.signup.privacy_policy')
|
privacy: $_("auth.signup.privacy_policy"),
|
||||||
}
|
},
|
||||||
})}
|
})}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,8 +196,9 @@ onMount(() => {
|
|||||||
<div class="grid w-full max-w-xl items-start gap-4">
|
<div class="grid w-full max-w-xl items-start gap-4">
|
||||||
<Alert.Root variant="destructive">
|
<Alert.Root variant="destructive">
|
||||||
<Alert.Title class="items-center flex"
|
<Alert.Title class="items-center flex"
|
||||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
||||||
></span>{$_('auth.signup.error')}</Alert.Title
|
"auth.signup.error",
|
||||||
|
)}</Alert.Title
|
||||||
>
|
>
|
||||||
<Alert.Description>{error}</Alert.Description>
|
<Alert.Description>{error}</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
@@ -221,9 +215,9 @@ onMount(() => {
|
|||||||
<div
|
<div
|
||||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||||
></div>
|
></div>
|
||||||
{$_('auth.signup.creating_account')}
|
{$_("auth.signup.creating_account")}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('auth.signup.create_account')}
|
{$_("auth.signup.create_account")}
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -231,9 +225,9 @@ onMount(() => {
|
|||||||
<!-- Sign In Link -->
|
<!-- Sign In Link -->
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
{$_('auth.signup.have_account')}
|
{$_("auth.signup.have_account")}
|
||||||
<a href="/login" class="text-primary hover:underline font-medium"
|
<a href="/login" class="text-primary hover:underline font-medium"
|
||||||
>{$_('auth.signup.sign_in_link')}</a
|
>{$_("auth.signup.sign_in_link")}</a
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,13 +4,7 @@ import { getArticles, getModels, getVideos } from "$lib/services";
|
|||||||
export const GET = async () => {
|
export const GET = async () => {
|
||||||
return await sitemap.response({
|
return await sitemap.response({
|
||||||
origin: "https://sexy.pivoine.art",
|
origin: "https://sexy.pivoine.art",
|
||||||
excludeRoutePatterns: [
|
excludeRoutePatterns: ["^/signup/verify", "^/password/reset", "^/me", "^/play", "^/tags/.+"],
|
||||||
"^/signup/verify",
|
|
||||||
"^/password/reset",
|
|
||||||
"^/me",
|
|
||||||
"^/play",
|
|
||||||
"^/tags/.+",
|
|
||||||
],
|
|
||||||
paramValues: {
|
paramValues: {
|
||||||
"/magazine/[slug]": (await getArticles(fetch)).map((a) => a.slug),
|
"/magazine/[slug]": (await getArticles(fetch)).map((a) => a.slug),
|
||||||
"/models/[slug]": (await getModels(fetch)).map((a) => a.slug),
|
"/models/[slug]": (await getModels(fetch)).map((a) => a.slug),
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ import { _ } from "svelte-i18n";
|
|||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
} from "$lib/components/ui/select";
|
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/directus";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
@@ -32,10 +27,8 @@ const filteredItems = $derived(() => {
|
|||||||
return data.items
|
return data.items
|
||||||
.filter((item: any) => {
|
.filter((item: any) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
searchQuery === "" ||
|
searchQuery === "" || item.title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
item.title.toLowerCase().includes(searchQuery.toLowerCase());
|
const matchesCategory = categoryFilter === "all" || item.category === categoryFilter;
|
||||||
const matchesCategory =
|
|
||||||
categoryFilter === "all" || item.category === categoryFilter;
|
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -52,8 +45,8 @@ const filteredItems = $derived(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Meta
|
<Meta
|
||||||
title={$_('tags.title', { values: { tag: data.tag } })}
|
title={$_("tags.title", { values: { tag: data.tag } })}
|
||||||
description={$_('tags.description', { values: { tag: data.tag } })}
|
description={$_("tags.description", { values: { tag: data.tag } })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -78,12 +71,12 @@ const filteredItems = $derived(() => {
|
|||||||
<h1
|
<h1
|
||||||
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||||
>
|
>
|
||||||
{$_('tags.title', { values: { tag: data.tag } })}
|
{$_("tags.title", { values: { tag: data.tag } })}
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
||||||
>
|
>
|
||||||
{$_('tags.description', { values: { tag: data.tag } })}
|
{$_("tags.description", { values: { tag: data.tag } })}
|
||||||
</p>
|
</p>
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
||||||
@@ -93,7 +86,7 @@ const filteredItems = $derived(() => {
|
|||||||
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
||||||
></span>
|
></span>
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_('tags.search_placeholder')}
|
placeholder={$_("tags.search_placeholder")}
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
@@ -105,25 +98,19 @@ const filteredItems = $derived(() => {
|
|||||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
||||||
{categoryFilter === 'all'
|
{categoryFilter === "all"
|
||||||
? $_('tags.categories.all')
|
? $_("tags.categories.all")
|
||||||
: categoryFilter === 'video'
|
: categoryFilter === "video"
|
||||||
? $_('tags.categories.video')
|
? $_("tags.categories.video")
|
||||||
: categoryFilter === 'article'
|
: categoryFilter === "article"
|
||||||
? $_('tags.categories.article')
|
? $_("tags.categories.article")
|
||||||
: $_('tags.categories.model')}
|
: $_("tags.categories.model")}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{$_('tags.categories.all')}</SelectItem>
|
<SelectItem value="all">{$_("tags.categories.all")}</SelectItem>
|
||||||
<SelectItem value="video"
|
<SelectItem value="video">{$_("tags.categories.video")}</SelectItem>
|
||||||
>{$_('tags.categories.video')}</SelectItem
|
<SelectItem value="article">{$_("tags.categories.article")}</SelectItem>
|
||||||
>
|
<SelectItem value="model">{$_("tags.categories.model")}</SelectItem>
|
||||||
<SelectItem value="article"
|
|
||||||
>{$_('tags.categories.article')}</SelectItem
|
|
||||||
>
|
|
||||||
<SelectItem value="model"
|
|
||||||
>{$_('tags.categories.model')}</SelectItem
|
|
||||||
>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +126,7 @@ const filteredItems = $derived(() => {
|
|||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(item['image'] || item['avatar'], 'preview')}
|
src={getAssetUrl(item["image"] || item["avatar"], "preview")}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
class="w-full h-64 object-cover group-hover:scale-105 transition-transform duration-300"
|
class="w-full h-64 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
/>
|
/>
|
||||||
@@ -158,9 +145,7 @@ const filteredItems = $derived(() => {
|
|||||||
<CardContent class="p-6">
|
<CardContent class="p-6">
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3 class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors">
|
||||||
class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
<!-- <div
|
<!-- <div
|
||||||
@@ -194,8 +179,8 @@ const filteredItems = $derived(() => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 border-primary/20 hover:bg-primary/10"
|
class="flex-1 border-primary/20 hover:bg-primary/10"
|
||||||
href={getUrlForItem(item)}
|
href={getUrlForItem(item)}
|
||||||
>{$_('tags.view', {
|
>{$_("tags.view", {
|
||||||
values: { category: item.category }
|
values: { category: item.category },
|
||||||
})}</Button
|
})}</Button
|
||||||
>
|
>
|
||||||
<!-- <Button
|
<!-- <Button
|
||||||
@@ -211,16 +196,16 @@ const filteredItems = $derived(() => {
|
|||||||
|
|
||||||
{#if filteredItems().length === 0}
|
{#if filteredItems().length === 0}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<p class="text-muted-foreground text-lg">{$_('tags.no_results')}</p>
|
<p class="text-muted-foreground text-lg">{$_("tags.no_results")}</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
searchQuery = '';
|
searchQuery = "";
|
||||||
categoryFilter = 'all';
|
categoryFilter = "all";
|
||||||
}}
|
}}
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
>
|
>
|
||||||
{$_('tags.clear_filters')}
|
{$_("tags.clear_filters")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,17 +6,42 @@ import { getGraphQLClient } from "$lib/api";
|
|||||||
const USER_PROFILE_QUERY = gql`
|
const USER_PROFILE_QUERY = gql`
|
||||||
query UserProfile($id: String!) {
|
query UserProfile($id: String!) {
|
||||||
userProfile(id: $id) {
|
userProfile(id: $id) {
|
||||||
id first_name last_name email description avatar date_created
|
id
|
||||||
|
first_name
|
||||||
|
last_name
|
||||||
|
email
|
||||||
|
description
|
||||||
|
avatar
|
||||||
|
date_created
|
||||||
}
|
}
|
||||||
userGamification(userId: $id) {
|
userGamification(userId: $id) {
|
||||||
stats {
|
stats {
|
||||||
user_id total_raw_points total_weighted_points
|
user_id
|
||||||
recordings_count playbacks_count comments_count achievements_count rank
|
total_raw_points
|
||||||
|
total_weighted_points
|
||||||
|
recordings_count
|
||||||
|
playbacks_count
|
||||||
|
comments_count
|
||||||
|
achievements_count
|
||||||
|
rank
|
||||||
}
|
}
|
||||||
achievements {
|
achievements {
|
||||||
id code name description icon category date_unlocked progress required_count
|
id
|
||||||
|
code
|
||||||
|
name
|
||||||
|
description
|
||||||
|
icon
|
||||||
|
category
|
||||||
|
date_unlocked
|
||||||
|
progress
|
||||||
|
required_count
|
||||||
|
}
|
||||||
|
recent_points {
|
||||||
|
action
|
||||||
|
points
|
||||||
|
date_created
|
||||||
|
recording_id
|
||||||
}
|
}
|
||||||
recent_points { action points date_created recording_id }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -30,16 +30,12 @@ let joinDate = $derived(
|
|||||||
image={data.user.avatar ? getAssetUrl(data.user.avatar, "thumbnail") : undefined}
|
image={data.user.avatar ? getAssetUrl(data.user.avatar, "thumbnail") : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
|
||||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5"
|
|
||||||
>
|
|
||||||
<PeonyBackground />
|
<PeonyBackground />
|
||||||
|
|
||||||
<div class="container mx-auto px-4 py-8 relative z-10">
|
<div class="container mx-auto px-4 py-8 relative z-10">
|
||||||
<!-- Profile Card -->
|
<!-- Profile Card -->
|
||||||
<Card
|
<Card class="max-w-3xl mx-auto bg-card/90 backdrop-blur-sm border-border/50">
|
||||||
class="max-w-3xl mx-auto bg-card/90 backdrop-blur-sm border-border/50"
|
|
||||||
>
|
|
||||||
<CardContent class="p-6 md:p-8">
|
<CardContent class="p-6 md:p-8">
|
||||||
<!-- Header with Back Button -->
|
<!-- Header with Back Button -->
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
@@ -86,9 +82,7 @@ let joinDate = $derived(
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h1 class="text-3xl font-bold mb-2">{displayName}</h1>
|
<h1 class="text-3xl font-bold mb-2">{displayName}</h1>
|
||||||
|
|
||||||
<div
|
<div class="flex items-center gap-2 text-muted-foreground mb-4">
|
||||||
class="flex items-center gap-2 text-muted-foreground mb-4"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||||
<span
|
<span
|
||||||
>{$_("profile.member_since", {
|
>{$_("profile.member_since", {
|
||||||
@@ -98,9 +92,7 @@ let joinDate = $derived(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.user.location}
|
{#if data.user.location}
|
||||||
<div
|
<div class="flex items-center gap-2 text-muted-foreground mb-4">
|
||||||
class="flex items-center gap-2 text-muted-foreground mb-4"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--map-pin-line] w-4 h-4"></span>
|
<span class="icon-[ri--map-pin-line] w-4 h-4"></span>
|
||||||
<span>{data.user.location}</span>
|
<span>{data.user.location}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,9 +105,7 @@ let joinDate = $derived(
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Statistics -->
|
<!-- Statistics -->
|
||||||
<div
|
<div class="grid grid-cols-2 gap-4 pt-4 border-t border-border/50">
|
||||||
class="grid grid-cols-2 gap-4 pt-4 border-t border-border/50"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-2xl font-bold text-primary">
|
<div class="text-2xl font-bold text-primary">
|
||||||
{data.stats.comments_count}
|
{data.stats.comments_count}
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ import { _ } from "svelte-i18n";
|
|||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
} from "$lib/components/ui/select";
|
|
||||||
import { getAssetUrl } from "$lib/directus";
|
import { getAssetUrl } from "$lib/directus";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
@@ -26,9 +21,7 @@ const { data } = $props();
|
|||||||
const filteredVideos = $derived(() => {
|
const filteredVideos = $derived(() => {
|
||||||
return data.videos
|
return data.videos
|
||||||
.filter((video) => {
|
.filter((video) => {
|
||||||
const matchesSearch = video.title
|
const matchesSearch = video.title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase());
|
|
||||||
// ||
|
// ||
|
||||||
// video.model.toLowerCase().includes(searchQuery.toLowerCase());
|
// video.model.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
const matchesCategory = categoryFilter === "all";
|
const matchesCategory = categoryFilter === "all";
|
||||||
@@ -43,20 +36,17 @@ const filteredVideos = $derived(() => {
|
|||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (sortBy === "recent")
|
if (sortBy === "recent")
|
||||||
return (
|
return new Date(b.upload_date).getTime() - new Date(a.upload_date).getTime();
|
||||||
new Date(b.upload_date).getTime() - new Date(a.upload_date).getTime()
|
if (sortBy === "most_liked") return (b.likes_count || 0) - (a.likes_count || 0);
|
||||||
);
|
if (sortBy === "most_played") return (b.plays_count || 0) - (a.plays_count || 0);
|
||||||
if (sortBy === "most_liked")
|
if (sortBy === "duration")
|
||||||
return (b.likes_count || 0) - (a.likes_count || 0);
|
return (b.movie_file?.duration ?? 0) - (a.movie_file?.duration ?? 0);
|
||||||
if (sortBy === "most_played")
|
|
||||||
return (b.plays_count || 0) - (a.plays_count || 0);
|
|
||||||
if (sortBy === "duration") return (b.movie_file?.duration ?? 0) - (a.movie_file?.duration ?? 0);
|
|
||||||
return a.title.localeCompare(b.title);
|
return a.title.localeCompare(b.title);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Meta title={$_('videos.title')} description={$_('videos.description')} />
|
<Meta title={$_("videos.title")} description={$_("videos.description")} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||||
@@ -83,12 +73,12 @@ const filteredVideos = $derived(() => {
|
|||||||
<h1
|
<h1
|
||||||
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||||
>
|
>
|
||||||
{$_('videos.title')}
|
{$_("videos.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
||||||
>
|
>
|
||||||
{$_('videos.description')}
|
{$_("videos.description")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -99,7 +89,7 @@ const filteredVideos = $derived(() => {
|
|||||||
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
||||||
></span>
|
></span>
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_('videos.search_placeholder')}
|
placeholder={$_("videos.search_placeholder")}
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
@@ -111,30 +101,22 @@ const filteredVideos = $derived(() => {
|
|||||||
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
||||||
{categoryFilter === 'all'
|
{categoryFilter === "all"
|
||||||
? $_('videos.categories.all')
|
? $_("videos.categories.all")
|
||||||
: categoryFilter === 'romantic'
|
: categoryFilter === "romantic"
|
||||||
? $_('videos.categories.romantic')
|
? $_("videos.categories.romantic")
|
||||||
: categoryFilter === 'artistic'
|
: categoryFilter === "artistic"
|
||||||
? $_('videos.categories.artistic')
|
? $_("videos.categories.artistic")
|
||||||
: categoryFilter === 'intimate'
|
: categoryFilter === "intimate"
|
||||||
? $_('videos.categories.intimate')
|
? $_("videos.categories.intimate")
|
||||||
: $_('videos.categories.performance')}
|
: $_("videos.categories.performance")}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{$_('videos.categories.all')}</SelectItem>
|
<SelectItem value="all">{$_("videos.categories.all")}</SelectItem>
|
||||||
<SelectItem value="romantic"
|
<SelectItem value="romantic">{$_("videos.categories.romantic")}</SelectItem>
|
||||||
>{$_('videos.categories.romantic')}</SelectItem
|
<SelectItem value="artistic">{$_("videos.categories.artistic")}</SelectItem>
|
||||||
>
|
<SelectItem value="intimate">{$_("videos.categories.intimate")}</SelectItem>
|
||||||
<SelectItem value="artistic"
|
<SelectItem value="performance">{$_("videos.categories.performance")}</SelectItem>
|
||||||
>{$_('videos.categories.artistic')}</SelectItem
|
|
||||||
>
|
|
||||||
<SelectItem value="intimate"
|
|
||||||
>{$_('videos.categories.intimate')}</SelectItem
|
|
||||||
>
|
|
||||||
<SelectItem value="performance"
|
|
||||||
>{$_('videos.categories.performance')}</SelectItem
|
|
||||||
>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -144,23 +126,19 @@ const filteredVideos = $derived(() => {
|
|||||||
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--timer-2-line] w-4 h-4 mr-2"></span>
|
<span class="icon-[ri--timer-2-line] w-4 h-4 mr-2"></span>
|
||||||
{durationFilter === 'all'
|
{durationFilter === "all"
|
||||||
? $_('videos.duration.all')
|
? $_("videos.duration.all")
|
||||||
: durationFilter === 'short'
|
: durationFilter === "short"
|
||||||
? $_('videos.duration.short')
|
? $_("videos.duration.short")
|
||||||
: durationFilter === 'medium'
|
: durationFilter === "medium"
|
||||||
? $_('videos.duration.medium')
|
? $_("videos.duration.medium")
|
||||||
: $_('videos.duration.long')}
|
: $_("videos.duration.long")}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{$_('videos.duration.all')}</SelectItem>
|
<SelectItem value="all">{$_("videos.duration.all")}</SelectItem>
|
||||||
<SelectItem value="short"
|
<SelectItem value="short">{$_("videos.duration.short")}</SelectItem>
|
||||||
>{$_('videos.duration.short')}</SelectItem
|
<SelectItem value="medium">{$_("videos.duration.medium")}</SelectItem>
|
||||||
>
|
<SelectItem value="long">{$_("videos.duration.long")}</SelectItem>
|
||||||
<SelectItem value="medium"
|
|
||||||
>{$_('videos.duration.medium')}</SelectItem
|
|
||||||
>
|
|
||||||
<SelectItem value="long">{$_('videos.duration.long')}</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -169,28 +147,22 @@ const filteredVideos = $derived(() => {
|
|||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
>
|
>
|
||||||
{sortBy === 'recent'
|
{sortBy === "recent"
|
||||||
? $_('videos.sort.recent')
|
? $_("videos.sort.recent")
|
||||||
: sortBy === 'most_liked'
|
: sortBy === "most_liked"
|
||||||
? $_('videos.sort.most_liked')
|
? $_("videos.sort.most_liked")
|
||||||
: sortBy === 'most_played'
|
: sortBy === "most_played"
|
||||||
? $_('videos.sort.most_played')
|
? $_("videos.sort.most_played")
|
||||||
: sortBy === 'duration'
|
: sortBy === "duration"
|
||||||
? $_('videos.sort.duration')
|
? $_("videos.sort.duration")
|
||||||
: $_('videos.sort.name')}
|
: $_("videos.sort.name")}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="recent">{$_('videos.sort.recent')}</SelectItem>
|
<SelectItem value="recent">{$_("videos.sort.recent")}</SelectItem>
|
||||||
<SelectItem value="most_liked"
|
<SelectItem value="most_liked">{$_("videos.sort.most_liked")}</SelectItem>
|
||||||
>{$_('videos.sort.most_liked')}</SelectItem
|
<SelectItem value="most_played">{$_("videos.sort.most_played")}</SelectItem>
|
||||||
>
|
<SelectItem value="duration">{$_("videos.sort.duration")}</SelectItem>
|
||||||
<SelectItem value="most_played"
|
<SelectItem value="name">{$_("videos.sort.name")}</SelectItem>
|
||||||
>{$_('videos.sort.most_played')}</SelectItem
|
|
||||||
>
|
|
||||||
<SelectItem value="duration"
|
|
||||||
>{$_('videos.sort.duration')}</SelectItem
|
|
||||||
>
|
|
||||||
<SelectItem value="name">{$_('videos.sort.name')}</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +178,7 @@ const filteredVideos = $derived(() => {
|
|||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(video.image, 'preview')}
|
src={getAssetUrl(video.image, "preview")}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
/>
|
/>
|
||||||
@@ -228,7 +200,7 @@ const filteredVideos = $derived(() => {
|
|||||||
<div
|
<div
|
||||||
class="absolute top-3 left-3 bg-gradient-to-r from-primary to-accent text-white text-xs px-2 py-1 rounded-full font-medium"
|
class="absolute top-3 left-3 bg-gradient-to-r from-primary to-accent text-white text-xs px-2 py-1 rounded-full font-medium"
|
||||||
>
|
>
|
||||||
{$_('videos.premium')}
|
{$_("videos.premium")}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -246,13 +218,12 @@ const filteredVideos = $derived(() => {
|
|||||||
<a
|
<a
|
||||||
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
href={`/videos/${video.slug}`}
|
href={`/videos/${video.slug}`}
|
||||||
aria-label={$_('videos.watch')}
|
aria-label={$_("videos.watch")}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-16 h-16 bg-primary/90 rounded-full flex flex-col items-center justify-center shadow-2xl"
|
class="w-16 h-16 bg-primary/90 rounded-full flex flex-col items-center justify-center shadow-2xl"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"
|
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -280,9 +251,7 @@ const filteredVideos = $derived(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div
|
<div class="flex items-center justify-between text-sm text-muted-foreground mb-4">
|
||||||
class="flex items-center justify-between text-sm text-muted-foreground mb-4"
|
|
||||||
>
|
|
||||||
<!-- <div class="flex items-center gap-4">
|
<!-- <div class="flex items-center gap-4">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<EyeIcon class="w-4 h-4" />
|
<EyeIcon class="w-4 h-4" />
|
||||||
@@ -309,7 +278,7 @@ const filteredVideos = $derived(() => {
|
|||||||
href={`/videos/${video.slug}`}
|
href={`/videos/${video.slug}`}
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--play-large-fill] w-4 h-4 mr-2"></span>
|
<span class="icon-[ri--play-large-fill] w-4 h-4 mr-2"></span>
|
||||||
{$_('videos.watch')}
|
{$_("videos.watch")}
|
||||||
</Button>
|
</Button>
|
||||||
<!-- <Button
|
<!-- <Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -327,18 +296,18 @@ const filteredVideos = $derived(() => {
|
|||||||
{#if filteredVideos().length === 0}
|
{#if filteredVideos().length === 0}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<p class="text-muted-foreground text-lg mb-4">
|
<p class="text-muted-foreground text-lg mb-4">
|
||||||
{$_('videos.no_results')}
|
{$_("videos.no_results")}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
searchQuery = '';
|
searchQuery = "";
|
||||||
categoryFilter = 'all';
|
categoryFilter = "all";
|
||||||
durationFilter = 'all';
|
durationFilter = "all";
|
||||||
}}
|
}}
|
||||||
class="border-primary/20 hover:bg-primary/10"
|
class="border-primary/20 hover:bg-primary/10"
|
||||||
>
|
>
|
||||||
{$_('videos.clear_filters')}
|
{$_("videos.clear_filters")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export async function load({ fetch, params, locals }) {
|
|||||||
video,
|
video,
|
||||||
comments,
|
comments,
|
||||||
authStatus: locals.authStatus,
|
authStatus: locals.authStatus,
|
||||||
likeStatus
|
likeStatus,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
error(404, "Video not found");
|
error(404, "Video not found");
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ import { AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
|||||||
import { formatVideoDuration, getUserInitials } from "$lib/utils";
|
import { formatVideoDuration, getUserInitials } from "$lib/utils";
|
||||||
import { invalidateAll } from "$app/navigation";
|
import { invalidateAll } from "$app/navigation";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { createCommentForVideo, likeVideo, unlikeVideo, recordVideoPlay, updateVideoPlay } from "$lib/services";
|
import {
|
||||||
|
createCommentForVideo,
|
||||||
|
likeVideo,
|
||||||
|
unlikeVideo,
|
||||||
|
recordVideoPlay,
|
||||||
|
updateVideoPlay,
|
||||||
|
} from "$lib/services";
|
||||||
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
@@ -145,7 +151,7 @@ let showPlayer = $state(false);
|
|||||||
<Meta
|
<Meta
|
||||||
title={data.video.title}
|
title={data.video.title}
|
||||||
description={data.video.description}
|
description={data.video.description}
|
||||||
image={getAssetUrl(data.video.image, 'medium')!}
|
image={getAssetUrl(data.video.image, "medium")!}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -157,16 +163,14 @@ let showPlayer = $state(false);
|
|||||||
<!-- Main Video Section -->
|
<!-- Main Video Section -->
|
||||||
<div class="lg:col-span-2 space-y-6">
|
<div class="lg:col-span-2 space-y-6">
|
||||||
<!-- Video Player -->
|
<!-- Video Player -->
|
||||||
<Card
|
<Card class="p-0 overflow-hidden bg-gradient-to-br from-card to-card/50">
|
||||||
class="p-0 overflow-hidden bg-gradient-to-br from-card to-card/50"
|
|
||||||
>
|
|
||||||
<div class="relative aspect-video bg-black">
|
<div class="relative aspect-video bg-black">
|
||||||
{#if showPlayer}
|
{#if showPlayer}
|
||||||
<media-controller class="absolute inset-0 w-full h-full">
|
<media-controller class="absolute inset-0 w-full h-full">
|
||||||
<video
|
<video
|
||||||
slot="media"
|
slot="media"
|
||||||
src={getAssetUrl(data.video.movie)}
|
src={getAssetUrl(data.video.movie)}
|
||||||
poster={getAssetUrl(data.video.image, 'preview')}
|
poster={getAssetUrl(data.video.image, "preview")}
|
||||||
autoplay
|
autoplay
|
||||||
ontimeupdate={handleTimeUpdate}
|
ontimeupdate={handleTimeUpdate}
|
||||||
class="block w-full"
|
class="block w-full"
|
||||||
@@ -184,13 +188,11 @@ let showPlayer = $state(false);
|
|||||||
</media-controller>
|
</media-controller>
|
||||||
{:else}
|
{:else}
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(data.video.image, 'medium')}
|
src={getAssetUrl(data.video.image, "medium")}
|
||||||
alt={data.video.title}
|
alt={data.video.title}
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div
|
<div class="absolute inset-0 bg-black/20 flex items-center justify-center">
|
||||||
class="absolute inset-0 bg-black/20 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
class="cursor-pointer w-20 h-20 bg-primary/90 hover:bg-primary rounded-full flex flex-col items-center justify-center transition-colors shadow-2xl"
|
class="cursor-pointer w-20 h-20 bg-primary/90 hover:bg-primary rounded-full flex flex-col items-center justify-center transition-colors shadow-2xl"
|
||||||
aria-label={data.video.title}
|
aria-label={data.video.title}
|
||||||
@@ -199,8 +201,7 @@ let showPlayer = $state(false);
|
|||||||
data-umami-event-id={data.video.movie}
|
data-umami-event-id={data.video.movie}
|
||||||
onclick={() => (showPlayer = true)}
|
onclick={() => (showPlayer = true)}
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--play-large-fill] w-10 h-10 text-white"
|
<span class="icon-[ri--play-large-fill] w-10 h-10 text-white"></span>
|
||||||
></span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -214,20 +215,21 @@ let showPlayer = $state(false);
|
|||||||
<div
|
<div
|
||||||
class="w-20 h-20 bg-primary/90 hover:bg-primary rounded-full flex flex-col items-center justify-center transition-colors shadow-2xl"
|
class="w-20 h-20 bg-primary/90 hover:bg-primary rounded-full flex flex-col items-center justify-center transition-colors shadow-2xl"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--play-large-fill] w-10 h-10 text-white"
|
<span class="icon-[ri--play-large-fill] w-10 h-10 text-white"></span>
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-4 left-4 bg-black/70 text-white px-3 py-1 rounded font-medium"
|
class="absolute bottom-4 left-4 bg-black/70 text-white px-3 py-1 rounded font-medium"
|
||||||
>
|
>
|
||||||
{#if data.video.movie_file?.duration}{formatVideoDuration(data.video.movie_file.duration)}{/if}
|
{#if data.video.movie_file?.duration}{formatVideoDuration(
|
||||||
|
data.video.movie_file.duration,
|
||||||
|
)}{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if data.video.premium}
|
{#if data.video.premium}
|
||||||
<div
|
<div
|
||||||
class="absolute top-4 left-4 bg-gradient-to-r from-primary to-accent text-white px-3 py-1 rounded-full font-medium"
|
class="absolute top-4 left-4 bg-gradient-to-r from-primary to-accent text-white px-3 py-1 rounded-full font-medium"
|
||||||
>
|
>
|
||||||
{$_('videos.premium')}
|
{$_("videos.premium")}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -240,13 +242,12 @@ let showPlayer = $state(false);
|
|||||||
<h1 class="text-2xl md:text-3xl font-bold mb-2">
|
<h1 class="text-2xl md:text-3xl font-bold mb-2">
|
||||||
{data.video.title}
|
{data.video.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div
|
<div class="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||||
class="flex flex-wrap items-center gap-4 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{#if data.video.plays_count}
|
{#if data.video.plays_count}
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="icon-[ri--play-fill] w-4 h-4"></span>
|
<span class="icon-[ri--play-fill] w-4 h-4"></span>
|
||||||
{data.video.plays_count} {data.video.plays_count === 1 ? 'play' : 'plays'}
|
{data.video.plays_count}
|
||||||
|
{data.video.plays_count === 1 ? "play" : "plays"}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
@@ -274,7 +275,7 @@ let showPlayer = $state(false);
|
|||||||
title: data.video.title,
|
title: data.video.title,
|
||||||
description: data.video.description,
|
description: data.video.description,
|
||||||
url: page.url.href,
|
url: page.url.href,
|
||||||
type: 'video' as const
|
type: "video" as const,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<!-- <Button
|
<!-- <Button
|
||||||
@@ -296,7 +297,7 @@ let showPlayer = $state(false);
|
|||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href={`/models/${model.slug}`}>
|
<a href={`/models/${model.slug}`}>
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(model.avatar as string, 'thumbnail')}
|
src={getAssetUrl(model.avatar as string, "thumbnail")}
|
||||||
alt={model.artist_name}
|
alt={model.artist_name}
|
||||||
class="w-12 h-12 rounded-full object-cover ring-2 ring-primary/20 hover:ring-primary/40 transition-all"
|
class="w-12 h-12 rounded-full object-cover ring-2 ring-primary/20 hover:ring-primary/40 transition-all"
|
||||||
/>
|
/>
|
||||||
@@ -307,14 +308,8 @@ let showPlayer = $state(false);
|
|||||||
class="font-semibold hover:text-primary transition-colors flex items-center gap-2"
|
class="font-semibold hover:text-primary transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{model.artist_name}
|
{model.artist_name}
|
||||||
<div
|
<div class="w-4 h-4 bg-primary rounded-full flex items-center justify-center">
|
||||||
class="w-4 h-4 bg-primary rounded-full flex items-center justify-center"
|
<svg class="w-2.5 h-2.5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-2.5 h-2.5 text-white"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
@@ -359,10 +354,10 @@ let showPlayer = $state(false);
|
|||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="font-semibold flex items-center gap-2">
|
<h3 class="font-semibold flex items-center gap-2">
|
||||||
<span class="icon-[ri--message-line] w-5 h-5"></span>
|
<span class="icon-[ri--message-line] w-5 h-5"></span>
|
||||||
{$_('videos.comments', {
|
{$_("videos.comments", {
|
||||||
values: {
|
values: {
|
||||||
comments: data.comments.length
|
comments: data.comments.length,
|
||||||
}
|
},
|
||||||
})}
|
})}
|
||||||
</h3>
|
</h3>
|
||||||
{#if data.comments.length > 0}
|
{#if data.comments.length > 0}
|
||||||
@@ -372,7 +367,7 @@ let showPlayer = $state(false);
|
|||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
onclick={() => (showComments = !showComments)}
|
onclick={() => (showComments = !showComments)}
|
||||||
>
|
>
|
||||||
{showComments ? $_('videos.hide') : $_('videos.show')}
|
{showComments ? $_("videos.hide") : $_("videos.show")}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -380,11 +375,9 @@ let showPlayer = $state(false);
|
|||||||
<!-- Add Comment -->
|
<!-- Add Comment -->
|
||||||
{#if data.authStatus.authenticated}
|
{#if data.authStatus.authenticated}
|
||||||
<div class="flex gap-3 mb-6">
|
<div class="flex gap-3 mb-6">
|
||||||
<Avatar
|
<Avatar class="h-8 w-8 ring-2 ring-accent/20 transition-all duration-200">
|
||||||
class="h-8 w-8 ring-2 ring-accent/20 transition-all duration-200"
|
|
||||||
>
|
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={getAssetUrl(data.authStatus.user!.avatar.id, 'mini')}
|
src={getAssetUrl(data.authStatus.user!.avatar.id, "mini")}
|
||||||
alt={data.authStatus.user!.artist_name}
|
alt={data.authStatus.user!.artist_name}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
@@ -396,7 +389,7 @@ let showPlayer = $state(false);
|
|||||||
<form class="flex-1 space-y-4" onsubmit={handleComment}>
|
<form class="flex-1 space-y-4" onsubmit={handleComment}>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={$_('videos.add_comment_placeholder')}
|
placeholder={$_("videos.add_comment_placeholder")}
|
||||||
bind:value={newComment}
|
bind:value={newComment}
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary resize-none"
|
class="bg-background/50 border-primary/20 focus:border-primary resize-none"
|
||||||
rows={2}
|
rows={2}
|
||||||
@@ -406,9 +399,8 @@ let showPlayer = $state(false);
|
|||||||
<div class="grid w-full max-w-xl items-start gap-4">
|
<div class="grid w-full max-w-xl items-start gap-4">
|
||||||
<Alert.Root variant="destructive">
|
<Alert.Root variant="destructive">
|
||||||
<Alert.Title class="items-center flex"
|
<Alert.Title class="items-center flex"
|
||||||
><span
|
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
||||||
class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"
|
></span>{$_("videos.error")}</Alert.Title
|
||||||
></span>{$_('videos.error')}</Alert.Title
|
|
||||||
>
|
>
|
||||||
<Alert.Description>{commentError}</Alert.Description>
|
<Alert.Description>{commentError}</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
@@ -425,9 +417,9 @@ let showPlayer = $state(false);
|
|||||||
<div
|
<div
|
||||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||||
></div>
|
></div>
|
||||||
{$_('videos.commenting')}
|
{$_("videos.commenting")}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('videos.comment')}
|
{$_("videos.comment")}
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -445,10 +437,7 @@ let showPlayer = $state(false);
|
|||||||
class="h-8 w-8 ring-2 ring-accent/20 hover:ring-primary/40 transition-all duration-200 cursor-pointer"
|
class="h-8 w-8 ring-2 ring-accent/20 hover:ring-primary/40 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={getAssetUrl(
|
src={getAssetUrl(comment.user_created.avatar as string, "mini")}
|
||||||
comment.user_created.avatar as string,
|
|
||||||
'mini'
|
|
||||||
)}
|
|
||||||
alt={comment.user_created.artist_name}
|
alt={comment.user_created.artist_name}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
@@ -460,19 +449,17 @@ let showPlayer = $state(false);
|
|||||||
</a>
|
</a>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<a href="/users/{comment.user_created.id}" class="font-medium text-sm hover:text-primary transition-colors"
|
<a
|
||||||
|
href="/users/{comment.user_created.id}"
|
||||||
|
class="font-medium text-sm hover:text-primary transition-colors"
|
||||||
>{comment.user_created.artist_name}</a
|
>{comment.user_created.artist_name}</a
|
||||||
>
|
>
|
||||||
<span class="text-xs text-muted-foreground"
|
<span class="text-xs text-muted-foreground"
|
||||||
>{timeAgo.format(
|
>{timeAgo.format(new Date(comment.date_created))}</span
|
||||||
new Date(comment.date_created)
|
|
||||||
)}</span
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm mb-2">{comment.comment}</p>
|
<p class="text-sm mb-2">{comment.comment}</p>
|
||||||
<div
|
<div class="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
class="flex items-center gap-4 text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
<!-- <button
|
<!-- <button
|
||||||
class="flex items-center gap-1 hover:text-primary transition-colors"
|
class="flex items-center gap-1 hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
@@ -543,8 +530,9 @@ let showPlayer = $state(false);
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
href="/videos"
|
href="/videos"
|
||||||
class="w-full border-primary/20 hover:bg-primary/10"
|
class="w-full border-primary/20 hover:bg-primary/10"
|
||||||
><span class="icon-[ri--arrow-left-long-line] w-4 h-4 mr-1"
|
><span class="icon-[ri--arrow-left-long-line] w-4 h-4 mr-1"></span>{$_(
|
||||||
></span>{$_('videos.back')}</Button
|
"videos.back",
|
||||||
|
)}</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path";
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import wasm from 'vite-plugin-wasm';
|
import wasm from "vite-plugin-wasm";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit(), tailwindcss(), wasm()],
|
plugins: [sveltekit(), tailwindcss(), wasm()],
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
|||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.1
|
version: 3.8.1
|
||||||
|
prettier-plugin-svelte:
|
||||||
|
specifier: ^3.5.1
|
||||||
|
version: 3.5.1(prettier@3.8.1)(svelte@5.53.7)
|
||||||
typescript-eslint:
|
typescript-eslint:
|
||||||
specifier: ^8.56.1
|
specifier: ^8.56.1
|
||||||
version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
|
|||||||
Reference in New Issue
Block a user