Compare commits

...

32 Commits

Author SHA1 Message Date
52aa00dd13 fix: embed DECAY_LAMBDA as SQL literal to avoid pg type inference failure
All checks were successful
Build and Push Backend Image / build (push) Successful in 45s
Build and Push Frontend Image / build (push) Successful in 1m12s
PostgreSQL cannot resolve the type of a parameterized $1 = 0.005 in
-$1 * EXTRACT(EPOCH ...) and fails with an operator type error. Using
sql.raw() embeds the constant directly in the query string so userId
is the only parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:55:45 +01:00
8085b40af8 fix: use NOW() in weighted score query instead of JS Date parameter
Passing a JS Date to a Drizzle sql template serializes it as a locale
string (e.g. "Mon Mar 09 2026 19:51:22 GMT+0100") which PostgreSQL
cannot parse as timestamptz, causing the gamification worker to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:52:03 +01:00
5f40a812d3 feat: gamification queue with deduplication and unpublish revoke
- Add migration 0004: partial unique index on user_points (user_id, action, recording_id)
  for RECORDING_CREATE and RECORDING_FEATURED to prevent earn-on-republish farming
- Add revokePoints() to gamification lib; awardPoints() now uses onConflictDoNothing
- Add gamificationQueue (BullMQ) with 3-attempt exponential backoff
- Add gamification worker handling awardPoints, revokePoints, checkAchievements jobs
- Move all inline gamification calls in recordings + comments resolvers to queue
- Revoke RECORDING_CREATE points when a recording is unpublished (published → draft)
- Register gamification worker at server startup alongside mail worker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:50:33 +01:00
1b724e86c9 chore: lint and format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:38:37 +01:00
a9e4ed6049 feat: refactor play area into sidebar layout with buttplug, recordings, and leaderboard sub-pages
- Add /play sidebar layout (mobile nav + desktop sidebar) with SexyBackground
- Move buttplug device control to /play/buttplug with Empty component and scan button
- Move recordings from /me/recordings to /play/recordings
- Move leaderboard to /play/leaderboard; redirect /leaderboard → /play/leaderboard
- Redirect /me/recordings → /play/recordings and /play → /play/buttplug
- Remove recordings entry from /me sidebar nav
- Rename "SexyPlay" → "Play", swap bluetooth icon for rocket, remove subtitle
- Add play.nav i18n keys (play, recordings, leaderboard, back_to_site, back_mobile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:33:28 +01:00
66179d7ba8 style: streamline draft/published badge across recording card and admin table
- Replace custom inline span+getStatusColor with Badge component in recording card
- Align admin recordings table badge to same style (outline, green/yellow)
- Use i18n label in admin table instead of raw status string
- Remove unused cn import and getStatusColor helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:43:18 +01:00
3a8fa7d8ce style: refine admin edit forms and fix mobile padding
- Remove back button from admin entity edit pages (sidebar handles navigation)
- Remove cancel button from video/article forms, make submit button full-width
- Show actual entity title + subtitle on video/article edit pages
- Remove asterisks from Title/Slug field labels in i18n
- Remove px-3 sm:px-0 from all admin list page headers/filters (fixes mobile padding)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:38:38 +01:00
fddc3f15d0 feat: fix recording save and add publish/unpublish support
- Fix broken fetch("/api/sexy/recordings") → use createRecording GraphQL service
- Round duration to integer before sending (GraphQL Int type)
- Add updateRecording mutation to services
- Add publish/unpublish buttons to RecordingCard (draft ↔ published)
- Remove "Go to Play" button from recordings page header
- Add publish/unpublish i18n keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:26:42 +01:00
d9a60f0572 style: refine admin & me UI — card forms, back arrows, avatar in admin sidebar, Empty component
- Replace ← text with icon-[ri--arrow-left-line] in admin and me layouts
- Add avatar + admin shield badge to admin sidebar header
- Wrap all admin edit forms in Card (bg-card/50 border-primary/20) with styled inputs
- Fix sm:pl-6 → lg:pl-6 so extra left padding only applies when sidebar is visible
- Update security form submit button to gradient style matching profile
- Remove "View Public Profile" button from me/profile
- Use shadcn-svelte Empty component for recordings empty state
- Install empty component via shadcn-svelte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:16:39 +01:00
ba648c796a feat: refactor /me into admin-style layout with profile, security, recordings, analytics sub-pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:47:00 +01:00
27e2ff5f66 feat: add Meta title tags to all admin pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:23:35 +01:00
b7a29c55b3 style: fix header nav gap
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m16s
2026-03-09 10:24:14 +01:00
99b2ed7f2b feat: show login/signup buttons on mobile header
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 10:00:02 +01:00
8357aecf98 feat: add ring border to logo icon matching avatar style
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 09:58:27 +01:00
ab3d9f4118 feat: show auth icon strip on mobile header, move burger outside pill
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 09:50:25 +01:00
5219fae36a feat: add structured logging to BullMQ queues and workers
All checks were successful
Build and Push Backend Image / build (push) Successful in 43s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 09:33:43 +01:00
7de1bf7a03 style: fix header
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m46s
2026-03-09 08:56:49 +01:00
a4fd1ff18b fix: soften header shadow glow
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 20:32:17 +01:00
6605980a43 style: move page gradient to global background so it shows behind header
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m19s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:32:58 +01:00
15d9708072 Revert "feat: fixed header with hero section extending behind it"
This reverts commit fc97c1b84b.
2026-03-08 19:25:15 +01:00
89c4c390fa Revert "feat: extend page-hero behind fixed header on all pages"
This reverts commit f5ff59b910.
2026-03-08 19:25:15 +01:00
f5ff59b910 feat: extend page-hero behind fixed header on all pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:24:22 +01:00
fc97c1b84b feat: fixed header with hero section extending behind it
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:22:41 +01:00
e2abb0794a style: use text-foreground color for burger menu lines
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:18:39 +01:00
2644e033b4 style: remove underline from logout button hover
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:16:43 +01:00
ee1cea6d01 style: match logout button hover to other icon buttons, destructive color on hover
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:16:04 +01:00
1496399b96 style: remove header border, keep glow shadow only
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:09:55 +01:00
075f64f4e3 style: single crisp primary border with soft glow on header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:06:43 +01:00
8c6c98d612 style: edgy glowing bottom border on header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:05:41 +01:00
28be084781 style: transparent header, no scroll tracking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:04:25 +01:00
21b8d2c223 feat: transparent header at top, solid on scroll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:03:39 +01:00
b315062d43 revert: restore original header styling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:02:42 +01:00
57 changed files with 2817 additions and 1763 deletions

View File

@@ -8,6 +8,7 @@ import {
pgEnum,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { users } from "./users";
import { recordings } from "./recordings";
@@ -68,6 +69,11 @@ export const user_points = pgTable(
(t) => [
index("user_points_user_idx").on(t.user_id),
index("user_points_date_idx").on(t.date_created),
uniqueIndex("user_points_unique_action_recording")
.on(t.user_id, t.action, t.recording_id)
.where(
sql`"action" IN ('RECORDING_CREATE', 'RECORDING_FEATURED') AND "recording_id" IS NOT NULL`,
),
],
);

View File

@@ -3,8 +3,8 @@ import { builder } from "../builder";
import { CommentType, AdminCommentListType } from "../types/index";
import { comments, users } from "../../db/schema/index";
import { eq, and, desc, ilike, count } from "drizzle-orm";
import { awardPoints, checkAchievements } from "../../lib/gamification";
import { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl";
import { gamificationQueue } from "../../queues/index";
builder.queryField("commentsForVideo", (t) =>
t.field({
@@ -59,10 +59,16 @@ builder.mutationField("createCommentForVideo", (t) =>
})
.returning();
// Gamification (non-blocking)
awardPoints(ctx.db, ctx.currentUser.id, "COMMENT_CREATE")
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "social"))
.catch((e) => console.error("Gamification error on comment:", e));
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "COMMENT_CREATE",
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "social",
});
const user = await ctx.db
.select({

View File

@@ -4,8 +4,8 @@ import { RecordingType, AdminRecordingListType } from "../types/index";
import { recordings, recording_plays } from "../../db/schema/index";
import { eq, and, desc, ilike, count, type SQL } from "drizzle-orm";
import { slugify } from "../../lib/slugify";
import { awardPoints, checkAchievements } from "../../lib/gamification";
import { requireAdmin } from "../../lib/acl";
import { gamificationQueue } from "../../queues/index";
builder.queryField("recordings", (t) =>
t.field({
@@ -122,11 +122,18 @@ builder.mutationField("createRecording", (t) =>
const recording = newRecording[0];
// Gamification (non-blocking)
if (recording.status === "published") {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id)
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
.catch((e) => console.error("Gamification error on recording create:", e));
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_CREATE",
recordingId: recording.id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "recordings",
});
}
return recording;
@@ -180,15 +187,45 @@ builder.mutationField("updateRecording", (t) =>
const recording = updated[0];
// Gamification (non-blocking)
if (args.status === "published" && existing[0].status !== "published") {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id)
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
.catch((e) => console.error("Gamification error on recording publish:", e));
// draft → published: award creation points
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_CREATE",
recordingId: recording.id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "recordings",
});
} else if (args.status === "draft" && existing[0].status === "published") {
// published → draft: revoke creation points
await gamificationQueue.add("revokePoints", {
job: "revokePoints",
userId: ctx.currentUser.id,
action: "RECORDING_CREATE",
recordingId: recording.id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "recordings",
});
} else if (args.status === "published" && recording.featured && !existing[0].featured) {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_FEATURED", recording.id)
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
.catch((e) => console.error("Gamification error on recording feature:", e));
// newly featured while published: award featured bonus
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_FEATURED",
recordingId: recording.id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "recordings",
});
}
return recording;
@@ -290,11 +327,18 @@ builder.mutationField("recordRecordingPlay", (t) =>
})
.returning({ id: recording_plays.id });
// Gamification (non-blocking)
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_PLAY", args.recordingId)
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "playback"))
.catch((e) => console.error("Gamification error on recording play:", e));
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_PLAY",
recordingId: args.recordingId,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "playback",
});
}
return { success: true, play_id: play[0].id };
@@ -329,11 +373,18 @@ builder.mutationField("updateRecordingPlay", (t) =>
})
.where(eq(recording_plays.id, args.playId));
// Gamification (non-blocking)
if (args.completed && !wasCompleted && ctx.currentUser) {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_COMPLETE", existing[0].recording_id)
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "playback"))
.catch((e) => console.error("Gamification error on recording complete:", e));
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_COMPLETE",
recordingId: existing[0].recording_id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "playback",
});
}
return true;

View File

@@ -17,6 +17,7 @@ import { redis } from "./lib/auth";
import { logger } from "./lib/logger";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { startMailWorker } from "./queues/workers/mail";
import { startGamificationWorker } from "./queues/workers/gamification";
const PORT = parseInt(process.env.PORT || "4000");
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
@@ -31,6 +32,7 @@ async function main() {
// Start background workers
startMailWorker();
startGamificationWorker();
logger.info("Queue workers started");
const fastify = Fastify({ loggerInstance: logger });

View File

@@ -28,21 +28,41 @@ export async function awardPoints(
recordingId?: string,
): Promise<void> {
const points = POINT_VALUES[action];
await db.insert(user_points).values({
user_id: userId,
action,
points,
recording_id: recordingId || null,
date_created: new Date(),
});
await db
.insert(user_points)
.values({
user_id: userId,
action,
points,
recording_id: recordingId || null,
date_created: new Date(),
})
.onConflictDoNothing();
await updateUserStats(db, userId);
}
export async function revokePoints(
db: DB,
userId: string,
action: keyof typeof POINT_VALUES,
recordingId: string,
): Promise<void> {
await db
.delete(user_points)
.where(
and(
eq(user_points.user_id, userId),
eq(user_points.action, action),
eq(user_points.recording_id, recordingId),
),
);
await updateUserStats(db, userId);
}
export async function calculateWeightedScore(db: DB, userId: string): Promise<number> {
const now = new Date();
const result = await db.execute(sql`
SELECT SUM(
points * EXP(-${DECAY_LAMBDA} * EXTRACT(EPOCH FROM (${now}::timestamptz - date_created)) / 86400)
points * EXP(${sql.raw(String(-DECAY_LAMBDA))} * EXTRACT(EPOCH FROM (NOW() - date_created)) / 86400)
) as weighted_score
FROM user_points
WHERE user_id = ${userId}

View File

@@ -0,0 +1,6 @@
-- Partial unique index: prevents duplicate RECORDING_CREATE / RECORDING_FEATURED points
-- for the same recording. RECORDING_PLAY / RECORDING_COMPLETE are excluded so a user
-- can earn play points across multiple sessions.
CREATE UNIQUE INDEX "user_points_unique_action_recording"
ON "user_points" ("user_id", "action", "recording_id")
WHERE "action" IN ('RECORDING_CREATE', 'RECORDING_FEATURED') AND "recording_id" IS NOT NULL;

View File

@@ -1,8 +1,25 @@
import { Queue } from "bullmq";
import { redisConnectionOpts } from "./connection.js";
import { logger } from "../lib/logger.js";
const log = logger.child({ component: "queues" });
export const mailQueue = new Queue("mail", { connection: redisConnectionOpts });
mailQueue.on("error", (err) => {
log.error({ queue: "mail", err: err.message }, "Queue error");
});
export const gamificationQueue = new Queue("gamification", {
connection: redisConnectionOpts,
defaultJobOptions: { attempts: 3, backoff: { type: "exponential", delay: 2000 } },
});
gamificationQueue.on("error", (err) => {
log.error({ queue: "gamification", err: err.message }, "Queue error");
});
log.info("Queues initialized");
export const queues: Record<string, Queue> = {
mail: mailQueue,
gamification: gamificationQueue,
};

View File

@@ -0,0 +1,52 @@
import { Worker } from "bullmq";
import { redisConnectionOpts } from "../connection.js";
import { awardPoints, revokePoints, checkAchievements } from "../../lib/gamification.js";
import { db } from "../../db/connection.js";
import { logger } from "../../lib/logger.js";
import type { POINT_VALUES } from "../../lib/gamification.js";
const log = logger.child({ component: "gamification-worker" });
export type GamificationJobData =
| { job: "awardPoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId?: string }
| { job: "revokePoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId: string }
| { job: "checkAchievements"; userId: string; category?: string };
export function startGamificationWorker(): Worker {
const worker = new Worker(
"gamification",
async (bullJob) => {
const data = bullJob.data as GamificationJobData;
log.info(
{ jobId: bullJob.id, job: data.job, userId: data.userId },
"Processing gamification job",
);
switch (data.job) {
case "awardPoints":
await awardPoints(db, data.userId, data.action, data.recordingId);
break;
case "revokePoints":
await revokePoints(db, data.userId, data.action, data.recordingId);
break;
case "checkAchievements":
await checkAchievements(db, data.userId, data.category);
break;
default:
throw new Error(`Unknown gamification job: ${(data as GamificationJobData).job}`);
}
log.info({ jobId: bullJob.id, job: data.job }, "Gamification job completed");
},
{ connection: redisConnectionOpts },
);
worker.on("failed", (bullJob, err) => {
log.error(
{ jobId: bullJob?.id, job: (bullJob?.data as GamificationJobData)?.job, err: err.message },
"Gamification job failed",
);
});
return worker;
}

View File

@@ -1,11 +1,15 @@
import { Worker } from "bullmq";
import { redisConnectionOpts } from "../connection.js";
import { sendVerification, sendPasswordReset } from "../../lib/email.js";
import { logger } from "../../lib/logger.js";
const log = logger.child({ component: "mail-worker" });
export function startMailWorker(): Worker {
const worker = new Worker(
"mail",
async (job) => {
log.info({ jobId: job.id, jobName: job.name }, `Processing mail job`);
switch (job.name) {
case "sendVerification":
await sendVerification(job.data.email as string, job.data.token as string);
@@ -16,12 +20,13 @@ export function startMailWorker(): Worker {
default:
throw new Error(`Unknown mail job: ${job.name}`);
}
log.info({ jobId: job.id, jobName: job.name }, `Mail job completed`);
},
{ connection: redisConnectionOpts },
);
worker.on("failed", (job, err) => {
console.error(`Mail job ${job?.id} (${job?.name}) failed:`, err.message);
log.error({ jobId: job?.id, jobName: job?.name, err: err.message }, `Mail job failed`);
});
return worker;

View File

@@ -10,23 +10,23 @@
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
>
<div
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? "translate-x-10" : ""}`}
class={`bg-foreground h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? "translate-x-10" : ""}`}
></div>
<div
class={`bg-white h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
class={`bg-foreground h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
></div>
<div
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
class={`bg-foreground h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
></div>
<div
class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? "translate-x-0 w-12" : ""}`}
>
<div
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? "rotate-45" : ""}`}
class={`absolute bg-foreground h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? "rotate-45" : ""}`}
></div>
<div
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? "-rotate-45" : ""}`}
class={`absolute bg-foreground h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? "-rotate-45" : ""}`}
></div>
</div>
</div>

View File

@@ -38,7 +38,7 @@
isMobileMenuOpen = false;
}
function isActiveLink(link: { name: string; href: string }) {
function isActiveLink(link: { name?: string; href: string }) {
return (
(page.url.pathname === "/" && link === navLinks[0]) ||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
@@ -47,7 +47,7 @@
</script>
<header
class="sticky top-0 z-50 w-full bg-gradient-to-br from-card/60 via-card/65 to-card/55 backdrop-blur-xl border-b border-border/20 shadow-lg shadow-primary/10"
class="sticky top-0 z-50 w-full backdrop-blur-xl shadow-[0_4px_24px_-8px_color-mix(in_oklab,var(--color-primary)_12%,transparent)] bg-card/50"
>
<div class="container mx-auto px-4">
<div class="flex items-center justify-evenly h-16">
@@ -76,28 +76,14 @@
{/each}
</nav>
<!-- Desktop Auth Actions -->
<!-- Auth Actions -->
{#if authStatus.authenticated}
<div class="w-full hidden lg:flex items-center justify-end">
<div class="w-full flex items-center justify-end">
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
<Button
variant="link"
size="icon"
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "text-foreground" : "hover:text-foreground"}`}
href="/me"
title={$_("header.dashboard")}
>
<span class="icon-[ri--dashboard-2-line] h-4 w-4"></span>
<span
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/me" }) ? "w-full" : "group-hover:w-full"}`}
></span>
<span class="sr-only">{$_("header.dashboard")}</span>
</Button>
<Button
variant="link"
size="icon"
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
class={`flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
href="/play"
title={$_("header.play")}
>
@@ -112,7 +98,7 @@
<Button
variant="link"
size="icon"
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/admin" }) ? "text-foreground" : "hover:text-foreground"}`}
class={`flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/admin" }) ? "text-foreground" : "hover:text-foreground"}`}
href="/admin/users"
title="Admin"
>
@@ -124,7 +110,7 @@
</Button>
{/if}
<Separator orientation="vertical" class="mx-1 h-6 bg-border/50" />
<Separator orientation="vertical" class="hidden lg:block mx-1 h-6 bg-border/50" />
<a href="/me" class="flex items-center gap-2 px-1 hover:opacity-80 transition-opacity">
<Avatar class="h-7 w-7 ring-2 ring-primary/20">
@@ -138,41 +124,51 @@
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
</AvatarFallback>
</Avatar>
<span class="text-sm font-medium text-foreground/90 max-w-24 truncate">
<span
class="hidden lg:inline text-sm font-medium text-foreground/90 max-w-24 truncate"
>
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
</span>
</a>
<Button
variant="ghost"
variant="link"
size="icon"
class="h-8 w-8 rounded-full text-foreground hover:text-destructive hover:bg-destructive/10"
class="hidden lg:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group hover:text-destructive"
onclick={handleLogout}
title={$_("header.logout")}
>
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
</Button>
</div>
<div class="lg:hidden ml-2">
<BurgerMenuButton
label={$_("header.navigation")}
bind:isMobileMenuOpen
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
/>
</div>
</div>
{:else}
<div class="hidden lg:flex w-full items-center justify-end gap-4">
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button>
<Button
href="/signup"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
>{$_("header.signup")}</Button
>
<div class="w-full flex items-center justify-end gap-2">
<div class="flex gap-4">
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button
>
<Button
href="/signup"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
>{$_("header.signup")}</Button
>
</div>
<div class="lg:hidden ml-2">
<BurgerMenuButton
label={$_("header.navigation")}
bind:isMobileMenuOpen
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
/>
</div>
</div>
{/if}
<!-- Burger button — mobile/tablet only -->
<div class="lg:hidden ml-auto">
<BurgerMenuButton
label={$_("header.navigation")}
bind:isMobileMenuOpen
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
/>
</div>
</div>
</div>
</header>

View File

@@ -9,7 +9,7 @@
<svg
xmlns="http://www.w3.org/2000/svg"
class={className}
class={`rounded-full ring-2 ring-primary/20 ${className}`}
width={size}
height={size}
viewBox="0 0 10240 10240"

View File

@@ -11,7 +11,6 @@
</script>
<section class="relative py-12 md:py-20 overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-b from-primary/12 via-accent/6 to-transparent"></div>
<div class="relative container mx-auto px-4 text-center">
<div class="max-w-5xl mx-auto">
<h1

View File

@@ -2,16 +2,18 @@
import { _ } from "svelte-i18n";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import type { Recording, DeviceInfo } from "$lib/types";
import { cn } from "$lib/utils";
interface Props {
recording: Recording;
onPlay?: (id: string) => void;
onPublish?: (id: string) => void;
onUnpublish?: (id: string) => void;
onDelete?: (id: string) => void;
}
let { recording, onPlay, onDelete }: Props = $props();
let { recording, onPlay, onPublish, onUnpublish, onDelete }: Props = $props();
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
@@ -19,17 +21,6 @@
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
function getStatusColor(status: string): string {
switch (status) {
case "published":
return "text-green-400 bg-green-400/20";
case "draft":
return "text-yellow-400 bg-yellow-400/20";
default:
return "text-gray-400 bg-gray-400/20";
}
}
</script>
<Card
@@ -42,9 +33,14 @@
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
{recording.title}
</h3>
<span class={cn("text-xs px-2 py-0.5 rounded-full", getStatusColor(recording.status))}>
<Badge
variant="outline"
class={recording.status === "published"
? "text-green-600 border-green-500/40 bg-green-500/10"
: "text-yellow-600 border-yellow-500/40 bg-yellow-500/10"}
>
{$_(`recording_card.status_${recording.status}`)}
</span>
</Badge>
</div>
{#if recording.description}
<p class="text-sm text-muted-foreground line-clamp-2">
@@ -149,12 +145,35 @@
{$_("recording_card.play")}
</Button>
{/if}
{#if onPublish && recording.status === "draft"}
<Button
size="sm"
variant="outline"
onclick={() => onPublish?.(recording.id)}
class="cursor-pointer border-primary/20 hover:bg-primary/10 hover:text-primary"
title={$_("recording_card.publish")}
>
<span class="icon-[ri--send-plane-line] w-4 h-4"></span>
</Button>
{/if}
{#if onUnpublish && recording.status === "published"}
<Button
size="sm"
variant="outline"
onclick={() => onUnpublish?.(recording.id)}
class="cursor-pointer border-muted-foreground/20 hover:bg-muted/50 hover:text-muted-foreground"
title={$_("recording_card.unpublish")}
>
<span class="icon-[ri--arrow-go-back-line] w-4 h-4"></span>
</Button>
{/if}
{#if onDelete}
<Button
size="sm"
variant="outline"
onclick={() => onDelete?.(recording.id)}
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
title={$_("common.delete")}
>
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
</Button>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-content"
class={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className,
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-description"
class={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...restProps}
>
{@render children?.()}
</div>

View File

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

View File

@@ -0,0 +1,41 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const emptyMediaVariants = tv({
base: "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
});
export type EmptyMediaVariant = VariantProps<typeof emptyMediaVariants>["variant"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
variant = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: EmptyMediaVariant } = $props();
</script>
<div
bind:this={ref}
data-slot="empty-icon"
data-variant={variant}
class={cn(emptyMediaVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>

View File

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

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty"
class={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className,
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,22 @@
import Root from "./empty.svelte";
import Header from "./empty-header.svelte";
import Media from "./empty-media.svelte";
import Title from "./empty-title.svelte";
import Description from "./empty-description.svelte";
import Content from "./empty-content.svelte";
export {
Root,
Header,
Media,
Title,
Description,
Content,
//
Root as Empty,
Header as EmptyHeader,
Media as EmptyMedia,
Title as EmptyTitle,
Description as EmptyDescription,
Content as EmptyContent,
};

View File

@@ -91,6 +91,23 @@ export default {
me: {
title: "Dashboard",
welcome: "Welcome back, {name}",
nav: {
profile: "Profile",
security: "Security",
recordings: "Recordings",
analytics: "Analytics",
back_to_site: "Back to site",
back_mobile: "Back",
},
analytics: {
title: "Analytics",
description: "Track your content performance and audience engagement",
total_videos: "Total Videos",
total_likes: "Total Likes",
total_plays: "Total Plays",
video_performance: "Video Performance",
video_performance_description: "Detailed metrics for each video",
},
view_profile: "View Public Profile",
settings: {
title: "Settings",
@@ -134,6 +151,10 @@ export default {
delete_confirm: "Are you sure you want to delete this recording?",
delete_success: "Recording deleted successfully",
delete_error: "Failed to delete recording",
publish_success: "Recording published successfully",
publish_error: "Failed to publish recording",
unpublish_success: "Recording unpublished",
unpublish_error: "Failed to unpublish recording",
},
},
recording_card: {
@@ -144,6 +165,8 @@ export default {
status_draft: "Draft",
status_published: "Published",
play: "Play",
publish: "Publish",
unpublish: "Unpublish",
edit: "Edit",
delete: "Delete",
public: "Public",
@@ -799,11 +822,19 @@ export default {
questions_email: "support@pivoine.art",
},
play: {
title: "SexyPlay",
title: "Play",
description: "Bring your toys.",
scan: "Start Scan",
scanning: "Scanning...",
no_results: "No Devices founds",
no_results: "No devices found",
no_results_description: "Start a scan to discover nearby Bluetooth devices",
nav: {
play: "Play",
recordings: "Recordings",
leaderboard: "Leaderboard",
back_to_site: "Back to site",
back_mobile: "Site",
},
},
error: {
not_found: "Oops! Page Not Found",
@@ -905,8 +936,8 @@ export default {
},
admin: {
nav: {
back_to_site: "Back to site",
back_mobile: "Back",
back_to_site: "Back to site",
back_mobile: "Back",
title: "Admin",
users: "Users",
videos: "Videos",
@@ -928,8 +959,8 @@ export default {
cover_image: "Cover image",
tags: "Tags",
publish_date: "Publish date",
title_field: "Title *",
slug_field: "Slug *",
title_field: "Title",
slug_field: "Slug",
title_slug_required: "Title and slug are required",
image_uploaded: "Image uploaded",
image_upload_failed: "Image upload failed",

View File

@@ -902,6 +902,26 @@ export async function createRecording(
);
}
const UPDATE_RECORDING_MUTATION = gql`
mutation UpdateRecording($id: String!, $status: String, $public: Boolean) {
updateRecording(id: $id, status: $status, public: $public) {
id
status
public
}
}
`;
export async function updateRecording(id: string, fields: { status?: string; public?: boolean }) {
return loggedApiCall("updateRecording", async () => {
const data = await getGraphQLClient().request<{ updateRecording: Recording }>(
UPDATE_RECORDING_MUTATION,
{ id, ...fields },
);
return data.updateRecording;
});
}
const DELETE_RECORDING_MUTATION = gql`
mutation DeleteRecording($id: String!) {
deleteRecording(id: $id)

View File

@@ -28,7 +28,9 @@
<div class="bg-background text-foreground min-h-screen">
<!-- Advanced Global Plasma Background -->
<div class="fixed inset-0 pointer-events-none overflow-hidden">
<div
class="fixed inset-0 pointer-events-none overflow-hidden bg-gradient-to-b from-primary/12 via-accent/6 to-transparent"
>
<!-- Large primary blobs -->
<div
class="absolute -top-40 -left-40 w-80 h-80 bg-gradient-to-r from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-ultra-slow"

View File

@@ -1,8 +1,17 @@
<script lang="ts">
import { page } from "$app/state";
import { _ } from "svelte-i18n";
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import { getAssetUrl } from "$lib/api";
const { children } = $props();
const { children, data } = $props();
const user = $derived(data.authStatus.user!);
const avatarUrl = $derived(
user.avatar ? (getAssetUrl(user.avatar, "thumbnail") ?? undefined) : undefined,
);
const displayName = $derived(user.artist_name ?? user.email);
const navLinks = $derived([
{ name: $_("admin.nav.users"), href: "/admin/users", icon: "icon-[ri--team-line]" },
@@ -33,9 +42,10 @@
<div class="flex items-center gap-1 overflow-x-auto py-2 scrollbar-none">
<a
href="/"
class="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
class="shrink-0 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
>
{$_("admin.nav.back_mobile")}
<span class="icon-[ri--arrow-left-line] h-4 w-4"></span>
<span class="hidden sm:inline">{$_("admin.nav.back_mobile")}</span>
</a>
{#each navLinks as link (link.href)}
<a
@@ -58,10 +68,33 @@
<!-- Sidebar (desktop only) -->
<aside class="hidden lg:flex w-56 shrink-0 flex-col border-r border-border/40">
<div class="px-4 py-5 border-b border-border/40">
<a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors">
<a
href="/"
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span class="icon-[ri--arrow-left-line] h-3.5 w-3.5"></span>
{$_("admin.nav.back_to_site")}
</a>
<h1 class="mt-2 text-base font-bold text-foreground">{$_("admin.nav.title")}</h1>
<div class="mt-3 flex items-center gap-3">
<div class="relative shrink-0">
<Avatar class="h-9 w-9">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback class="text-xs">
{getUserInitials(displayName)}
</AvatarFallback>
</Avatar>
<span
class="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary ring-2 ring-background"
>
<span class="icon-[ri--shield-keyhole-fill] h-2.5 w-2.5 text-primary-foreground"
></span>
</span>
</div>
<div class="min-w-0">
<p class="text-sm font-semibold text-foreground truncate">{displayName}</p>
<p class="text-xs text-primary font-medium">{$_("admin.nav.title")}</p>
</div>
</div>
</div>
<nav class="flex-1 p-3 space-y-1">

View File

@@ -12,6 +12,7 @@
import * as Dialog from "$lib/components/ui/dialog";
import type { Article } from "$lib/types";
import TimeAgo from "javascript-time-ago";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
@@ -64,8 +65,10 @@
}
</script>
<div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<Meta title={$_("admin.articles.title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.articles.title")}</h1>
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground"
@@ -81,7 +84,7 @@
</div>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0">
<div class="flex flex-wrap items-center gap-3 mb-4">
<Input
placeholder={$_("admin.articles.search_placeholder")}
class="max-w-xs"
@@ -201,7 +204,7 @@
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
<div class="flex items-center justify-between mt-4">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {

View File

@@ -11,9 +11,11 @@
import { Textarea } from "$lib/components/ui/textarea";
import { TagsInput } from "$lib/components/ui/tags-input";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { Card, CardContent } from "$lib/components/ui/card";
import { getAssetUrl } from "$lib/api";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import { DatePicker } from "$lib/components/ui/date-picker";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
@@ -93,147 +95,170 @@
}
</script>
<div class="p-3 sm:p-6">
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/articles" size="sm">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
</Button>
<h1 class="text-2xl font-bold">{$_("admin.article_form.edit_title")}</h1>
<Meta title={$_("admin.article_form.edit_title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{data.article.title}</h1>
<p class="text-xs text-muted-foreground mt-0.5">
{data.article.slug}{data.article.category ? " · " + data.article.category : ""}{data.article
.author
? " · " + data.article.author.artist_name
: ""}
</p>
</div>
<div class="space-y-5 max-w-4xl">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input id="title" bind:value={title} />
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input id="slug" bind:value={slug} />
</div>
</div>
<div class="space-y-1.5">
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
<Textarea id="excerpt" bind:value={excerpt} rows={2} />
</div>
<!-- Markdown editor with live preview -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label>{$_("admin.article_form.content")}</Label>
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
<Button
variant="ghost"
size="sm"
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</Button
>
<Button
variant="ghost"
size="sm"
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</Button
>
<Card class="bg-card/50 border-primary/20 max-w-4xl">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input
id="slug"
bind:value={slug}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
<div class="space-y-1.5">
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
<Textarea
bind:value={content}
class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
id="excerpt"
bind:value={excerpt}
rows={2}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
<div
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
>
{#if preview}
{@html preview}
{:else}
<p class="text-muted-foreground italic text-sm">
{$_("admin.article_form.preview_placeholder")}
</p>
{/if}
</div>
<!-- Markdown editor with live preview -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label>{$_("admin.article_form.content")}</Label>
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
<Button
variant="ghost"
size="sm"
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</Button
>
<Button
variant="ghost"
size="sm"
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</Button
>
</div>
</div>
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
<Textarea
bind:value={content}
class={`h-full min-h-96 font-mono text-sm resize-none bg-background/50 border-primary/20 focus:border-primary ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
/>
<div
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
>
{#if preview}
{@html preview}
{:else}
<p class="text-muted-foreground italic text-sm">
{$_("admin.article_form.preview_placeholder")}
</p>
{/if}
</div>
</div>
</div>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.cover_image")}</Label>
{#if imageId}
<img
src={getAssetUrl(imageId, "thumbnail")}
alt=""
class="h-24 rounded object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.cover_image")}</Label>
{#if imageId}
<img
src={getAssetUrl(imageId, "thumbnail")}
alt=""
class="h-24 rounded object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
</div>
<!-- Author -->
<div class="space-y-1.5">
<Label>{$_("admin.article_form.author")}</Label>
<Select type="single" bind:value={authorId}>
<SelectTrigger class="w-full">
{#if selectedAuthor}
{#if selectedAuthor.avatar}
<img
src={getAssetUrl(selectedAuthor.avatar, "mini")}
alt=""
class="h-5 w-5 rounded-full object-cover shrink-0"
/>
{/if}
{selectedAuthor.artist_name}
{:else}
<span class="text-muted-foreground">{$_("admin.article_form.no_author")}</span>
{/if}
</SelectTrigger>
<SelectContent>
<SelectItem value="">{$_("admin.article_form.no_author")}</SelectItem>
{#each data.authors as author (author.id)}
<SelectItem value={author.id}>
{#if author.avatar}
<div class="space-y-1.5">
<Label>{$_("admin.article_form.author")}</Label>
<Select type="single" bind:value={authorId}>
<SelectTrigger class="w-full bg-background/50 border-primary/20">
{#if selectedAuthor}
{#if selectedAuthor.avatar}
<img
src={getAssetUrl(author.avatar, "mini")}
src={getAssetUrl(selectedAuthor.avatar, "mini")}
alt=""
class="h-5 w-5 rounded-full object-cover shrink-0"
/>
{/if}
{author.artist_name}
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="category">{$_("admin.article_form.category")}</Label>
<Input id="category" bind:value={category} />
{selectedAuthor.artist_name}
{:else}
<span class="text-muted-foreground">{$_("admin.article_form.no_author")}</span>
{/if}
</SelectTrigger>
<SelectContent>
<SelectItem value="">{$_("admin.article_form.no_author")}</SelectItem>
{#each data.authors as author (author.id)}
<SelectItem value={author.id}>
{#if author.avatar}
<img
src={getAssetUrl(author.avatar, "mini")}
alt=""
class="h-5 w-5 rounded-full object-cover shrink-0"
/>
{/if}
{author.artist_name}
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="category">{$_("admin.article_form.category")}</Label>
<Input
id="category"
bind:value={category}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
</div>
</div>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput
bind:value={tags}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
<div class="flex gap-3 pt-2">
<Button
onclick={handleSubmit}
disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button>
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -11,6 +11,8 @@
import { TagsInput } from "$lib/components/ui/tags-input";
import { DatePicker } from "$lib/components/ui/date-picker";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { Card, CardContent } from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
let title = $state("");
let slug = $state("");
@@ -75,129 +77,134 @@
}
</script>
<div class="p-3 sm:p-6">
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/articles" size="sm">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
</Button>
<Meta title={$_("admin.article_form.new_title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("admin.article_form.new_title")}</h1>
</div>
<div class="space-y-5 max-w-4xl">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
oninput={() => {
if (!slug) slug = generateSlug(title);
}}
placeholder={$_("admin.article_form.title_placeholder")}
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input
id="slug"
bind:value={slug}
placeholder={$_("admin.article_form.slug_placeholder")}
/>
</div>
</div>
<div class="space-y-1.5">
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
<Textarea
id="excerpt"
bind:value={excerpt}
placeholder={$_("admin.article_form.excerpt_placeholder")}
rows={2}
/>
</div>
<!-- Markdown editor with live preview -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label>{$_("admin.article_form.content")}</Label>
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
<Button
variant="ghost"
size="sm"
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</Button
>
<Button
variant="ghost"
size="sm"
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</Button
>
<Card class="bg-card/50 border-primary/20 max-w-4xl">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
oninput={() => {
if (!slug) slug = generateSlug(title);
}}
placeholder={$_("admin.article_form.title_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input
id="slug"
bind:value={slug}
placeholder={$_("admin.article_form.slug_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
<!-- Mobile: single pane toggled; Desktop: side by side -->
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
<div class="space-y-1.5">
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
<Textarea
bind:value={content}
placeholder={$_("admin.article_form.content_placeholder")}
class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
id="excerpt"
bind:value={excerpt}
placeholder={$_("admin.article_form.excerpt_placeholder")}
rows={2}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
<div
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
>
{#if preview}
{@html preview}
{:else}
<p class="text-muted-foreground italic text-sm">
{$_("admin.article_form.preview_placeholder")}
</p>
{/if}
</div>
<!-- Markdown editor with live preview -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label>{$_("admin.article_form.content")}</Label>
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
<Button
variant="ghost"
size="sm"
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</Button
>
<Button
variant="ghost"
size="sm"
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</Button
>
</div>
</div>
<!-- Mobile: single pane toggled; Desktop: side by side -->
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
<Textarea
bind:value={content}
placeholder={$_("admin.article_form.content_placeholder")}
class={`h-full min-h-96 font-mono text-sm resize-none bg-background/50 border-primary/20 focus:border-primary ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
/>
<div
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
>
{#if preview}
{@html preview}
{:else}
<p class="text-muted-foreground italic text-sm">
{$_("admin.article_form.preview_placeholder")}
</p>
{/if}
</div>
</div>
</div>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.cover_image")}</Label>
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
{#if imageId}<p class="text-xs text-green-600 mt-1">
{$_("admin.common.image_uploaded")}
</p>{/if}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="category">{$_("admin.article_form.category")}</Label>
<Input
id="category"
bind:value={category}
placeholder={$_("admin.article_form.category_placeholder")}
<Label>{$_("admin.common.cover_image")}</Label>
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
{#if imageId}
<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")}</p>
{/if}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="category">{$_("admin.article_form.category")}</Label>
<Input
id="category"
bind:value={category}
placeholder={$_("admin.article_form.category_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
</div>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput
bind:value={tags}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
</div>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} />
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
<div class="flex gap-3 pt-2">
<Button
onclick={handleSubmit}
disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{saving ? $_("admin.common.creating") : $_("admin.article_form.create")}
</Button>
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -10,6 +10,7 @@
import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog";
import TimeAgo from "javascript-time-ago";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
const timeAgo = new TimeAgo("en");
@@ -53,15 +54,17 @@
}
</script>
<div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<Meta title={$_("admin.comments.title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.comments.title")}</h1>
<span class="text-sm text-muted-foreground"
>{$_("admin.users.total", { values: { total: data.total } })}</span
>
</div>
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
<div class="flex flex-wrap gap-3 mb-4">
<Input
placeholder={$_("admin.comments.search_placeholder")}
class="max-w-xs"
@@ -150,7 +153,7 @@
</div>
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
<div class="flex items-center justify-between mt-4">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {

View File

@@ -12,6 +12,7 @@
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import type { Job } from "$lib/services";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
@@ -124,13 +125,15 @@
}
</script>
<div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<Meta title={$_("admin.queues.title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.queues.title")}</h1>
</div>
<!-- Queue cards -->
<div class="flex flex-wrap gap-3 mb-6 px-3 sm:px-0">
<div class="flex flex-wrap gap-3 mb-6">
{#each queues as queue (queue.name)}
{@const isSelected = selectedQueue === queue.name}
<div
@@ -193,10 +196,9 @@
{#if selectedQueue}
<!-- Status filter tabs -->
<div class="flex gap-1 mb-4 px-3 sm:px-0 flex-wrap">
<div class="flex gap-1 mb-4 flex-wrap">
{#each STATUS_FILTERS as f (f.value ?? "all")}
<Button
size="sm"
variant={selectedStatus === f.value ? "default" : "outline"}
onclick={() => selectStatus(f.value)}
>

View File

@@ -11,6 +11,7 @@
import * as Dialog from "$lib/components/ui/dialog";
import type { Recording } from "$lib/types";
import TimeAgo from "javascript-time-ago";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
const timeAgo = new TimeAgo("en");
@@ -63,15 +64,17 @@
}
</script>
<div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<Meta title={$_("admin.recordings.title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.recordings.title")}</h1>
<span class="text-sm text-muted-foreground"
>{$_("admin.users.total", { values: { total: data.total } })}</span
>
</div>
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0">
<div class="flex flex-wrap items-center gap-3 mb-4">
<Input
placeholder={$_("admin.recordings.search_placeholder")}
class="max-w-xs"
@@ -128,10 +131,12 @@
<td class="px-4 py-3 hidden sm:table-cell">
<div class="flex gap-1">
<Badge
variant={recording.status === "published" ? "default" : "outline"}
class={recording.status === "draft" ? "text-muted-foreground" : ""}
variant="outline"
class={recording.status === "published"
? "text-green-600 border-green-500/40 bg-green-500/10"
: "text-yellow-600 border-yellow-500/40 bg-yellow-500/10"}
>
{recording.status}
{$_(`recording_card.status_${recording.status}`)}
</Badge>
{#if recording.public}
<Badge variant="outline" class="text-blue-600 border-blue-500/40 bg-blue-500/10"
@@ -174,7 +179,7 @@
</div>
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
<div class="flex items-center justify-between mt-4">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {

View File

@@ -12,6 +12,7 @@
import { Badge } from "$lib/components/ui/badge";
import * as Dialog from "$lib/components/ui/dialog";
import type { User } from "$lib/types";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
@@ -84,8 +85,10 @@
}
</script>
<div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<Meta title={$_("admin.users.title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.users.title")}</h1>
<span class="text-sm text-muted-foreground"
>{$_("admin.users.total", { values: { total: data.total } })}</span
@@ -93,7 +96,7 @@
</div>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0">
<div class="flex flex-wrap items-center gap-3 mb-4">
<Input
placeholder={$_("admin.users.search_placeholder")}
class="max-w-xs"
@@ -225,7 +228,7 @@
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
<div class="flex items-center justify-between mt-4">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {

View File

@@ -13,7 +13,9 @@
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Card, CardContent } from "$lib/components/ui/card";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
@@ -125,11 +127,10 @@
}
</script>
<div class="p-3 sm:p-6 max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/users" size="sm">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
</Button>
<Meta title={data.user.artist_name || data.user.email} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<div>
<h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1>
<p class="text-xs text-muted-foreground">
@@ -140,118 +141,142 @@
</div>
</div>
<div class="space-y-6">
<!-- Basic info -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="firstName">{$_("admin.user_edit.first_name")}</Label>
<Input id="firstName" bind:value={firstName} />
</div>
<div class="space-y-1.5">
<Label for="lastName">{$_("admin.user_edit.last_name")}</Label>
<Input id="lastName" bind:value={lastName} />
</div>
</div>
<div class="space-y-1.5">
<Label for="artistName">{$_("admin.user_edit.artist_name")}</Label>
<Input id="artistName" bind:value={artistName} />
</div>
<!-- Avatar -->
<div class="space-y-1.5">
<Label>{$_("admin.user_edit.avatar")}</Label>
{#if avatarId}
<img
src={getAssetUrl(avatarId, "thumbnail")}
alt=""
class="h-20 w-20 rounded-full object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleAvatarUpload} />
</div>
<!-- Banner -->
<div class="space-y-1.5">
<Label>{$_("admin.user_edit.banner")}</Label>
{#if bannerId}
<img
src={getAssetUrl(bannerId, "preview")}
alt=""
class="w-full h-24 rounded object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} />
</div>
<!-- Model photo (used in cards & model page, not for avatar/comments) -->
<div class="space-y-1.5">
<Label>{$_("admin.user_edit.model_photo")}</Label>
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.model_photo_hint")}</p>
{#if photoId}
<img
src={getAssetUrl(photoId, "preview")}
alt=""
class="w-full h-48 rounded object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload2} />
</div>
<!-- Admin flag -->
<label
class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"
>
<input
type="checkbox"
bind:checked={isAdmin}
class="h-4 w-4 rounded accent-primary shrink-0"
/>
<div>
<span class="text-sm font-medium">{$_("admin.user_edit.is_admin")}</span>
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.is_admin_hint")}</p>
</div>
</label>
<div class="flex gap-3">
<Button
onclick={handleSave}
disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button>
</div>
<!-- Photo gallery -->
<div class="space-y-3 pt-4 border-t border-border/40">
<Label>{$_("admin.user_edit.photos")}</Label>
{#if data.user.photos && data.user.photos.length > 0}
<div class="grid grid-cols-3 gap-2">
{#each data.user.photos as photo (photo.id)}
<div class="relative group">
<img
src={getAssetUrl(photo.id, "thumbnail")}
alt=""
class="w-full aspect-square object-cover rounded"
/>
<Button
variant="ghost"
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded h-auto p-0"
onclick={() => removePhoto(photo.id)}
aria-label="Remove photo"
>
<span class="icon-[ri--delete-bin-line] h-5 w-5 text-white"></span>
</Button>
</div>
{/each}
<div class="space-y-6 max-w-2xl">
<!-- Profile & files card -->
<Card class="bg-card/50 border-primary/20">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="firstName">{$_("admin.user_edit.first_name")}</Label>
<Input
id="firstName"
bind:value={firstName}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label for="lastName">{$_("admin.user_edit.last_name")}</Label>
<Input
id="lastName"
bind:value={lastName}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
{:else}
<p class="text-sm text-muted-foreground">{$_("admin.user_edit.no_photos")}</p>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
</div>
<div class="space-y-1.5">
<Label for="artistName">{$_("admin.user_edit.artist_name")}</Label>
<Input
id="artistName"
bind:value={artistName}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.user_edit.avatar")}</Label>
{#if avatarId}
<img
src={getAssetUrl(avatarId, "thumbnail")}
alt=""
class="h-20 w-20 rounded-full object-cover mb-2"
/>
{/if}
<FileDropZone
accept="image/*"
maxFileSize={10 * MEGABYTE}
onUpload={handleAvatarUpload}
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.user_edit.banner")}</Label>
{#if bannerId}
<img
src={getAssetUrl(bannerId, "preview")}
alt=""
class="w-full h-24 rounded object-cover mb-2"
/>
{/if}
<FileDropZone
accept="image/*"
maxFileSize={10 * MEGABYTE}
onUpload={handleBannerUpload}
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.user_edit.model_photo")}</Label>
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.model_photo_hint")}</p>
{#if photoId}
<img
src={getAssetUrl(photoId, "preview")}
alt=""
class="w-full h-48 rounded object-cover mb-2"
/>
{/if}
<FileDropZone
accept="image/*"
maxFileSize={10 * MEGABYTE}
onUpload={handlePhotoUpload2}
/>
</div>
<label
class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"
>
<input
type="checkbox"
bind:checked={isAdmin}
class="h-4 w-4 rounded accent-primary shrink-0"
/>
<div>
<span class="text-sm font-medium">{$_("admin.user_edit.is_admin")}</span>
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.is_admin_hint")}</p>
</div>
</label>
<Button
onclick={handleSave}
disabled={saving}
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button>
</CardContent>
</Card>
<!-- Photo gallery card -->
<Card class="bg-card/50 border-primary/20">
<CardContent class="space-y-4 pt-6">
<Label>{$_("admin.user_edit.photos")}</Label>
{#if data.user.photos && data.user.photos.length > 0}
<div class="grid grid-cols-3 gap-2">
{#each data.user.photos as photo (photo.id)}
<div class="relative group">
<img
src={getAssetUrl(photo.id, "thumbnail")}
alt=""
class="w-full aspect-square object-cover rounded"
/>
<Button
variant="ghost"
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded h-auto p-0"
onclick={() => removePhoto(photo.id)}
aria-label="Remove photo"
>
<span class="icon-[ri--delete-bin-line] h-5 w-5 text-white"></span>
</Button>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">{$_("admin.user_edit.no_photos")}</p>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
</CardContent>
</Card>
</div>
</div>

View File

@@ -11,6 +11,7 @@
import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog";
import type { Video } from "$lib/types";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
@@ -61,8 +62,10 @@
}
</script>
<div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<Meta title={$_("admin.videos.title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.videos.title")}</h1>
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground"
@@ -78,7 +81,7 @@
</div>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0">
<div class="flex flex-wrap items-center gap-3 mb-4">
<Input
placeholder={$_("admin.videos.search_placeholder")}
class="max-w-xs"
@@ -206,7 +209,7 @@
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
<div class="flex items-center justify-between mt-4">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {

View File

@@ -10,9 +10,11 @@
import { Textarea } from "$lib/components/ui/textarea";
import { TagsInput } from "$lib/components/ui/tags-input";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { Card, CardContent } from "$lib/components/ui/card";
import { getAssetUrl } from "$lib/api";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import { DatePicker } from "$lib/components/ui/date-picker";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
@@ -102,132 +104,145 @@
}
</script>
<div class="p-3 sm:p-6 max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/videos" size="sm">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
</Button>
<h1 class="text-2xl font-bold">{$_("admin.video_form.edit_title")}</h1>
<Meta title={$_("admin.video_form.edit_title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{data.video.title}</h1>
<p class="text-xs text-muted-foreground mt-0.5">
{data.video.slug}{data.video.premium ? " · premium" : ""}{data.video.featured
? " · featured"
: ""}
</p>
</div>
<div class="space-y-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card class="bg-card/50 border-primary/20 max-w-2xl">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
placeholder={$_("admin.video_form.title_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input
id="slug"
bind:value={slug}
placeholder={$_("admin.video_form.slug_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
placeholder={$_("admin.video_form.title_placeholder")}
<Label for="description">{$_("admin.video_form.description")}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={$_("admin.video_form.description_placeholder")}
rows={3}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
<Label>{$_("admin.common.cover_image")}</Label>
{#if imageId}
<img
src={getAssetUrl(imageId, "thumbnail")}
alt=""
class="h-24 rounded object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
</div>
</div>
<div class="space-y-1.5">
<Label for="description">{$_("admin.video_form.description")}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={$_("admin.video_form.description_placeholder")}
rows={3}
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.video_form.video_file")}</Label>
{#if movieId}
<video
src={getAssetUrl(movieId)}
poster={imageId ? (getAssetUrl(imageId, "preview") ?? undefined) : undefined}
controls
class="w-full rounded-lg bg-black max-h-72 mb-2"
>
<track kind="captions" />
</video>
{/if}
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.cover_image")}</Label>
{#if imageId}
<img
src={getAssetUrl(imageId, "thumbnail")}
alt=""
class="h-24 rounded object-cover mb-2"
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput
bind:value={tags}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.video_form.video_file")}</Label>
{#if movieId}
<video
src={getAssetUrl(movieId)}
poster={imageId ? (getAssetUrl(imageId, "preview") ?? undefined) : undefined}
controls
class="w-full rounded-lg bg-black max-h-72 mb-2"
>
<track kind="captions" />
</video>
{/if}
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker
bind:value={uploadDate}
placeholder={$_("admin.common.publish_date")}
showTime={false}
/>
</div>
<div class="flex gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={premium} class="rounded" />
<span class="text-sm">{$_("admin.common.premium")}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
</div>
{#if data.models.length > 0}
<div class="space-y-1.5">
<Label>{$_("admin.video_form.models")}</Label>
<Select type="multiple" bind:value={selectedModelIds}>
<SelectTrigger class="w-full">
{#if selectedModelIds.length}
{$_("admin.video_form.models_selected", {
values: { count: selectedModelIds.length },
})}
{:else}
<span class="text-muted-foreground">{$_("admin.video_form.no_models")}</span>
{/if}
</SelectTrigger>
<SelectContent>
{#each data.models as model (model.id)}
<SelectItem value={model.id}>
{#if model.avatar}
<img
src={getAssetUrl(model.avatar, "mini")}
alt=""
class="h-5 w-5 rounded-full object-cover shrink-0"
/>
{/if}
{model.artist_name}
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
{/if}
<div class="flex gap-3 pt-2">
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker
bind:value={uploadDate}
placeholder={$_("admin.common.publish_date")}
showTime={false}
/>
</div>
<div class="flex gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={premium} class="rounded" />
<span class="text-sm">{$_("admin.common.premium")}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
</div>
{#if data.models.length > 0}
<div class="space-y-1.5">
<Label>{$_("admin.video_form.models")}</Label>
<Select type="multiple" bind:value={selectedModelIds}>
<SelectTrigger class="w-full bg-background/50 border-primary/20">
{#if selectedModelIds.length}
{$_("admin.video_form.models_selected", {
values: { count: selectedModelIds.length },
})}
{:else}
<span class="text-muted-foreground">{$_("admin.video_form.no_models")}</span>
{/if}
</SelectTrigger>
<SelectContent>
{#each data.models as model (model.id)}
<SelectItem value={model.id}>
{#if model.avatar}
<img
src={getAssetUrl(model.avatar, "mini")}
alt=""
class="h-5 w-5 rounded-full object-cover shrink-0"
/>
{/if}
{model.artist_name}
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
{/if}
<Button
onclick={handleSubmit}
disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button>
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -10,6 +10,8 @@
import { TagsInput } from "$lib/components/ui/tags-input";
import { DatePicker } from "$lib/components/ui/date-picker";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { Card, CardContent } from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
@@ -97,115 +99,123 @@
}
</script>
<div class="p-3 sm:p-6 max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/videos" size="sm">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
</Button>
<Meta title={$_("admin.video_form.new_title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("admin.video_form.new_title")}</h1>
</div>
<div class="space-y-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
oninput={() => {
if (!slug) slug = generateSlug(title);
}}
placeholder={$_("admin.video_form.title_placeholder")}
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
</div>
</div>
<div class="space-y-1.5">
<Label for="description">{$_("admin.video_form.description")}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={$_("admin.video_form.description_placeholder")}
rows={3}
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.cover_image")}</Label>
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
{#if imageId}<p class="text-xs text-green-600 mt-1">
{$_("admin.common.image_uploaded")}
</p>{/if}
</div>
<div class="space-y-1.5">
<Label>{$_("admin.video_form.video_file")}</Label>
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
{#if movieId}<p class="text-xs text-green-600 mt-1">
{$_("admin.video_form.video_uploaded")}
</p>{/if}
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} />
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker
bind:value={uploadDate}
placeholder={$_("admin.common.publish_date")}
showTime={false}
/>
</div>
<div class="flex gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={premium} class="rounded" />
<span class="text-sm">{$_("admin.common.premium")}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
</div>
{#if data.models.length > 0}
<div class="space-y-2">
<Label>Models</Label>
<div class="flex flex-wrap gap-2">
{#each data.models as model (model.id)}
<Button
variant="ghost"
size="sm"
class={`px-3 py-1.5 h-auto rounded-full text-sm border transition-colors ${
selectedModelIds.includes(model.id)
? "border-primary bg-primary/10 text-primary"
: "border-border/40 text-muted-foreground hover:border-primary/40"
}`}
onclick={() => toggleModel(model.id)}
>
{model.artist_name || model.id}
</Button>
{/each}
<Card class="bg-card/50 border-primary/20 max-w-2xl">
<CardContent class="space-y-5 pt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
<Input
id="title"
bind:value={title}
oninput={() => {
if (!slug) slug = generateSlug(title);
}}
placeholder={$_("admin.video_form.title_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input
id="slug"
bind:value={slug}
placeholder={$_("admin.video_form.slug_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
{/if}
<div class="flex gap-3 pt-2">
<div class="space-y-1.5">
<Label for="description">{$_("admin.video_form.description")}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={$_("admin.video_form.description_placeholder")}
rows={3}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.cover_image")}</Label>
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
{#if imageId}
<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")}</p>
{/if}
</div>
<div class="space-y-1.5">
<Label>{$_("admin.video_form.video_file")}</Label>
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
{#if movieId}
<p class="text-xs text-green-600 mt-1">{$_("admin.video_form.video_uploaded")}</p>
{/if}
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput
bind:value={tags}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label>{$_("admin.common.publish_date")}</Label>
<DatePicker
bind:value={uploadDate}
placeholder={$_("admin.common.publish_date")}
showTime={false}
/>
</div>
<div class="flex gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={premium} class="rounded" />
<span class="text-sm">{$_("admin.common.premium")}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">{$_("admin.common.featured")}</span>
</label>
</div>
{#if data.models.length > 0}
<div class="space-y-2">
<Label>{$_("admin.video_form.models")}</Label>
<div class="flex flex-wrap gap-2">
{#each data.models as model (model.id)}
<Button
variant="ghost"
size="sm"
class={`px-3 py-1.5 h-auto rounded-full text-sm border transition-colors ${
selectedModelIds.includes(model.id)
? "border-primary bg-primary/10 text-primary"
: "border-border/40 text-muted-foreground hover:border-primary/40"
}`}
onclick={() => toggleModel(model.id)}
>
{model.artist_name || model.id}
</Button>
{/each}
</div>
</div>
{/if}
<Button
onclick={handleSubmit}
disabled={saving}
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{saving ? $_("admin.common.creating") : $_("admin.video_form.create")}
</Button>
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -1,66 +1,5 @@
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import { gql } from "graphql-request";
import { getGraphQLClient } from "$lib/api";
const LEADERBOARD_QUERY = gql`
query Leaderboard($limit: Int, $offset: Int) {
leaderboard(limit: $limit, offset: $offset) {
user_id
display_name
avatar
total_weighted_points
total_raw_points
recordings_count
playbacks_count
achievements_count
rank
}
}
`;
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
// Guard: Redirect to login if not authenticated
if (!locals.authStatus.authenticated) {
throw redirect(302, "/login");
}
try {
const limit = parseInt(url.searchParams.get("limit") || "100");
const offset = parseInt(url.searchParams.get("offset") || "0");
const client = getGraphQLClient(fetch);
const data = await client.request<{
leaderboard: {
user_id: string;
display_name: string | null;
avatar: string | null;
total_weighted_points: number | null;
total_raw_points: number | null;
recordings_count: number | null;
playbacks_count: number | null;
achievements_count: number | null;
rank: number;
}[];
}>(LEADERBOARD_QUERY, { limit, offset });
return {
leaderboard: data.leaderboard || [],
pagination: {
limit,
offset,
hasMore: data.leaderboard?.length === limit,
},
};
} catch (error) {
console.error("Leaderboard load error:", error);
return {
leaderboard: [],
pagination: {
limit: 100,
offset: 0,
hasMore: false,
},
};
}
};
export function load() {
throw redirect(301, "/play/leaderboard");
}

View File

@@ -0,0 +1,12 @@
import { redirect } from "@sveltejs/kit";
import { isModel } from "$lib/api";
export async function load({ locals }) {
if (!locals.authStatus.authenticated) {
throw redirect(302, "/login");
}
return {
authStatus: locals.authStatus,
isModel: isModel(locals.authStatus.user!),
};
}

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { page } from "$app/state";
import { _ } from "svelte-i18n";
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import { getAssetUrl } from "$lib/api";
const { children, data } = $props();
const navLinks = $derived([
{ name: $_("me.nav.profile"), href: "/me/profile", icon: "icon-[ri--user-line]" },
{ name: $_("me.nav.security"), href: "/me/security", icon: "icon-[ri--shield-keyhole-line]" },
...(data.isModel
? [
{
name: $_("me.nav.analytics"),
href: "/me/analytics",
icon: "icon-[ri--line-chart-line]",
},
]
: []),
]);
function isActive(href: string) {
return page.url.pathname.startsWith(href);
}
const user = $derived(data.authStatus.user!);
const avatarUrl = $derived(
user.avatar ? (getAssetUrl(user.avatar, "thumbnail") ?? undefined) : undefined,
);
const displayName = $derived(user.artist_name ?? user.email);
</script>
<div class="min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
<div class="container mx-auto px-4">
<!-- Mobile top nav -->
<div class="lg:hidden border-b border-border/40">
<div class="flex items-center gap-1 overflow-x-auto py-2 scrollbar-none">
<a
href="/"
class="shrink-0 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
>
<span class="icon-[ri--arrow-left-line] h-4 w-4"></span>
<span class="hidden sm:inline">{$_("me.nav.back_mobile")}</span>
</a>
{#each navLinks as link (link.href)}
<a
href={link.href}
class={`shrink-0 flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
isActive(link.href)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span class={`${link.icon} h-4 w-4 shrink-0`}></span>
<span class="hidden sm:inline">{link.name}</span>
</a>
{/each}
</div>
</div>
<!-- Desktop layout -->
<div class="flex min-h-screen">
<!-- Sidebar (desktop only) -->
<aside class="hidden lg:flex w-56 shrink-0 flex-col border-r border-border/40">
<div class="px-4 py-5 border-b border-border/40">
<a
href="/"
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span class="icon-[ri--arrow-left-line] h-3.5 w-3.5"></span>
{$_("me.nav.back_to_site")}
</a>
<div class="mt-3 flex items-center gap-3">
<Avatar class="h-9 w-9 shrink-0">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback class="text-xs">
{getUserInitials(displayName)}
</AvatarFallback>
</Avatar>
<div class="min-w-0">
<p class="text-sm font-semibold text-foreground truncate">{displayName}</p>
<p class="text-xs text-muted-foreground">{$_("me.title")}</p>
</div>
</div>
</div>
<nav class="flex-1 p-3 space-y-1">
{#each navLinks as link (link.href)}
<a
href={link.href}
class={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive(link.href)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span class={`${link.icon} h-4 w-4`}></span>
{link.name}
</a>
{/each}
</nav>
</aside>
<!-- Main content -->
<main class="flex-1 min-w-0">
{@render children()}
</main>
</div>
</div>
</div>

View File

@@ -1,25 +1,4 @@
import { redirect } from "@sveltejs/kit";
import { getAnalytics, getFolders, getRecordings } from "$lib/services";
import { isModel } from "$lib/api";
export async function load({ locals, fetch }) {
// Redirect to login if not authenticated
if (!locals.authStatus.authenticated) {
throw redirect(302, "/login");
}
const recordings = await getRecordings(fetch).catch(() => []);
const analytics = isModel(locals.authStatus.user!)
? await getAnalytics(fetch).catch(() => null)
: null;
const folders = await getFolders(fetch).catch(() => []);
return {
authStatus: locals.authStatus,
folders,
recordings,
analytics,
};
export function load() {
throw redirect(302, "/me/profile");
}

View File

@@ -1,690 +0,0 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import SexyBackground from "$lib/components/background/background.svelte";
import { onMount, untrack } from "svelte";
import { goto, invalidateAll } from "$app/navigation";
import { getAssetUrl, isModel } from "$lib/api";
import * as Alert from "$lib/components/ui/alert";
import { toast } from "svelte-sonner";
import { deleteRecording, removeFile, updateProfile, uploadFile } from "$lib/services";
import * as Dialog from "$lib/components/ui/dialog";
import { Textarea } from "$lib/components/ui/textarea";
import Meta from "$lib/components/meta/meta.svelte";
import { TagsInput } from "$lib/components/ui/tags-input";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import RecordingCard from "$lib/components/recording-card/recording-card.svelte";
const { data } = $props();
let recordings = $state(untrack(() => data.recordings));
let deleteTarget = $state<string | null>(null);
let deleteOpen = $state(false);
let deleting = $state(false);
let activeTab = $state("settings");
let firstName = $state(untrack(() => data.authStatus.user!.first_name));
let lastName = $state(untrack(() => data.authStatus.user!.last_name));
let artistName = $state(untrack(() => data.authStatus.user!.artist_name));
let description = $state(untrack(() => data.authStatus.user!.description));
let tags = $state(untrack(() => data.authStatus.user!.tags ?? undefined));
$effect(() => {
recordings = data.recordings;
firstName = data.authStatus.user!.first_name;
lastName = data.authStatus.user!.last_name;
artistName = data.authStatus.user!.artist_name;
description = data.authStatus.user!.description;
tags = data.authStatus.user!.tags ?? undefined;
email = data.authStatus.user!.email;
});
let email = $state(untrack(() => data.authStatus.user!.email));
let password = $state("");
let confirmPassword = $state("");
let showPassword = $state(false);
let showConfirmPassword = $state(false);
let isProfileLoading = $state(false);
let isProfileError = $state(false);
let profileError = $state("");
let isSecurityLoading = $state(false);
let isSecurityError = $state(false);
let securityError = $state("");
async function handleProfileSubmit(e: Event) {
e.preventDefault();
try {
isProfileLoading = true;
isProfileError = false;
profileError = "";
let avatarId: string | null | undefined = undefined;
if (!avatar?.id && data.authStatus.user!.avatar) {
// User removed their avatar
await removeFile(data.authStatus.user!.avatar);
avatarId = null;
} else if (avatar?.id) {
// Keep existing avatar
avatarId = avatar.id;
}
if (avatar?.file) {
const formData = new FormData();
formData.append("file", avatar.file);
const result = await uploadFile(formData);
avatarId = result.id;
}
await updateProfile({
first_name: firstName,
last_name: lastName,
artist_name: artistName,
description,
tags,
avatar: avatarId ?? undefined,
});
toast.success($_("me.settings.toast_update"));
invalidateAll();
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
profileError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
isProfileError = true;
} finally {
isProfileLoading = false;
}
}
async function handleSecuritySubmit(e: Event) {
e.preventDefault();
try {
if (password !== confirmPassword) {
throw new Error($_("me.settings.password_error"));
}
isSecurityLoading = true;
isSecurityError = false;
securityError = "";
await updateProfile({
email,
password,
});
toast.success($_("me.settings.toast_update"));
invalidateAll();
password = confirmPassword = "";
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
securityError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
isSecurityError = true;
} finally {
isSecurityLoading = false;
}
}
let avatar = $state<{
id?: string;
url: string;
name: string;
size: number;
file?: File;
}>();
async function handleFilesUpload(files: File[]) {
const file = files[0];
avatar = {
name: file.name,
size: file.size,
url: URL.createObjectURL(file),
file,
};
}
async function handleAvatarRemove() {
if (avatar!.id) {
avatar = undefined;
} else {
setExistingAvatar();
}
}
function setExistingAvatar() {
if (data.authStatus.user!.avatar) {
avatar = {
id: data.authStatus.user!.avatar,
url: getAssetUrl(data.authStatus.user!.avatar, "thumbnail")!,
name: data.authStatus.user!.artist_name ?? "",
size: 0,
};
} else {
avatar = undefined;
}
}
function handleDeleteRecording(id: string) {
deleteTarget = id;
deleteOpen = true;
}
async function confirmDeleteRecording() {
if (!deleteTarget) return;
deleting = true;
try {
await deleteRecording(deleteTarget);
recordings = recordings.filter((r) => r.id !== deleteTarget);
toast.success($_("me.recordings.delete_success"));
deleteOpen = false;
deleteTarget = null;
} catch {
toast.error($_("me.recordings.delete_error"));
} finally {
deleting = false;
}
}
function handlePlayRecording(id: string) {
// Navigate to play page with recording ID
goto(`/play?recording=${id}`);
}
onMount(() => {
if (data.authStatus.authenticated) {
setExistingAvatar();
return;
}
goto("/login");
});
</script>
<Meta
title={$_("me.title")}
description={$_("me.welcome", {
values: { name: data.authStatus.user!.artist_name },
})}
/>
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<SexyBackground />
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">{$_("me.title")}</h1>
<p class="text-sm text-muted-foreground mt-0.5">
{$_("me.welcome", { values: { name: data.authStatus.user!.artist_name } })}
</p>
</div>
{#if isModel(data.authStatus.user!)}
<Button href={`/models/${data.authStatus.user!.slug}`} variant="outline">
{$_("me.view_profile")}
</Button>
{/if}
</div>
<!-- Dashboard Tabs -->
<Tabs bind:value={activeTab} class="w-full">
<TabsList class="grid w-full {data.analytics ? 'grid-cols-3' : 'grid-cols-2'} max-w-2xl mb-8">
<TabsTrigger value="settings" class="flex items-center gap-2">
<span class="icon-[ri--settings-4-line] w-4 h-4"></span>
{$_("me.settings.title")}
</TabsTrigger>
<TabsTrigger value="recordings" class="flex items-center gap-2">
<span class="icon-[ri--play-list-2-line] w-4 h-4"></span>
{$_("me.recordings.title")}
</TabsTrigger>
{#if data.analytics}
<TabsTrigger value="analytics" class="flex items-center gap-2">
<span class="icon-[ri--line-chart-line] w-4 h-4"></span>
Analytics
</TabsTrigger>
{/if}
</TabsList>
<!-- Settings Tab -->
<TabsContent value="settings" class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Profile Settings -->
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle>{$_("me.settings.profile_title")}</CardTitle>
<CardDescription>{$_("me.settings.profile_subtitle")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<form onsubmit={handleProfileSubmit} class="space-y-4">
<div class="space-y-2">
<Label>{$_("me.settings.avatar")}</Label>
<div class="flex items-center gap-5">
<FileDropZone
id="avatar"
fileCount={0}
maxFiles={1}
maxFileSize={2 * MEGABYTE}
onUpload={handleFilesUpload}
accept="image/*"
class="h-auto w-auto shrink-0 border-none p-0 rounded-full hover:bg-transparent"
>
<div class="relative group cursor-pointer w-24 h-24">
{#if avatar}
<img
src={avatar.url}
alt={avatar.name}
class="w-24 h-24 rounded-full object-cover ring-4 ring-primary/20 group-hover:ring-primary/50 transition-all"
/>
<div
class="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<span class="icon-[ri--camera-line] w-7 h-7 text-white"></span>
</div>
{:else}
<div
class="w-24 h-24 rounded-full border-2 border-dashed border-primary/30 group-hover:border-primary/60 bg-primary/5 group-hover:bg-primary/10 transition-all flex flex-col items-center justify-center gap-1"
>
<span
class="icon-[ri--camera-line] w-7 h-7 text-primary/50 group-hover:text-primary/80 transition-colors"
></span>
<span class="text-xs text-muted-foreground">Upload</span>
</div>
{/if}
</div>
</FileDropZone>
<div class="flex flex-col gap-1">
<p class="text-sm text-muted-foreground">JPG, PNG · max 2 MB</p>
<p class="text-xs text-muted-foreground/70">
Click or drop to {avatar ? "change" : "upload"}
</p>
{#if avatar}
<Button
variant="ghost"
size="sm"
onclick={handleAvatarRemove}
class="cursor-pointer w-fit mt-1 px-2 h-7 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<span class="icon-[ri--delete-bin-line] w-3.5 h-3.5 mr-1"></span>
Remove
</Button>
{/if}
</div>
</div>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="firstName">{$_("me.settings.first_name")}</Label>
<Input
id="firstName"
placeholder={$_("me.settings.first_name_placeholder")}
bind:value={firstName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-2">
<Label for="lastName">{$_("me.settings.last_name")}</Label>
<Input
id="lastName"
placeholder={$_("me.settings.last_name_placeholder")}
bind:value={lastName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
<div class="space-y-2">
<Label for="artistName">{$_("me.settings.artist_name")}</Label>
<Input
id="artistName"
placeholder={$_("me.settings.artist_name_placeholder")}
bind:value={artistName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-2">
<Label for="description">{$_("me.settings.description")}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={$_("me.settings.description_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
rows={3}
/>
</div>
<div class="space-y-2">
<Label for="tags">{$_("me.settings.tags")}</Label>
<TagsInput
id="tags"
bind:value={tags}
placeholder={$_("me.settings.tags_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
{#if isProfileError}
<div class="grid w-full items-start gap-4">
<Alert.Root variant="destructive">
<Alert.Title class="items-center flex"
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
"me.settings.error",
)}</Alert.Title
>
<Alert.Description>{profileError}</Alert.Description>
</Alert.Root>
</div>
{/if}
<Button
type="submit"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
disabled={isProfileLoading}
>
{#if isProfileLoading}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("me.settings.updating_profile")}
{:else}
{$_("me.settings.update_profile")}
{/if}
</Button>
</form>
</CardContent>
</Card>
<!-- Privacy Settings -->
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle>{$_("me.settings.privacy_title")}</CardTitle>
<CardDescription>{$_("me.settings.privacy_subtitle")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<form onsubmit={handleSecuritySubmit} class="space-y-4">
<!-- Email -->
<div class="space-y-2">
<Label for="email">{$_("me.settings.email")}</Label>
<Input
id="email"
type="email"
placeholder={$_("me.settings.email_placeholder")}
bind:value={email}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<!-- Password -->
<div class="space-y-2">
<Label for="password">{$_("me.settings.password")}</Label>
<div class="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder={$_("me.settings.password_placeholder")}
bind:value={password}
required
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{#if showPassword}
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
{:else}
<span class="icon-[ri--eye-line] w-4 h-4"></span>
{/if}
</button>
</div>
</div>
<!-- Confirm Password -->
<div class="space-y-2">
<Label for="confirmPassword">{$_("me.settings.confirm_password")}</Label>
<div class="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder={$_("me.settings.confirm_password_placeholder")}
bind:value={confirmPassword}
required
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
/>
<button
type="button"
onclick={() => (showConfirmPassword = !showConfirmPassword)}
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{#if showConfirmPassword}
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
{:else}
<span class="icon-[ri--eye-line] w-4 h-4"></span>
{/if}
</button>
</div>
</div>
{#if isSecurityError}
<div class="grid w-full items-start gap-4">
<Alert.Root variant="destructive">
<Alert.Title class="items-center flex"
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
"me.settings.error",
)}</Alert.Title
>
<Alert.Description>{securityError}</Alert.Description>
</Alert.Root>
</div>
{/if}
<Button
variant="outline"
type="submit"
class="cursor-pointer w-full border-primary/20 hover:bg-primary/10"
disabled={isSecurityLoading}
>
{#if isSecurityLoading}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("me.settings.updating_security")}
{:else}
{$_("me.settings.update_security")}
{/if}
</Button>
</form>
</CardContent>
</Card>
</div>
</TabsContent>
<!-- Recordings Tab -->
<TabsContent value="recordings" class="space-y-6">
<div class="mb-6 flex justify-between items-center">
<div>
<h2 class="text-2xl font-bold text-card-foreground">
{$_("me.recordings.title")}
</h2>
<p class="text-muted-foreground">
{$_("me.recordings.description")}
</p>
</div>
<Button
href="/play"
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
{$_("me.recordings.go_to_play")}
</Button>
</div>
{#if recordings.length === 0}
<Card class="bg-card/50 border-primary/20">
<CardContent class="py-12">
<div class="flex flex-col items-center justify-center text-center">
<div 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>
</div>
<h3 class="text-xl font-semibold mb-2">
{$_("me.recordings.no_recordings")}
</h3>
<p class="text-muted-foreground mb-6 max-w-md">
{$_("me.recordings.no_recordings_description")}
</p>
<Button
href="/play"
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
{$_("me.recordings.go_to_play")}
</Button>
</div>
</CardContent>
</Card>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each recordings as recording (recording.id)}
<RecordingCard
{recording}
onPlay={handlePlayRecording}
onDelete={handleDeleteRecording}
/>
{/each}
</div>
{/if}
</TabsContent>
<!-- Analytics Tab -->
{#if data.analytics}
<TabsContent value="analytics" class="space-y-6">
<div class="mb-6">
<h2 class="text-2xl font-bold text-card-foreground">Analytics Dashboard</h2>
<p class="text-muted-foreground">
Track your content performance and audience engagement
</p>
</div>
<!-- Overview Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--video-line] w-5 h-5 text-primary"></span>
Total Videos
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{data.analytics.total_videos}</p>
</CardContent>
</Card>
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--heart-fill] w-5 h-5 text-primary"></span>
Total Likes
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{data.analytics.total_likes.toLocaleString()}</p>
</CardContent>
</Card>
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--play-fill] w-5 h-5 text-primary"></span>
Total Plays
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{data.analytics.total_plays.toLocaleString()}</p>
</CardContent>
</Card>
</div>
<!-- Video Performance Table -->
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle>Video Performance</CardTitle>
<CardDescription>Detailed metrics for each video</CardDescription>
</CardHeader>
<CardContent>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-border">
<th class="text-left p-3">Title</th>
<th class="text-right p-3">Likes</th>
<th class="text-right p-3">Plays</th>
<th class="text-right p-3">Completion Rate</th>
<th class="text-right p-3">Avg Watch Time</th>
</tr>
</thead>
<tbody>
{#each data.analytics.videos as video (video.slug)}
<tr class="border-b border-border/50 hover:bg-primary/5 transition-colors">
<td class="p-3">
<a
href="/videos/{video.slug}"
class="hover:text-primary transition-colors"
>
{video.title}
</a>
</td>
<td class="text-right p-3 font-medium">
{video.likes}
</td>
<td class="text-right p-3 font-medium">
{video.plays}
</td>
<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'}"
>
{video.completion_rate.toFixed(1)}%
</span>
</td>
<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")}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
{/if}
</Tabs>
</div>
</div>
<Dialog.Root bind:open={deleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$_("me.recordings.delete_confirm")}</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
<Button variant="destructive" disabled={deleting} onclick={confirmDeleteRecording}>
{deleting ? "Deleting…" : "Delete"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,12 @@
import { redirect } from "@sveltejs/kit";
import { isModel } from "$lib/api";
import { getAnalytics } from "$lib/services";
export async function load({ locals, fetch }) {
if (!isModel(locals.authStatus.user!)) {
throw redirect(302, "/me/profile");
}
return {
analytics: await getAnalytics(fetch).catch(() => null),
};
}

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
</script>
<Meta title={$_("me.analytics.title")} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">{$_("me.analytics.title")}</h1>
<p class="text-sm text-muted-foreground mt-0.5">{$_("me.analytics.description")}</p>
</div>
</div>
<div class="space-y-6">
{#if data.analytics}
<!-- Overview Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--video-line] w-5 h-5 text-primary"></span>
{$_("me.analytics.total_videos")}
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{data.analytics.total_videos}</p>
</CardContent>
</Card>
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--heart-fill] w-5 h-5 text-primary"></span>
{$_("me.analytics.total_likes")}
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{data.analytics.total_likes.toLocaleString()}</p>
</CardContent>
</Card>
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--play-fill] w-5 h-5 text-primary"></span>
{$_("me.analytics.total_plays")}
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{data.analytics.total_plays.toLocaleString()}</p>
</CardContent>
</Card>
</div>
<!-- Video Performance Table -->
<Card class="bg-card/50 border-primary/20">
<CardHeader>
<CardTitle>{$_("me.analytics.video_performance")}</CardTitle>
<CardDescription>{$_("me.analytics.video_performance_description")}</CardDescription>
</CardHeader>
<CardContent>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-border">
<th class="text-left p-3">Title</th>
<th class="text-right p-3">Likes</th>
<th class="text-right p-3">Plays</th>
<th class="text-right p-3">Completion Rate</th>
<th class="text-right p-3">Avg Watch Time</th>
</tr>
</thead>
<tbody>
{#each data.analytics.videos as video (video.slug)}
<tr class="border-b border-border/50 hover:bg-primary/5 transition-colors">
<td class="p-3">
<a href="/videos/{video.slug}" class="hover:text-primary transition-colors">
{video.title}
</a>
</td>
<td class="text-right p-3 font-medium">
{video.likes}
</td>
<td class="text-right p-3 font-medium">
{video.plays}
</td>
<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'}"
>
{video.completion_rate.toFixed(1)}%
</span>
</td>
<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")}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</CardContent>
</Card>
{:else}
<Card class="bg-card/50 border-primary/20">
<CardContent class="py-12">
<div class="flex flex-col items-center justify-center text-center">
<div class="mb-4 p-4 rounded-full bg-muted/30 border border-border/30">
<span class="icon-[ri--line-chart-line] w-12 h-12 text-muted-foreground"></span>
</div>
<h3 class="text-xl font-semibold mb-2">No analytics available</h3>
<p class="text-muted-foreground max-w-md">
Analytics data will appear here once your content starts getting views.
</p>
</div>
</CardContent>
</Card>
{/if}
</div>
</div>

View File

@@ -0,0 +1,284 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { invalidateAll } from "$app/navigation";
import { untrack } from "svelte";
import { getAssetUrl } from "$lib/api";
import { toast } from "svelte-sonner";
import { updateProfile, uploadFile, removeFile } from "$lib/services";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Textarea } from "$lib/components/ui/textarea";
import { TagsInput } from "$lib/components/ui/tags-input";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import * as Alert from "$lib/components/ui/alert";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
let firstName = $state(untrack(() => data.authStatus.user!.first_name));
let lastName = $state(untrack(() => data.authStatus.user!.last_name));
let artistName = $state(untrack(() => data.authStatus.user!.artist_name));
let description = $state(untrack(() => data.authStatus.user!.description));
let tags = $state(untrack(() => data.authStatus.user!.tags ?? undefined));
$effect(() => {
firstName = data.authStatus.user!.first_name;
lastName = data.authStatus.user!.last_name;
artistName = data.authStatus.user!.artist_name;
description = data.authStatus.user!.description;
tags = data.authStatus.user!.tags ?? undefined;
});
let isProfileLoading = $state(false);
let isProfileError = $state(false);
let profileError = $state("");
let avatar = $state<{
id?: string;
url: string;
name: string;
size: number;
file?: File;
}>();
function setExistingAvatar() {
if (data.authStatus.user!.avatar) {
avatar = {
id: data.authStatus.user!.avatar,
url: getAssetUrl(data.authStatus.user!.avatar, "thumbnail")!,
name: data.authStatus.user!.artist_name ?? "",
size: 0,
};
} else {
avatar = undefined;
}
}
$effect(() => {
setExistingAvatar();
});
async function handleFilesUpload(files: File[]) {
const file = files[0];
avatar = {
name: file.name,
size: file.size,
url: URL.createObjectURL(file),
file,
};
}
async function handleAvatarRemove() {
if (avatar!.id) {
avatar = undefined;
} else {
setExistingAvatar();
}
}
async function handleProfileSubmit(e: Event) {
e.preventDefault();
try {
isProfileLoading = true;
isProfileError = false;
profileError = "";
let avatarId: string | null | undefined = undefined;
if (!avatar?.id && data.authStatus.user!.avatar) {
await removeFile(data.authStatus.user!.avatar);
avatarId = null;
} else if (avatar?.id) {
avatarId = avatar.id;
}
if (avatar?.file) {
const formData = new FormData();
formData.append("file", avatar.file);
const result = await uploadFile(formData);
avatarId = result.id;
}
await updateProfile({
first_name: firstName,
last_name: lastName,
artist_name: artistName,
description,
tags,
avatar: avatarId ?? undefined,
});
toast.success($_("me.settings.toast_update"));
invalidateAll();
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
profileError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
isProfileError = true;
} finally {
isProfileLoading = false;
}
}
</script>
<Meta title={$_("me.settings.profile_title")} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("me.settings.profile_title")}</h1>
</div>
<Card class="bg-card/50 border-primary/20 max-w-2xl">
<CardHeader>
<CardTitle>{$_("me.settings.profile_title")}</CardTitle>
<CardDescription>{$_("me.settings.profile_subtitle")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<form onsubmit={handleProfileSubmit} class="space-y-4">
<div class="space-y-2">
<Label>{$_("me.settings.avatar")}</Label>
<div class="flex items-center gap-5">
<FileDropZone
id="avatar"
fileCount={0}
maxFiles={1}
maxFileSize={2 * MEGABYTE}
onUpload={handleFilesUpload}
accept="image/*"
class="h-auto w-auto shrink-0 border-none p-0 rounded-full hover:bg-transparent"
>
<div class="relative group cursor-pointer w-24 h-24">
{#if avatar}
<img
src={avatar.url}
alt={avatar.name}
class="w-24 h-24 rounded-full object-cover ring-4 ring-primary/20 group-hover:ring-primary/50 transition-all"
/>
<div
class="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<span class="icon-[ri--camera-line] w-7 h-7 text-white"></span>
</div>
{:else}
<div
class="w-24 h-24 rounded-full border-2 border-dashed border-primary/30 group-hover:border-primary/60 bg-primary/5 group-hover:bg-primary/10 transition-all flex flex-col items-center justify-center gap-1"
>
<span
class="icon-[ri--camera-line] w-7 h-7 text-primary/50 group-hover:text-primary/80 transition-colors"
></span>
<span class="text-xs text-muted-foreground">Upload</span>
</div>
{/if}
</div>
</FileDropZone>
<div class="flex flex-col gap-1">
<p class="text-sm text-muted-foreground">JPG, PNG · max 2 MB</p>
<p class="text-xs text-muted-foreground/70">
Click or drop to {avatar ? "change" : "upload"}
</p>
{#if avatar}
<Button
variant="ghost"
size="sm"
onclick={handleAvatarRemove}
class="cursor-pointer w-fit mt-1 px-2 h-7 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<span class="icon-[ri--delete-bin-line] w-3.5 h-3.5 mr-1"></span>
Remove
</Button>
{/if}
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="firstName">{$_("me.settings.first_name")}</Label>
<Input
id="firstName"
placeholder={$_("me.settings.first_name_placeholder")}
bind:value={firstName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-2">
<Label for="lastName">{$_("me.settings.last_name")}</Label>
<Input
id="lastName"
placeholder={$_("me.settings.last_name_placeholder")}
bind:value={lastName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
<div class="space-y-2">
<Label for="artistName">{$_("me.settings.artist_name")}</Label>
<Input
id="artistName"
placeholder={$_("me.settings.artist_name_placeholder")}
bind:value={artistName}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-2">
<Label for="description">{$_("me.settings.description")}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={$_("me.settings.description_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
rows={3}
/>
</div>
<div class="space-y-2">
<Label for="tags">{$_("me.settings.tags")}</Label>
<TagsInput
id="tags"
bind:value={tags}
placeholder={$_("me.settings.tags_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
{#if isProfileError}
<div class="grid w-full items-start gap-4">
<Alert.Root variant="destructive">
<Alert.Title class="items-center flex">
<span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>
{$_("me.settings.error")}
</Alert.Title>
<Alert.Description>{profileError}</Alert.Description>
</Alert.Root>
</div>
{/if}
<Button
type="submit"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
disabled={isProfileLoading}
>
{#if isProfileLoading}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("me.settings.updating_profile")}
{:else}
{$_("me.settings.update_profile")}
{/if}
</Button>
</form>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load() {
throw redirect(301, "/play/recordings");
}

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { untrack } from "svelte";
import { _ } from "svelte-i18n";
import { goto } from "$app/navigation";
import { toast } from "svelte-sonner";
import { deleteRecording, updateRecording } from "$lib/services";
import { Button } from "$lib/components/ui/button";
import * as Empty from "$lib/components/ui/empty";
import * as Dialog from "$lib/components/ui/dialog";
import RecordingCard from "$lib/components/recording-card/recording-card.svelte";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
let recordings = $state(untrack(() => data.recordings));
let deleteTarget = $state<string | null>(null);
let deleteOpen = $state(false);
let deleting = $state(false);
function handleDeleteRecording(id: string) {
deleteTarget = id;
deleteOpen = true;
}
async function confirmDeleteRecording() {
if (!deleteTarget) return;
deleting = true;
try {
await deleteRecording(deleteTarget);
recordings = recordings.filter((r) => r.id !== deleteTarget);
toast.success($_("me.recordings.delete_success"));
deleteOpen = false;
deleteTarget = null;
} catch {
toast.error($_("me.recordings.delete_error"));
} finally {
deleting = false;
}
}
async function handlePublishRecording(id: string) {
try {
await updateRecording(id, { status: "published" });
recordings = recordings.map((r) => (r.id === id ? { ...r, status: "published" } : r));
toast.success($_("me.recordings.publish_success"));
} catch {
toast.error($_("me.recordings.publish_error"));
}
}
async function handleUnpublishRecording(id: string) {
try {
await updateRecording(id, { status: "draft" });
recordings = recordings.map((r) => (r.id === id ? { ...r, status: "draft" } : r));
toast.success($_("me.recordings.unpublish_success"));
} catch {
toast.error($_("me.recordings.unpublish_error"));
}
}
function handlePlayRecording(id: string) {
goto(`/play?recording=${id}`);
}
</script>
<Meta title={$_("me.recordings.title")} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("me.recordings.title")}</h1>
</div>
{#if recordings.length === 0}
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon">
<span class="icon-[ri--play-list-2-line] w-8 h-8"></span>
</Empty.Media>
<Empty.Title>{$_("me.recordings.no_recordings")}</Empty.Title>
<Empty.Description>{$_("me.recordings.no_recordings_description")}</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button
href="/play"
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
{$_("me.recordings.go_to_play")}
</Button>
</Empty.Content>
</Empty.Root>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each recordings as recording (recording.id)}
<RecordingCard
{recording}
onPlay={handlePlayRecording}
onPublish={handlePublishRecording}
onUnpublish={handleUnpublishRecording}
onDelete={handleDeleteRecording}
/>
{/each}
</div>
{/if}
</div>
<Dialog.Root bind:open={deleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$_("me.recordings.delete_confirm")}</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (deleteOpen = false)}>
{$_("common.cancel")}
</Button>
<Button variant="destructive" disabled={deleting} onclick={confirmDeleteRecording}>
{deleting ? "Deleting…" : $_("common.delete")}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,163 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { invalidateAll } from "$app/navigation";
import { untrack } from "svelte";
import { toast } from "svelte-sonner";
import { updateProfile } from "$lib/services";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Alert from "$lib/components/ui/alert";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
let email = $state(untrack(() => data.authStatus.user!.email));
let password = $state("");
let confirmPassword = $state("");
let showPassword = $state(false);
let showConfirmPassword = $state(false);
let isSecurityLoading = $state(false);
let isSecurityError = $state(false);
let securityError = $state("");
async function handleSecuritySubmit(e: Event) {
e.preventDefault();
try {
if (password !== confirmPassword) {
throw new Error($_("me.settings.password_error"));
}
isSecurityLoading = true;
isSecurityError = false;
securityError = "";
await updateProfile({
email,
password,
});
toast.success($_("me.settings.toast_update"));
invalidateAll();
password = confirmPassword = "";
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
securityError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
isSecurityError = true;
} finally {
isSecurityLoading = false;
}
}
</script>
<Meta title={$_("me.settings.privacy_title")} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("me.settings.privacy_title")}</h1>
</div>
<Card class="bg-card/50 border-primary/20 max-w-2xl">
<CardHeader>
<CardTitle>{$_("me.settings.privacy_title")}</CardTitle>
<CardDescription>{$_("me.settings.privacy_subtitle")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<form onsubmit={handleSecuritySubmit} class="space-y-4">
<div class="space-y-2">
<Label for="email">{$_("me.settings.email")}</Label>
<Input
id="email"
type="email"
placeholder={$_("me.settings.email_placeholder")}
bind:value={email}
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-2">
<Label for="password">{$_("me.settings.password")}</Label>
<div class="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder={$_("me.settings.password_placeholder")}
bind:value={password}
required
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{#if showPassword}
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
{:else}
<span class="icon-[ri--eye-line] w-4 h-4"></span>
{/if}
</button>
</div>
</div>
<div class="space-y-2">
<Label for="confirmPassword">{$_("me.settings.confirm_password")}</Label>
<div class="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder={$_("me.settings.confirm_password_placeholder")}
bind:value={confirmPassword}
required
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
/>
<button
type="button"
onclick={() => (showConfirmPassword = !showConfirmPassword)}
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{#if showConfirmPassword}
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
{:else}
<span class="icon-[ri--eye-line] w-4 h-4"></span>
{/if}
</button>
</div>
</div>
{#if isSecurityError}
<div class="grid w-full items-start gap-4">
<Alert.Root variant="destructive">
<Alert.Title class="items-center flex">
<span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>
{$_("me.settings.error")}
</Alert.Title>
<Alert.Description>{securityError}</Alert.Description>
</Alert.Root>
</div>
{/if}
<Button
type="submit"
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
disabled={isSecurityLoading}
>
{#if isSecurityLoading}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("me.settings.updating_security")}
{:else}
{$_("me.settings.update_security")}
{/if}
</Button>
</form>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,8 @@
import { redirect } from "@sveltejs/kit";
export async function load({ locals }) {
if (!locals.authStatus.authenticated) {
throw redirect(302, "/login");
}
return { authStatus: locals.authStatus };
}

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import { page } from "$app/state";
import { _ } from "svelte-i18n";
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import { getAssetUrl } from "$lib/api";
import SexyBackground from "$lib/components/background/background.svelte";
const { children, data } = $props();
const navLinks = $derived([
{
name: $_("play.nav.play"),
href: "/play/buttplug",
icon: "icon-[ri--rocket-line]",
exact: false,
},
{
name: $_("play.nav.recordings"),
href: "/play/recordings",
icon: "icon-[ri--play-list-2-line]",
exact: false,
},
{
name: $_("play.nav.leaderboard"),
href: "/play/leaderboard",
icon: "icon-[ri--trophy-line]",
exact: false,
},
]);
function isActive(link: { href: string; exact: boolean }) {
if (link.exact) return page.url.pathname === link.href;
return page.url.pathname.startsWith(link.href);
}
const user = $derived(data.authStatus.user!);
const avatarUrl = $derived(
user.avatar ? (getAssetUrl(user.avatar, "thumbnail") ?? undefined) : undefined,
);
const displayName = $derived(user.artist_name ?? user.email);
</script>
<div class="min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 relative">
<SexyBackground />
<div class="container mx-auto px-4 relative z-10">
<!-- Mobile top nav -->
<div class="lg:hidden border-b border-border/40">
<div class="flex items-center gap-1 overflow-x-auto py-2 scrollbar-none">
<a
href="/"
class="shrink-0 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
>
<span class="icon-[ri--arrow-left-line] h-4 w-4"></span>
<span class="hidden sm:inline">{$_("play.nav.back_mobile")}</span>
</a>
{#each navLinks as link (link.href)}
<a
href={link.href}
class={`shrink-0 flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
isActive(link)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span class={`${link.icon} h-4 w-4 shrink-0`}></span>
<span class="hidden sm:inline">{link.name}</span>
</a>
{/each}
</div>
</div>
<!-- Desktop layout -->
<div class="flex min-h-screen">
<!-- Sidebar (desktop only) -->
<aside class="hidden lg:flex w-56 shrink-0 flex-col border-r border-border/40">
<div class="px-4 py-5 border-b border-border/40">
<a
href="/"
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span class="icon-[ri--arrow-left-line] h-3.5 w-3.5"></span>
{$_("play.nav.back_to_site")}
</a>
<div class="mt-3 flex items-center gap-3">
<Avatar class="h-9 w-9 shrink-0">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback class="text-xs">
{getUserInitials(displayName)}
</AvatarFallback>
</Avatar>
<div class="min-w-0">
<p class="text-sm font-semibold text-foreground truncate">{displayName}</p>
<p class="text-xs text-primary">{$_("play.title")}</p>
</div>
</div>
</div>
<nav class="flex-1 p-3 space-y-1">
{#each navLinks as link (link.href)}
<a
href={link.href}
class={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive(link)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span class={`${link.icon} h-4 w-4`}></span>
{link.name}
</a>
{/each}
</nav>
</aside>
<!-- Main content -->
<main class="flex-1 min-w-0">
{@render children()}
</main>
</div>
</div>
</div>

View File

@@ -1,20 +1,5 @@
import { getRecording } from "$lib/services";
import type { Recording } from "$lib/types";
import { redirect } from "@sveltejs/kit";
export async function load({ locals, url, fetch }) {
const recordingId = url.searchParams.get("recording");
let recording: Recording | null = null;
if (recordingId && locals.authStatus.authenticated) {
try {
recording = await getRecording(recordingId, fetch);
} catch (error) {
console.error("Failed to load recording:", error);
}
}
return {
authStatus: locals.authStatus,
recording,
};
export function load() {
throw redirect(302, "/play/buttplug");
}

View File

@@ -0,0 +1,19 @@
import { getRecording } from "$lib/services";
import type { Recording } from "$lib/types";
export async function load({ url, fetch }) {
const recordingId = url.searchParams.get("recording");
let recording: Recording | null = null;
if (recordingId) {
try {
recording = await getRecording(recordingId, fetch);
} catch (error) {
console.error("Failed to load recording:", error);
}
}
return {
recording,
};
}

View File

@@ -4,13 +4,13 @@
import type * as ButtplugTypes from "@sexy.pivoine.art/buttplug";
import Button from "$lib/components/ui/button/button.svelte";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import DeviceCard from "$lib/components/device-card/device-card.svelte";
import RecordingSaveDialog from "./components/recording-save-dialog.svelte";
import DeviceMappingDialog from "./components/device-mapping-dialog.svelte";
import RecordingSaveDialog from "../components/recording-save-dialog.svelte";
import DeviceMappingDialog from "../components/device-mapping-dialog.svelte";
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
import { toast } from "svelte-sonner";
import SexyBackground from "$lib/components/background/background.svelte";
import { createRecording } from "$lib/services";
import * as Empty from "$lib/components/ui/empty";
// Runtime buttplug values — loaded dynamically from the buttplug nginx container
let client: ButtplugTypes.ButtplugClient;
@@ -40,7 +40,6 @@
async function init() {
const connector = new ButtplugWasmClientConnector();
// await ButtplugWasmClientConnector.activateLogging("info");
await client.connect(connector);
client.on("deviceadded", onDeviceAdded);
client.on("deviceremoved", (dev: ButtplugTypes.ButtplugClientDevice) => {
@@ -61,7 +60,6 @@
const device = convertDevice(dev);
devices.push(device);
// Try to read battery level — access through the reactive array so Svelte detects the mutation
const idx = devices.length - 1;
if (device.hasBattery) {
try {
@@ -94,16 +92,13 @@
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
// Capture event if recording
if (isRecording && recordingStartTime) {
captureEvent(device, actuatorIdx, value);
}
}
function startRecording() {
if (devices.length === 0) {
return;
}
if (devices.length === 0) return;
isRecording = true;
recordingStartTime = performance.now();
recordedEvents = [];
@@ -130,7 +125,7 @@
device_name: device.name,
actuator_index: actuatorIdx,
actuator_type: actuator.outputType,
value: (value / actuator.maxSteps) * 100, // Normalize to 0-100
value: (value / actuator.maxSteps) * 100,
});
}
@@ -165,7 +160,11 @@
};
}
async function handleSaveRecording(data: { title: string; description: string; tags: string[] }) {
async function handleSaveRecording(saveData: {
title: string;
description: string;
tags: string[];
}) {
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
name: d.name,
index: d.info.index,
@@ -173,33 +172,20 @@
}));
try {
const response = await fetch("/api/sexy/recordings", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: data.title,
description: data.description,
duration: recordingDuration,
events: recordedEvents,
device_info: deviceInfo,
tags: data.tags,
status: "draft",
}),
await createRecording({
title: saveData.title,
description: saveData.description,
duration: Math.round(recordingDuration),
events: recordedEvents,
device_info: deviceInfo,
tags: saveData.tags,
status: "draft",
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
toast.success("Recording saved successfully!");
showSaveDialog = false;
recordedEvents = [];
recordingDuration = 0;
// Optionally navigate to dashboard
// goto("/me?tab=recordings");
} catch (error) {
console.error("Failed to save recording:", error);
toast.error("Failed to save recording. Please try again.");
@@ -212,24 +198,19 @@
recordingDuration = 0;
}
// Playback functions
function startPlayback() {
if (!data.recording) {
return;
}
if (!data.recording) return;
if (devices.length === 0) {
toast.error("Please connect devices before playing recording");
return;
}
// Check if we need to map devices
if (deviceMappings.size === 0 && (data.recording.device_info?.length ?? 0) > 0) {
showMappingDialog = true;
return;
}
// Start playback with existing mappings
beginPlayback();
}
@@ -259,8 +240,6 @@
}
playbackProgress = 0;
currentEventIndex = 0;
// Stop all devices
devices.forEach((device) => handleStop(device));
}
@@ -274,7 +253,6 @@
function resumePlayback() {
if (!data.recording) return;
isPlaying = true;
playbackStartTime = performance.now() - playbackProgress;
scheduleNextEvent();
@@ -295,12 +273,10 @@
const delay = event.timestamp - currentTime;
if (delay <= 0) {
// Execute event immediately
executeEvent(event);
currentEventIndex++;
scheduleNextEvent();
} else {
// Schedule event
playbackTimeoutId = setTimeout(() => {
executeEvent(event);
currentEventIndex++;
@@ -311,31 +287,25 @@
}
function executeEvent(event: RecordedEvent) {
// Get mapped device
const device = deviceMappings.get(event.device_name);
if (!device) {
console.warn(`No device mapping for: ${event.device_name}`);
return;
}
// Find matching actuator by type
const actuator = device.actuators.find((a) => a.outputType === event.actuator_type);
if (!actuator) {
console.warn(`Actuator type ${event.actuator_type} not found on ${device.name}`);
return;
}
// Convert normalized value (0-100) back to device scale
const deviceValue = Math.round((event.value / 100) * actuator.maxSteps);
// Send command to device via feature
const feature = device.info.features.get(actuator.featureIndex);
if (feature) {
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
}
// Update UI
actuator.value = deviceValue;
}
@@ -345,7 +315,6 @@
const targetTime = (percentage / 100) * data.recording.duration;
playbackProgress = targetTime;
// Find the event index at this time
const seekEvents = (data.recording.events ?? []) as RecordedEvent[];
currentEventIndex = seekEvents.findIndex((e) => e.timestamp >= targetTime);
if (currentEventIndex === -1) {
@@ -364,10 +333,6 @@
const { data } = $props();
onMount(async () => {
if (!data.authStatus.authenticated) {
goto("/login");
return;
}
// Concatenation prevents Rollup from statically resolving this URL at build time
const buttplugUrl = "/buttplug/" + "dist/index.js";
const bp = await import(/* @vite-ignore */ buttplugUrl);
@@ -382,240 +347,203 @@
<Meta title={$_("play.title")} description={$_("play.description")} />
<div
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
>
<SexyBackground />
<div class="py-3 sm:py-6 lg:pl-6">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("play.title")}</h1>
</div>
<div class="container mx-auto py-20 relative px-4">
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="text-center mb-12">
<h1
class="text-4xl md:text-5xl font-bold mb-4 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
<!-- Recording controls (only when devices are connected) -->
{#if devices.length > 0 && !data.recording}
<div class="flex flex-wrap items-center gap-3 mb-6">
{#if !isRecording}
<Button
variant="outline"
onclick={startRecording}
class="cursor-pointer border-primary/30 hover:bg-primary/10"
>
{$_("play.title")}
</h1>
<p class="text-lg text-muted-foreground mb-6">
{$_("play.description")}
</p>
<div class="flex justify-center gap-3 mb-10">
<Button
variant="outline"
size="sm"
href="/leaderboard"
class="border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--trophy-line] w-4 h-4 mr-2"></span>
{$_("gamification.leaderboard")}
</Button>
<Button
variant="outline"
size="sm"
href="/me"
class="border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--user-line] w-4 h-4 mr-2"></span>
{$_("common.my_profile")}
</Button>
</div>
<div class="flex justify-center gap-4 items-center">
<Button
size="lg"
disabled={!connected || scanning}
onclick={startScanning}
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{#if scanning}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("play.scanning")}
{:else}
{$_("play.scan")}
{/if}
</Button>
{#if devices.length > 0 && !data.recording}
{#if !isRecording}
<Button
size="lg"
variant="outline"
onclick={startRecording}
class="cursor-pointer border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--record-circle-line] w-5 h-5 mr-2"></span>
Start Recording
</Button>
{:else}
<Button
size="lg"
onclick={stopRecording}
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white"
>
<span class="icon-[ri--stop-circle-fill] w-5 h-5 mr-2 animate-pulse"></span>
Stop Recording ({recordedEvents.length} events)
</Button>
{/if}
{/if}
</div>
</div>
<!-- Playback Controls (only shown when recording is loaded) -->
{#if data.recording}
<div class="bg-card/50 border border-primary/20 rounded-lg p-6 backdrop-blur-sm">
<div class="mb-4">
<h2 class="text-xl font-semibold text-card-foreground mb-2">
{data.recording.title}
</h2>
{#if data.recording.description}
<p class="text-sm text-muted-foreground">
{data.recording.description}
</p>
{/if}
</div>
<!-- Progress Bar -->
<div class="mb-4">
<div class="flex items-center gap-3 mb-2">
<span class="text-sm text-muted-foreground min-w-[50px]">
{Math.floor(playbackProgress / 1000 / 60)}:{(
Math.floor(playbackProgress / 1000) % 60
)
.toString()
.padStart(2, "0")}
</span>
<div
role="slider"
tabindex="0"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={data.recording.duration}
aria-valuenow={playbackProgress}
class="flex-1 h-2 bg-muted rounded-full overflow-hidden cursor-pointer relative"
onclick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const percentage = ((e.clientX - rect.left) / rect.width) * 100;
seek(percentage);
}}
onkeydown={(e) => {
if (e.key === "ArrowRight")
seek(((playbackProgress + 1) / data.recording.duration) * 100);
else if (e.key === "ArrowLeft")
seek(((playbackProgress - 1) / data.recording.duration) * 100);
}}
>
<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>
<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")}
</span>
</div>
</div>
<!-- Playback Buttons -->
<div class="flex gap-2 justify-center">
<Button
size="lg"
variant="outline"
onclick={stopPlayback}
disabled={!isPlaying && playbackProgress === 0}
class="cursor-pointer border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--stop-fill] w-5 h-5"></span>
</Button>
{#if !isPlaying}
<Button
size="lg"
onclick={playbackProgress > 0 ? resumePlayback : startPlayback}
disabled={devices.length === 0}
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>
{playbackProgress > 0 ? "Resume" : "Play"}
</Button>
{:else}
<Button
size="lg"
onclick={pausePlayback}
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--pause-fill] w-5 h-5 mr-2"></span>
Pause
</Button>
{/if}
</div>
<!-- Recording Info -->
<div class="mt-4 pt-4 border-t border-border/50 grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-xs text-muted-foreground">Events</p>
<p class="text-sm font-medium">{data.recording.events?.length ?? 0}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Devices</p>
<p class="text-sm font-medium">{data.recording.device_info?.length ?? 0}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Status</p>
<p class="text-sm font-medium capitalize">{data.recording.status}</p>
</div>
</div>
</div>
{/if}
</div>
</div>
<div class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#if devices}
{#each devices as device (device.name)}
<DeviceCard
{device}
onChange={(scalarIndex, val) => handleChange(device, scalarIndex, val)}
onStop={() => handleStop(device)}
/>
{/each}
<span class="icon-[ri--record-circle-line] w-4 h-4 mr-2"></span>
Start Recording
</Button>
{:else}
<Button
onclick={stopRecording}
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white"
>
<span class="icon-[ri--stop-circle-fill] w-4 h-4 mr-2 animate-pulse"></span>
Stop Recording ({recordedEvents.length} events)
</Button>
{/if}
</div>
{/if}
{#if devices?.length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground text-lg mb-4">
{$_("play.no_results")}
</p>
</div>
{/if}
</div>
<!-- Recording Save Dialog -->
<RecordingSaveDialog
open={showSaveDialog}
events={recordedEvents}
deviceInfo={devices.map((d) => ({
name: d.name,
index: d.info.index,
capabilities: d.actuators.map((a) => a.outputType),
}))}
duration={recordingDuration}
onSave={handleSaveRecording}
onCancel={handleCancelSave}
/>
<!-- Device Mapping Dialog -->
<!-- Playback Controls (only shown when recording is loaded) -->
{#if data.recording}
<DeviceMappingDialog
open={showMappingDialog}
recordedDevices={(data.recording.device_info ?? []) as DeviceInfo[]}
connectedDevices={devices}
onConfirm={handleMappingConfirm}
onCancel={handleMappingCancel}
/>
<div class="bg-card/50 border border-primary/20 rounded-lg p-6 mb-6">
<div class="mb-4">
<h2 class="text-xl font-semibold text-card-foreground mb-1">
{data.recording.title}
</h2>
{#if data.recording.description}
<p class="text-sm text-muted-foreground">
{data.recording.description}
</p>
{/if}
</div>
<!-- Progress Bar -->
<div class="mb-4">
<div class="flex items-center gap-3 mb-2">
<span class="text-sm text-muted-foreground min-w-[50px]">
{Math.floor(playbackProgress / 1000 / 60)}:{(Math.floor(playbackProgress / 1000) % 60)
.toString()
.padStart(2, "0")}
</span>
<div
role="slider"
tabindex="0"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={data.recording.duration}
aria-valuenow={playbackProgress}
class="flex-1 h-2 bg-muted rounded-full overflow-hidden cursor-pointer relative"
onclick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const percentage = ((e.clientX - rect.left) / rect.width) * 100;
seek(percentage);
}}
onkeydown={(e) => {
if (e.key === "ArrowRight")
seek(((playbackProgress + 1) / data.recording.duration) * 100);
else if (e.key === "ArrowLeft")
seek(((playbackProgress - 1) / data.recording.duration) * 100);
}}
>
<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>
<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")}
</span>
</div>
</div>
<!-- Playback Buttons -->
<div class="flex gap-2 justify-center">
<Button
variant="outline"
onclick={stopPlayback}
disabled={!isPlaying && playbackProgress === 0}
class="cursor-pointer border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--stop-fill] w-5 h-5"></span>
</Button>
{#if !isPlaying}
<Button
onclick={playbackProgress > 0 ? resumePlayback : startPlayback}
disabled={devices.length === 0}
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>
{playbackProgress > 0 ? "Resume" : "Play"}
</Button>
{:else}
<Button
onclick={pausePlayback}
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--pause-fill] w-5 h-5 mr-2"></span>
Pause
</Button>
{/if}
</div>
<!-- Recording Info -->
<div class="mt-4 pt-4 border-t border-border/50 grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-xs text-muted-foreground">Events</p>
<p class="text-sm font-medium">{data.recording.events?.length ?? 0}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Devices</p>
<p class="text-sm font-medium">{data.recording.device_info?.length ?? 0}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Status</p>
<p class="text-sm font-medium capitalize">{data.recording.status}</p>
</div>
</div>
</div>
{/if}
<!-- Devices grid or empty state -->
{#if devices.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{#each devices as device (device.name)}
<DeviceCard
{device}
onChange={(scalarIndex, val) => handleChange(device, scalarIndex, val)}
onStop={() => handleStop(device)}
/>
{/each}
</div>
{:else}
<Empty.Root>
<Empty.Header>
<Empty.Media>
<span class="icon-[ri--rocket-line] w-8 h-8"></span>
</Empty.Media>
<Empty.Title>{$_("play.no_results")}</Empty.Title>
<Empty.Description>{$_("play.no_results_description")}</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button
disabled={!connected || scanning}
onclick={startScanning}
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{#if scanning}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
{$_("play.scanning")}
{:else}
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
{$_("play.scan")}
{/if}
</Button>
</Empty.Content>
</Empty.Root>
{/if}
</div>
<!-- Recording Save Dialog -->
<RecordingSaveDialog
open={showSaveDialog}
events={recordedEvents}
deviceInfo={devices.map((d) => ({
name: d.name,
index: d.info.index,
capabilities: d.actuators.map((a) => a.outputType),
}))}
duration={recordingDuration}
onSave={handleSaveRecording}
onCancel={handleCancelSave}
/>
<!-- Device Mapping Dialog -->
{#if data.recording}
<DeviceMappingDialog
open={showMappingDialog}
recordedDevices={(data.recording.device_info ?? []) as DeviceInfo[]}
connectedDevices={devices}
onConfirm={handleMappingConfirm}
onCancel={handleMappingCancel}
/>
{/if}

View File

@@ -0,0 +1,60 @@
import type { PageServerLoad } from "./$types";
import { gql } from "graphql-request";
import { getGraphQLClient } from "$lib/api";
const LEADERBOARD_QUERY = gql`
query Leaderboard($limit: Int, $offset: Int) {
leaderboard(limit: $limit, offset: $offset) {
user_id
display_name
avatar
total_weighted_points
total_raw_points
recordings_count
playbacks_count
achievements_count
rank
}
}
`;
export const load: PageServerLoad = async ({ fetch, url }) => {
try {
const limit = parseInt(url.searchParams.get("limit") || "100");
const offset = parseInt(url.searchParams.get("offset") || "0");
const client = getGraphQLClient(fetch);
const data = await client.request<{
leaderboard: {
user_id: string;
display_name: string | null;
avatar: string | null;
total_weighted_points: number | null;
total_raw_points: number | null;
recordings_count: number | null;
playbacks_count: number | null;
achievements_count: number | null;
rank: number;
}[];
}>(LEADERBOARD_QUERY, { limit, offset });
return {
leaderboard: data.leaderboard || [],
pagination: {
limit,
offset,
hasMore: data.leaderboard?.length === limit,
},
};
} catch (error) {
console.error("Leaderboard load error:", error);
return {
leaderboard: [],
pagination: {
limit: 100,
offset: 0,
hasMore: false,
},
};
}
};

View File

@@ -0,0 +1,188 @@
<script lang="ts">
import { _, locale } from "svelte-i18n";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
function formatPoints(points: number | null | undefined): string {
return Math.round(points ?? 0).toLocaleString($locale || "en");
}
function getMedalEmoji(rank: number): string {
switch (rank) {
case 1:
return "🥇";
case 2:
return "🥈";
case 3:
return "🥉";
default:
return "";
}
}
function getUserInitials(name: string | null | undefined): string {
if (!name) return "?";
const parts = name.split(" ");
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
</script>
<Meta
title={$_("gamification.leaderboard")}
description={$_("gamification.leaderboard_description")}
/>
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">{$_("gamification.leaderboard")}</h1>
<p class="text-sm text-muted-foreground mt-0.5">{$_("gamification.leaderboard_subtitle")}</p>
</div>
</div>
<Card class="bg-card/50 border-border/50">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span class="icon-[ri--trophy-line] w-5 h-5 text-primary"></span>
{$_("gamification.top_players")}
</CardTitle>
</CardHeader>
<CardContent>
{#if data.leaderboard.length === 0}
<div class="text-center py-12 text-muted-foreground">
<span class="icon-[ri--trophy-line] w-12 h-12 mx-auto mb-4 opacity-50 block"></span>
<p>{$_("gamification.no_rankings_yet")}</p>
</div>
{:else}
<div class="space-y-2">
{#each data.leaderboard as entry (entry.user_id)}
<a
href="/users/{entry.user_id}"
class="flex items-center gap-4 p-4 rounded-lg hover:bg-accent/10 transition-colors group"
>
<!-- Rank Badge -->
<div class="flex-shrink-0 w-14 text-center">
{#if entry.rank <= 3}
<span class="text-3xl">{getMedalEmoji(entry.rank)}</span>
{:else}
<span
class="text-xl font-bold text-muted-foreground group-hover:text-foreground transition-colors"
>
#{entry.rank}
</span>
{/if}
</div>
<!-- Avatar -->
<Avatar
class="h-12 w-12 ring-2 ring-accent/20 group-hover:ring-primary/40 transition-all"
>
{#if entry.avatar}
<AvatarImage src={getAssetUrl(entry.avatar, "mini")} alt={entry.display_name} />
{/if}
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground font-semibold"
>
{getUserInitials(entry.display_name)}
</AvatarFallback>
</Avatar>
<!-- User Info -->
<div class="flex-1 min-w-0">
<div class="font-semibold truncate group-hover:text-primary transition-colors">
{entry.display_name || $_("common.anonymous")}
</div>
<div class="text-sm text-muted-foreground flex items-center gap-3">
<span title={$_("gamification.recordings")}>
<span class="icon-[ri--video-line] w-3.5 h-3.5 inline mr-1"></span>
{entry.recordings_count}
</span>
<span title={$_("gamification.plays")}>
<span class="icon-[ri--play-line] w-3.5 h-3.5 inline mr-1"></span>
{entry.playbacks_count}
</span>
<span title={$_("gamification.achievements")}>
<span class="icon-[ri--trophy-line] w-3.5 h-3.5 inline mr-1"></span>
{entry.achievements_count}
</span>
</div>
</div>
<!-- Score -->
<div class="text-right flex-shrink-0">
<div class="text-2xl font-bold text-primary">
{formatPoints(entry.total_weighted_points)}
</div>
<div class="text-xs text-muted-foreground">
{$_("gamification.points")}
</div>
</div>
<!-- Arrow indicator -->
<div class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<span class="icon-[ri--arrow-right-s-line] w-5 h-5 text-muted-foreground"></span>
</div>
</a>
{/each}
</div>
{#if data.pagination.hasMore}
<div class="mt-6 text-center">
<Button
variant="outline"
href="/play/leaderboard?offset={data.pagination.offset +
data.pagination.limit}&limit={data.pagination.limit}"
>
{$_("common.load_more")}
</Button>
</div>
{/if}
{/if}
</CardContent>
</Card>
<!-- Info Card -->
<Card class="mt-6 bg-card/50 border-border/50">
<CardContent class="p-6">
<h3 class="font-semibold mb-2 flex items-center gap-2">
<span class="icon-[ri--information-line] w-4 h-4 text-primary"></span>
{$_("gamification.how_it_works")}
</h3>
<p class="text-sm text-muted-foreground mb-4">
{$_("gamification.how_it_works_description")}
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<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>
<div>
<div class="font-medium">{$_("gamification.earn_by_creating")}</div>
<div class="text-muted-foreground">{$_("gamification.earn_by_creating_desc")}</div>
</div>
</div>
<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>
<div>
<div class="font-medium">{$_("gamification.earn_by_playing")}</div>
<div class="text-muted-foreground">{$_("gamification.earn_by_playing_desc")}</div>
</div>
</div>
<div class="flex items-start gap-2">
<span class="icon-[ri--time-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"></span>
<div>
<div class="font-medium">{$_("gamification.stay_active")}</div>
<div class="text-muted-foreground">{$_("gamification.stay_active_desc")}</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,7 @@
import { getRecordings } from "$lib/services";
export async function load({ fetch }) {
return {
recordings: await getRecordings(fetch).catch(() => []),
};
}

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { untrack } from "svelte";
import { _ } from "svelte-i18n";
import { goto } from "$app/navigation";
import { toast } from "svelte-sonner";
import { deleteRecording, updateRecording } from "$lib/services";
import { Button } from "$lib/components/ui/button";
import * as Empty from "$lib/components/ui/empty";
import * as Dialog from "$lib/components/ui/dialog";
import RecordingCard from "$lib/components/recording-card/recording-card.svelte";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
let recordings = $state(untrack(() => data.recordings));
let deleteTarget = $state<string | null>(null);
let deleteOpen = $state(false);
let deleting = $state(false);
function handleDeleteRecording(id: string) {
deleteTarget = id;
deleteOpen = true;
}
async function confirmDeleteRecording() {
if (!deleteTarget) return;
deleting = true;
try {
await deleteRecording(deleteTarget);
recordings = recordings.filter((r) => r.id !== deleteTarget);
toast.success($_("me.recordings.delete_success"));
deleteOpen = false;
deleteTarget = null;
} catch {
toast.error($_("me.recordings.delete_error"));
} finally {
deleting = false;
}
}
async function handlePublishRecording(id: string) {
try {
await updateRecording(id, { status: "published" });
recordings = recordings.map((r) => (r.id === id ? { ...r, status: "published" } : r));
toast.success($_("me.recordings.publish_success"));
} catch {
toast.error($_("me.recordings.publish_error"));
}
}
async function handleUnpublishRecording(id: string) {
try {
await updateRecording(id, { status: "draft" });
recordings = recordings.map((r) => (r.id === id ? { ...r, status: "draft" } : r));
toast.success($_("me.recordings.unpublish_success"));
} catch {
toast.error($_("me.recordings.unpublish_error"));
}
}
function handlePlayRecording(id: string) {
goto(`/play/buttplug?recording=${id}`);
}
</script>
<Meta title={$_("me.recordings.title")} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{$_("me.recordings.title")}</h1>
</div>
{#if recordings.length === 0}
<Empty.Root>
<Empty.Header>
<Empty.Media>
<span class="icon-[ri--play-list-2-line] w-8 h-8"></span>
</Empty.Media>
<Empty.Title>{$_("me.recordings.no_recordings")}</Empty.Title>
<Empty.Description>{$_("me.recordings.no_recordings_description")}</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button
href="/play/buttplug"
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
{$_("me.recordings.go_to_play")}
</Button>
</Empty.Content>
</Empty.Root>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each recordings as recording (recording.id)}
<RecordingCard
{recording}
onPlay={handlePlayRecording}
onPublish={handlePublishRecording}
onUnpublish={handleUnpublishRecording}
onDelete={handleDeleteRecording}
/>
{/each}
</div>
{/if}
</div>
<Dialog.Root bind:open={deleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$_("me.recordings.delete_confirm")}</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (deleteOpen = false)}>
{$_("common.cancel")}
</Button>
<Button variant="destructive" disabled={deleting} onclick={confirmDeleteRecording}>
{deleting ? "Deleting…" : $_("common.delete")}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -131,7 +131,7 @@
<span class="icon-[ri--trophy-line] w-6 h-6 text-primary"></span>
{$_("gamification.stats")}
</h2>
<Button variant="outline" size="sm" href="/leaderboard">
<Button variant="outline" size="sm" href="/play/leaderboard">
<span class="icon-[ri--bar-chart-line] w-4 h-4 mr-2"></span>
{$_("gamification.leaderboard")}
</Button>