Compare commits
32 Commits
5bef996dbc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 52aa00dd13 | |||
| 8085b40af8 | |||
| 5f40a812d3 | |||
| 1b724e86c9 | |||
| a9e4ed6049 | |||
| 66179d7ba8 | |||
| 3a8fa7d8ce | |||
| fddc3f15d0 | |||
| d9a60f0572 | |||
| ba648c796a | |||
| 27e2ff5f66 | |||
| b7a29c55b3 | |||
| 99b2ed7f2b | |||
| 8357aecf98 | |||
| ab3d9f4118 | |||
| 5219fae36a | |||
| 7de1bf7a03 | |||
| a4fd1ff18b | |||
| 6605980a43 | |||
| 15d9708072 | |||
| 89c4c390fa | |||
| f5ff59b910 | |||
| fc97c1b84b | |||
| e2abb0794a | |||
| 2644e033b4 | |||
| ee1cea6d01 | |||
| 1496399b96 | |||
| 075f64f4e3 | |||
| 8c6c98d612 | |||
| 28be084781 | |||
| 21b8d2c223 | |||
| b315062d43 |
@@ -8,6 +8,7 @@ import {
|
|||||||
pgEnum,
|
pgEnum,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
import { recordings } from "./recordings";
|
import { recordings } from "./recordings";
|
||||||
|
|
||||||
@@ -68,6 +69,11 @@ export const user_points = pgTable(
|
|||||||
(t) => [
|
(t) => [
|
||||||
index("user_points_user_idx").on(t.user_id),
|
index("user_points_user_idx").on(t.user_id),
|
||||||
index("user_points_date_idx").on(t.date_created),
|
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`,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { builder } from "../builder";
|
|||||||
import { CommentType, AdminCommentListType } from "../types/index";
|
import { CommentType, AdminCommentListType } from "../types/index";
|
||||||
import { comments, users } from "../../db/schema/index";
|
import { comments, users } from "../../db/schema/index";
|
||||||
import { eq, and, desc, ilike, count } from "drizzle-orm";
|
import { eq, and, desc, ilike, count } from "drizzle-orm";
|
||||||
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
|
||||||
import { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl";
|
import { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl";
|
||||||
|
import { gamificationQueue } from "../../queues/index";
|
||||||
|
|
||||||
builder.queryField("commentsForVideo", (t) =>
|
builder.queryField("commentsForVideo", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
@@ -59,10 +59,16 @@ builder.mutationField("createCommentForVideo", (t) =>
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Gamification (non-blocking)
|
await gamificationQueue.add("awardPoints", {
|
||||||
awardPoints(ctx.db, ctx.currentUser.id, "COMMENT_CREATE")
|
job: "awardPoints",
|
||||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "social"))
|
userId: ctx.currentUser.id,
|
||||||
.catch((e) => console.error("Gamification error on comment:", e));
|
action: "COMMENT_CREATE",
|
||||||
|
});
|
||||||
|
await gamificationQueue.add("checkAchievements", {
|
||||||
|
job: "checkAchievements",
|
||||||
|
userId: ctx.currentUser.id,
|
||||||
|
category: "social",
|
||||||
|
});
|
||||||
|
|
||||||
const user = await ctx.db
|
const user = await ctx.db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { RecordingType, AdminRecordingListType } from "../types/index";
|
|||||||
import { recordings, recording_plays } from "../../db/schema/index";
|
import { recordings, recording_plays } from "../../db/schema/index";
|
||||||
import { eq, and, desc, ilike, count, type SQL } from "drizzle-orm";
|
import { eq, and, desc, ilike, count, type SQL } from "drizzle-orm";
|
||||||
import { slugify } from "../../lib/slugify";
|
import { slugify } from "../../lib/slugify";
|
||||||
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
|
||||||
import { requireAdmin } from "../../lib/acl";
|
import { requireAdmin } from "../../lib/acl";
|
||||||
|
import { gamificationQueue } from "../../queues/index";
|
||||||
|
|
||||||
builder.queryField("recordings", (t) =>
|
builder.queryField("recordings", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
@@ -122,11 +122,18 @@ builder.mutationField("createRecording", (t) =>
|
|||||||
|
|
||||||
const recording = newRecording[0];
|
const recording = newRecording[0];
|
||||||
|
|
||||||
// Gamification (non-blocking)
|
|
||||||
if (recording.status === "published") {
|
if (recording.status === "published") {
|
||||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id)
|
await gamificationQueue.add("awardPoints", {
|
||||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
|
job: "awardPoints",
|
||||||
.catch((e) => console.error("Gamification error on recording create:", e));
|
userId: ctx.currentUser.id,
|
||||||
|
action: "RECORDING_CREATE",
|
||||||
|
recordingId: recording.id,
|
||||||
|
});
|
||||||
|
await gamificationQueue.add("checkAchievements", {
|
||||||
|
job: "checkAchievements",
|
||||||
|
userId: ctx.currentUser.id,
|
||||||
|
category: "recordings",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return recording;
|
return recording;
|
||||||
@@ -180,15 +187,45 @@ builder.mutationField("updateRecording", (t) =>
|
|||||||
|
|
||||||
const recording = updated[0];
|
const recording = updated[0];
|
||||||
|
|
||||||
// Gamification (non-blocking)
|
|
||||||
if (args.status === "published" && existing[0].status !== "published") {
|
if (args.status === "published" && existing[0].status !== "published") {
|
||||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id)
|
// draft → published: award creation points
|
||||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
|
await gamificationQueue.add("awardPoints", {
|
||||||
.catch((e) => console.error("Gamification error on recording publish:", e));
|
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) {
|
} else if (args.status === "published" && recording.featured && !existing[0].featured) {
|
||||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_FEATURED", recording.id)
|
// newly featured while published: award featured bonus
|
||||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
|
await gamificationQueue.add("awardPoints", {
|
||||||
.catch((e) => console.error("Gamification error on recording feature:", e));
|
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;
|
return recording;
|
||||||
@@ -290,11 +327,18 @@ builder.mutationField("recordRecordingPlay", (t) =>
|
|||||||
})
|
})
|
||||||
.returning({ id: recording_plays.id });
|
.returning({ id: recording_plays.id });
|
||||||
|
|
||||||
// Gamification (non-blocking)
|
|
||||||
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
|
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
|
||||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_PLAY", args.recordingId)
|
await gamificationQueue.add("awardPoints", {
|
||||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "playback"))
|
job: "awardPoints",
|
||||||
.catch((e) => console.error("Gamification error on recording play:", e));
|
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 };
|
return { success: true, play_id: play[0].id };
|
||||||
@@ -329,11 +373,18 @@ builder.mutationField("updateRecordingPlay", (t) =>
|
|||||||
})
|
})
|
||||||
.where(eq(recording_plays.id, args.playId));
|
.where(eq(recording_plays.id, args.playId));
|
||||||
|
|
||||||
// Gamification (non-blocking)
|
|
||||||
if (args.completed && !wasCompleted && ctx.currentUser) {
|
if (args.completed && !wasCompleted && ctx.currentUser) {
|
||||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_COMPLETE", existing[0].recording_id)
|
await gamificationQueue.add("awardPoints", {
|
||||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "playback"))
|
job: "awardPoints",
|
||||||
.catch((e) => console.error("Gamification error on recording complete:", e));
|
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;
|
return true;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { redis } from "./lib/auth";
|
|||||||
import { logger } from "./lib/logger";
|
import { logger } from "./lib/logger";
|
||||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
import { startMailWorker } from "./queues/workers/mail";
|
import { startMailWorker } from "./queues/workers/mail";
|
||||||
|
import { startGamificationWorker } from "./queues/workers/gamification";
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || "4000");
|
const PORT = parseInt(process.env.PORT || "4000");
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
|
||||||
@@ -31,6 +32,7 @@ async function main() {
|
|||||||
|
|
||||||
// Start background workers
|
// Start background workers
|
||||||
startMailWorker();
|
startMailWorker();
|
||||||
|
startGamificationWorker();
|
||||||
logger.info("Queue workers started");
|
logger.info("Queue workers started");
|
||||||
|
|
||||||
const fastify = Fastify({ loggerInstance: logger });
|
const fastify = Fastify({ loggerInstance: logger });
|
||||||
|
|||||||
@@ -28,21 +28,41 @@ export async function awardPoints(
|
|||||||
recordingId?: string,
|
recordingId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const points = POINT_VALUES[action];
|
const points = POINT_VALUES[action];
|
||||||
await db.insert(user_points).values({
|
await db
|
||||||
|
.insert(user_points)
|
||||||
|
.values({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
action,
|
action,
|
||||||
points,
|
points,
|
||||||
recording_id: recordingId || null,
|
recording_id: recordingId || null,
|
||||||
date_created: new Date(),
|
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);
|
await updateUserStats(db, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function calculateWeightedScore(db: DB, userId: string): Promise<number> {
|
export async function calculateWeightedScore(db: DB, userId: string): Promise<number> {
|
||||||
const now = new Date();
|
|
||||||
const result = await db.execute(sql`
|
const result = await db.execute(sql`
|
||||||
SELECT SUM(
|
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
|
) as weighted_score
|
||||||
FROM user_points
|
FROM user_points
|
||||||
WHERE user_id = ${userId}
|
WHERE user_id = ${userId}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,8 +1,25 @@
|
|||||||
import { Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
import { redisConnectionOpts } from "./connection.js";
|
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 });
|
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> = {
|
export const queues: Record<string, Queue> = {
|
||||||
mail: mailQueue,
|
mail: mailQueue,
|
||||||
|
gamification: gamificationQueue,
|
||||||
};
|
};
|
||||||
|
|||||||
52
packages/backend/src/queues/workers/gamification.ts
Normal file
52
packages/backend/src/queues/workers/gamification.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Worker } from "bullmq";
|
import { Worker } from "bullmq";
|
||||||
import { redisConnectionOpts } from "../connection.js";
|
import { redisConnectionOpts } from "../connection.js";
|
||||||
import { sendVerification, sendPasswordReset } from "../../lib/email.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 {
|
export function startMailWorker(): Worker {
|
||||||
const worker = new Worker(
|
const worker = new Worker(
|
||||||
"mail",
|
"mail",
|
||||||
async (job) => {
|
async (job) => {
|
||||||
|
log.info({ jobId: job.id, jobName: job.name }, `Processing mail job`);
|
||||||
switch (job.name) {
|
switch (job.name) {
|
||||||
case "sendVerification":
|
case "sendVerification":
|
||||||
await sendVerification(job.data.email as string, job.data.token as string);
|
await sendVerification(job.data.email as string, job.data.token as string);
|
||||||
@@ -16,12 +20,13 @@ export function startMailWorker(): Worker {
|
|||||||
default:
|
default:
|
||||||
throw new Error(`Unknown mail job: ${job.name}`);
|
throw new Error(`Unknown mail job: ${job.name}`);
|
||||||
}
|
}
|
||||||
|
log.info({ jobId: job.id, jobName: job.name }, `Mail job completed`);
|
||||||
},
|
},
|
||||||
{ connection: redisConnectionOpts },
|
{ connection: redisConnectionOpts },
|
||||||
);
|
);
|
||||||
|
|
||||||
worker.on("failed", (job, err) => {
|
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;
|
return worker;
|
||||||
|
|||||||
@@ -10,23 +10,23 @@
|
|||||||
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
|
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
class={`bg-foreground h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class={`bg-white h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
class={`bg-foreground h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
class={`bg-foreground h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? "translate-x-0 w-12" : ""}`}
|
class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? "translate-x-0 w-12" : ""}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? "rotate-45" : ""}`}
|
class={`absolute bg-foreground h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? "rotate-45" : ""}`}
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? "-rotate-45" : ""}`}
|
class={`absolute bg-foreground h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? "-rotate-45" : ""}`}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
isMobileMenuOpen = false;
|
isMobileMenuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActiveLink(link: { name: string; href: string }) {
|
function isActiveLink(link: { name?: string; href: string }) {
|
||||||
return (
|
return (
|
||||||
(page.url.pathname === "/" && link === navLinks[0]) ||
|
(page.url.pathname === "/" && link === navLinks[0]) ||
|
||||||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header
|
<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="container mx-auto px-4">
|
||||||
<div class="flex items-center justify-evenly h-16">
|
<div class="flex items-center justify-evenly h-16">
|
||||||
@@ -76,28 +76,14 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Desktop Auth Actions -->
|
<!-- Auth Actions -->
|
||||||
{#if authStatus.authenticated}
|
{#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">
|
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="icon"
|
size="icon"
|
||||||
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "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="/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"}`}
|
|
||||||
href="/play"
|
href="/play"
|
||||||
title={$_("header.play")}
|
title={$_("header.play")}
|
||||||
>
|
>
|
||||||
@@ -112,7 +98,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="icon"
|
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"
|
href="/admin/users"
|
||||||
title="Admin"
|
title="Admin"
|
||||||
>
|
>
|
||||||
@@ -124,7 +110,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/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">
|
<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">
|
<Avatar class="h-7 w-7 ring-2 ring-primary/20">
|
||||||
@@ -138,35 +124,24 @@
|
|||||||
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
|
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</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]}
|
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="link"
|
||||||
size="icon"
|
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}
|
onclick={handleLogout}
|
||||||
title={$_("header.logout")}
|
title={$_("header.logout")}
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
|
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="lg:hidden ml-2">
|
||||||
{: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>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Burger button — mobile/tablet only -->
|
|
||||||
<div class="lg:hidden ml-auto">
|
|
||||||
<BurgerMenuButton
|
<BurgerMenuButton
|
||||||
label={$_("header.navigation")}
|
label={$_("header.navigation")}
|
||||||
bind:isMobileMenuOpen
|
bind:isMobileMenuOpen
|
||||||
@@ -174,6 +149,27 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class={className}
|
class={`rounded-full ring-2 ring-primary/20 ${className}`}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 10240 10240"
|
viewBox="0 0 10240 10240"
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="relative py-12 md:py-20 overflow-hidden">
|
<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="relative container mx-auto px-4 text-center">
|
||||||
<div class="max-w-5xl mx-auto">
|
<div class="max-w-5xl mx-auto">
|
||||||
<h1
|
<h1
|
||||||
|
|||||||
@@ -2,16 +2,18 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
import type { Recording, DeviceInfo } from "$lib/types";
|
import type { Recording, DeviceInfo } from "$lib/types";
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
recording: Recording;
|
recording: Recording;
|
||||||
onPlay?: (id: string) => void;
|
onPlay?: (id: string) => void;
|
||||||
|
onPublish?: (id: string) => void;
|
||||||
|
onUnpublish?: (id: string) => void;
|
||||||
onDelete?: (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 {
|
function formatDuration(ms: number): string {
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
@@ -19,17 +21,6 @@
|
|||||||
const seconds = totalSeconds % 60;
|
const seconds = totalSeconds % 60;
|
||||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusColor(status: string): string {
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
@@ -42,9 +33,14 @@
|
|||||||
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
|
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
|
||||||
{recording.title}
|
{recording.title}
|
||||||
</h3>
|
</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}`)}
|
{$_(`recording_card.status_${recording.status}`)}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{#if recording.description}
|
{#if recording.description}
|
||||||
<p class="text-sm text-muted-foreground line-clamp-2">
|
<p class="text-sm text-muted-foreground line-clamp-2">
|
||||||
@@ -149,12 +145,35 @@
|
|||||||
{$_("recording_card.play")}
|
{$_("recording_card.play")}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/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}
|
{#if onDelete}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => onDelete?.(recording.id)}
|
onclick={() => onDelete?.(recording.id)}
|
||||||
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
|
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
title={$_("common.delete")}
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
|
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
23
packages/frontend/src/lib/components/ui/empty/empty.svelte
Normal file
23
packages/frontend/src/lib/components/ui/empty/empty.svelte
Normal 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>
|
||||||
22
packages/frontend/src/lib/components/ui/empty/index.ts
Normal file
22
packages/frontend/src/lib/components/ui/empty/index.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -91,6 +91,23 @@ export default {
|
|||||||
me: {
|
me: {
|
||||||
title: "Dashboard",
|
title: "Dashboard",
|
||||||
welcome: "Welcome back, {name}",
|
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",
|
view_profile: "View Public Profile",
|
||||||
settings: {
|
settings: {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
@@ -134,6 +151,10 @@ export default {
|
|||||||
delete_confirm: "Are you sure you want to delete this recording?",
|
delete_confirm: "Are you sure you want to delete this recording?",
|
||||||
delete_success: "Recording deleted successfully",
|
delete_success: "Recording deleted successfully",
|
||||||
delete_error: "Failed to delete recording",
|
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: {
|
recording_card: {
|
||||||
@@ -144,6 +165,8 @@ export default {
|
|||||||
status_draft: "Draft",
|
status_draft: "Draft",
|
||||||
status_published: "Published",
|
status_published: "Published",
|
||||||
play: "Play",
|
play: "Play",
|
||||||
|
publish: "Publish",
|
||||||
|
unpublish: "Unpublish",
|
||||||
edit: "Edit",
|
edit: "Edit",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
public: "Public",
|
public: "Public",
|
||||||
@@ -799,11 +822,19 @@ export default {
|
|||||||
questions_email: "support@pivoine.art",
|
questions_email: "support@pivoine.art",
|
||||||
},
|
},
|
||||||
play: {
|
play: {
|
||||||
title: "SexyPlay",
|
title: "Play",
|
||||||
description: "Bring your toys.",
|
description: "Bring your toys.",
|
||||||
scan: "Start Scan",
|
scan: "Start Scan",
|
||||||
scanning: "Scanning...",
|
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: {
|
error: {
|
||||||
not_found: "Oops! Page Not Found",
|
not_found: "Oops! Page Not Found",
|
||||||
@@ -905,8 +936,8 @@ export default {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
nav: {
|
nav: {
|
||||||
back_to_site: "← Back to site",
|
back_to_site: "Back to site",
|
||||||
back_mobile: "← Back",
|
back_mobile: "Back",
|
||||||
title: "Admin",
|
title: "Admin",
|
||||||
users: "Users",
|
users: "Users",
|
||||||
videos: "Videos",
|
videos: "Videos",
|
||||||
@@ -928,8 +959,8 @@ export default {
|
|||||||
cover_image: "Cover image",
|
cover_image: "Cover image",
|
||||||
tags: "Tags",
|
tags: "Tags",
|
||||||
publish_date: "Publish date",
|
publish_date: "Publish date",
|
||||||
title_field: "Title *",
|
title_field: "Title",
|
||||||
slug_field: "Slug *",
|
slug_field: "Slug",
|
||||||
title_slug_required: "Title and slug are required",
|
title_slug_required: "Title and slug are required",
|
||||||
image_uploaded: "Image uploaded",
|
image_uploaded: "Image uploaded",
|
||||||
image_upload_failed: "Image upload failed",
|
image_upload_failed: "Image upload failed",
|
||||||
|
|||||||
@@ -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`
|
const DELETE_RECORDING_MUTATION = gql`
|
||||||
mutation DeleteRecording($id: String!) {
|
mutation DeleteRecording($id: String!) {
|
||||||
deleteRecording(id: $id)
|
deleteRecording(id: $id)
|
||||||
|
|||||||
@@ -28,7 +28,9 @@
|
|||||||
|
|
||||||
<div class="bg-background text-foreground min-h-screen">
|
<div class="bg-background text-foreground min-h-screen">
|
||||||
<!-- Advanced Global Plasma Background -->
|
<!-- 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 -->
|
<!-- Large primary blobs -->
|
||||||
<div
|
<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"
|
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"
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { _ } from "svelte-i18n";
|
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([
|
const navLinks = $derived([
|
||||||
{ name: $_("admin.nav.users"), href: "/admin/users", icon: "icon-[ri--team-line]" },
|
{ 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">
|
<div class="flex items-center gap-1 overflow-x-auto py-2 scrollbar-none">
|
||||||
<a
|
<a
|
||||||
href="/"
|
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>
|
</a>
|
||||||
{#each navLinks as link (link.href)}
|
{#each navLinks as link (link.href)}
|
||||||
<a
|
<a
|
||||||
@@ -58,10 +68,33 @@
|
|||||||
<!-- Sidebar (desktop only) -->
|
<!-- Sidebar (desktop only) -->
|
||||||
<aside class="hidden lg:flex w-56 shrink-0 flex-col border-r border-border/40">
|
<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">
|
<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")}
|
{$_("admin.nav.back_to_site")}
|
||||||
</a>
|
</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>
|
</div>
|
||||||
|
|
||||||
<nav class="flex-1 p-3 space-y-1">
|
<nav class="flex-1 p-3 space-y-1">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import * as Dialog from "$lib/components/ui/dialog";
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import type { Article } from "$lib/types";
|
import type { Article } from "$lib/types";
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -64,8 +65,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-3 sm:py-6 sm:pl-6">
|
<Meta title={$_("admin.articles.title")} description={null} />
|
||||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
|
||||||
|
<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>
|
<h1 class="text-2xl font-bold">{$_("admin.articles.title")}</h1>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground"
|
||||||
@@ -81,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- 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
|
<Input
|
||||||
placeholder={$_("admin.articles.search_placeholder")}
|
placeholder={$_("admin.articles.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -201,7 +204,7 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{#if data.total > data.limit}
|
{#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">
|
<span class="text-sm text-muted-foreground">
|
||||||
{$_("admin.users.showing", {
|
{$_("admin.users.showing", {
|
||||||
values: {
|
values: {
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
import { Textarea } from "$lib/components/ui/textarea";
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -93,29 +95,48 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-3 sm:p-6">
|
<Meta title={$_("admin.article_form.edit_title")} description={null} />
|
||||||
<div class="flex items-center gap-4 mb-6">
|
|
||||||
<Button variant="ghost" href="/admin/articles" size="sm">
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
<div class="mb-6">
|
||||||
</Button>
|
<h1 class="text-2xl font-bold">{data.article.title}</h1>
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.article_form.edit_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>
|
||||||
|
|
||||||
<div class="space-y-5 max-w-4xl">
|
<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="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||||
<Input id="title" bind:value={title} />
|
<Input
|
||||||
|
id="title"
|
||||||
|
bind:value={title}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||||
<Input id="slug" bind:value={slug} />
|
<Input
|
||||||
|
id="slug"
|
||||||
|
bind:value={slug}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
||||||
<Textarea id="excerpt" bind:value={excerpt} rows={2} />
|
<Textarea
|
||||||
|
id="excerpt"
|
||||||
|
bind:value={excerpt}
|
||||||
|
rows={2}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Markdown editor with live preview -->
|
<!-- Markdown editor with live preview -->
|
||||||
@@ -140,7 +161,7 @@
|
|||||||
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
||||||
<Textarea
|
<Textarea
|
||||||
bind:value={content}
|
bind:value={content}
|
||||||
class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
|
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
|
<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" : ""}`}
|
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" : ""}`}
|
||||||
@@ -168,11 +189,10 @@
|
|||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Author -->
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.article_form.author")}</Label>
|
<Label>{$_("admin.article_form.author")}</Label>
|
||||||
<Select type="single" bind:value={authorId}>
|
<Select type="single" bind:value={authorId}>
|
||||||
<SelectTrigger class="w-full">
|
<SelectTrigger class="w-full bg-background/50 border-primary/20">
|
||||||
{#if selectedAuthor}
|
{#if selectedAuthor}
|
||||||
{#if selectedAuthor.avatar}
|
{#if selectedAuthor.avatar}
|
||||||
<img
|
<img
|
||||||
@@ -207,7 +227,11 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="category">{$_("admin.article_form.category")}</Label>
|
<Label for="category">{$_("admin.article_form.category")}</Label>
|
||||||
<Input id="category" bind:value={category} />
|
<Input
|
||||||
|
id="category"
|
||||||
|
bind:value={category}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.publish_date")}</Label>
|
<Label>{$_("admin.common.publish_date")}</Label>
|
||||||
@@ -217,7 +241,10 @@
|
|||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.tags")}</Label>
|
<Label>{$_("admin.common.tags")}</Label>
|
||||||
<TagsInput bind:value={tags} />
|
<TagsInput
|
||||||
|
bind:value={tags}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
@@ -225,15 +252,13 @@
|
|||||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<Button
|
<Button
|
||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
disabled={saving}
|
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")}
|
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
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 title = $state("");
|
||||||
let slug = $state("");
|
let slug = $state("");
|
||||||
@@ -75,15 +77,15 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-3 sm:p-6">
|
<Meta title={$_("admin.article_form.new_title")} description={null} />
|
||||||
<div class="flex items-center gap-4 mb-6">
|
|
||||||
<Button variant="ghost" href="/admin/articles" size="sm">
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
<div class="mb-6">
|
||||||
</Button>
|
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.article_form.new_title")}</h1>
|
<h1 class="text-2xl font-bold">{$_("admin.article_form.new_title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-5 max-w-4xl">
|
<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="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||||
@@ -94,6 +96,7 @@
|
|||||||
if (!slug) slug = generateSlug(title);
|
if (!slug) slug = generateSlug(title);
|
||||||
}}
|
}}
|
||||||
placeholder={$_("admin.article_form.title_placeholder")}
|
placeholder={$_("admin.article_form.title_placeholder")}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -102,6 +105,7 @@
|
|||||||
id="slug"
|
id="slug"
|
||||||
bind:value={slug}
|
bind:value={slug}
|
||||||
placeholder={$_("admin.article_form.slug_placeholder")}
|
placeholder={$_("admin.article_form.slug_placeholder")}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,6 +117,7 @@
|
|||||||
bind:value={excerpt}
|
bind:value={excerpt}
|
||||||
placeholder={$_("admin.article_form.excerpt_placeholder")}
|
placeholder={$_("admin.article_form.excerpt_placeholder")}
|
||||||
rows={2}
|
rows={2}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -140,7 +145,7 @@
|
|||||||
<Textarea
|
<Textarea
|
||||||
bind:value={content}
|
bind:value={content}
|
||||||
placeholder={$_("admin.article_form.content_placeholder")}
|
placeholder={$_("admin.article_form.content_placeholder")}
|
||||||
class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
|
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
|
<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" : ""}`}
|
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" : ""}`}
|
||||||
@@ -159,9 +164,9 @@
|
|||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.cover_image")}</Label>
|
<Label>{$_("admin.common.cover_image")}</Label>
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||||
{#if imageId}<p class="text-xs text-green-600 mt-1">
|
{#if imageId}
|
||||||
{$_("admin.common.image_uploaded")} ✓
|
<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")} ✓</p>
|
||||||
</p>{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
@@ -171,6 +176,7 @@
|
|||||||
id="category"
|
id="category"
|
||||||
bind:value={category}
|
bind:value={category}
|
||||||
placeholder={$_("admin.article_form.category_placeholder")}
|
placeholder={$_("admin.article_form.category_placeholder")}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -181,7 +187,10 @@
|
|||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.tags")}</Label>
|
<Label>{$_("admin.common.tags")}</Label>
|
||||||
<TagsInput bind:value={tags} />
|
<TagsInput
|
||||||
|
bind:value={tags}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
@@ -189,15 +198,13 @@
|
|||||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<Button
|
<Button
|
||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
disabled={saving}
|
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")}
|
{saving ? $_("admin.common.creating") : $_("admin.article_form.create")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
const timeAgo = new TimeAgo("en");
|
const timeAgo = new TimeAgo("en");
|
||||||
@@ -53,15 +54,17 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-3 sm:py-6 sm:pl-6">
|
<Meta title={$_("admin.comments.title")} description={null} />
|
||||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
|
||||||
|
<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>
|
<h1 class="text-2xl font-bold">{$_("admin.comments.title")}</h1>
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground"
|
||||||
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
||||||
>
|
>
|
||||||
</div>
|
</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
|
<Input
|
||||||
placeholder={$_("admin.comments.search_placeholder")}
|
placeholder={$_("admin.comments.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -150,7 +153,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.total > data.limit}
|
{#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">
|
<span class="text-sm text-muted-foreground">
|
||||||
{$_("admin.users.showing", {
|
{$_("admin.users.showing", {
|
||||||
values: {
|
values: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
import type { Job } from "$lib/services";
|
import type { Job } from "$lib/services";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -124,13 +125,15 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-3 sm:py-6 sm:pl-6">
|
<Meta title={$_("admin.queues.title")} description={null} />
|
||||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
|
||||||
|
<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>
|
<h1 class="text-2xl font-bold">{$_("admin.queues.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Queue cards -->
|
<!-- 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)}
|
{#each queues as queue (queue.name)}
|
||||||
{@const isSelected = selectedQueue === queue.name}
|
{@const isSelected = selectedQueue === queue.name}
|
||||||
<div
|
<div
|
||||||
@@ -193,10 +196,9 @@
|
|||||||
|
|
||||||
{#if selectedQueue}
|
{#if selectedQueue}
|
||||||
<!-- Status filter tabs -->
|
<!-- 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")}
|
{#each STATUS_FILTERS as f (f.value ?? "all")}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant={selectedStatus === f.value ? "default" : "outline"}
|
variant={selectedStatus === f.value ? "default" : "outline"}
|
||||||
onclick={() => selectStatus(f.value)}
|
onclick={() => selectStatus(f.value)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import * as Dialog from "$lib/components/ui/dialog";
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import type { Recording } from "$lib/types";
|
import type { Recording } from "$lib/types";
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
const timeAgo = new TimeAgo("en");
|
const timeAgo = new TimeAgo("en");
|
||||||
@@ -63,15 +64,17 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-3 sm:py-6 sm:pl-6">
|
<Meta title={$_("admin.recordings.title")} description={null} />
|
||||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
|
||||||
|
<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>
|
<h1 class="text-2xl font-bold">{$_("admin.recordings.title")}</h1>
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground"
|
||||||
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
||||||
>
|
>
|
||||||
</div>
|
</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
|
<Input
|
||||||
placeholder={$_("admin.recordings.search_placeholder")}
|
placeholder={$_("admin.recordings.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -128,10 +131,12 @@
|
|||||||
<td class="px-4 py-3 hidden sm:table-cell">
|
<td class="px-4 py-3 hidden sm:table-cell">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Badge
|
<Badge
|
||||||
variant={recording.status === "published" ? "default" : "outline"}
|
variant="outline"
|
||||||
class={recording.status === "draft" ? "text-muted-foreground" : ""}
|
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>
|
</Badge>
|
||||||
{#if recording.public}
|
{#if recording.public}
|
||||||
<Badge variant="outline" class="text-blue-600 border-blue-500/40 bg-blue-500/10"
|
<Badge variant="outline" class="text-blue-600 border-blue-500/40 bg-blue-500/10"
|
||||||
@@ -174,7 +179,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.total > data.limit}
|
{#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">
|
<span class="text-sm text-muted-foreground">
|
||||||
{$_("admin.users.showing", {
|
{$_("admin.users.showing", {
|
||||||
values: {
|
values: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import { Badge } from "$lib/components/ui/badge";
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import type { User } from "$lib/types";
|
import type { User } from "$lib/types";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -84,8 +85,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-3 sm:py-6 sm:pl-6">
|
<Meta title={$_("admin.users.title")} description={null} />
|
||||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
|
||||||
|
<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>
|
<h1 class="text-2xl font-bold">{$_("admin.users.title")}</h1>
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground"
|
||||||
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
||||||
@@ -93,7 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- 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
|
<Input
|
||||||
placeholder={$_("admin.users.search_placeholder")}
|
placeholder={$_("admin.users.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -225,7 +228,7 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{#if data.total > data.limit}
|
{#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">
|
<span class="text-sm text-muted-foreground">
|
||||||
{$_("admin.users.showing", {
|
{$_("admin.users.showing", {
|
||||||
values: {
|
values: {
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -125,11 +127,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-3 sm:p-6 max-w-2xl">
|
<Meta title={data.user.artist_name || data.user.email} description={null} />
|
||||||
<div class="flex items-center gap-4 mb-6">
|
|
||||||
<Button variant="ghost" href="/admin/users" size="sm">
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
<div class="mb-6">
|
||||||
</Button>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1>
|
<h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
@@ -140,25 +141,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6 max-w-2xl">
|
||||||
<!-- Basic info -->
|
<!-- 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="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="firstName">{$_("admin.user_edit.first_name")}</Label>
|
<Label for="firstName">{$_("admin.user_edit.first_name")}</Label>
|
||||||
<Input id="firstName" bind:value={firstName} />
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
bind:value={firstName}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="lastName">{$_("admin.user_edit.last_name")}</Label>
|
<Label for="lastName">{$_("admin.user_edit.last_name")}</Label>
|
||||||
<Input id="lastName" bind:value={lastName} />
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
bind:value={lastName}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="artistName">{$_("admin.user_edit.artist_name")}</Label>
|
<Label for="artistName">{$_("admin.user_edit.artist_name")}</Label>
|
||||||
<Input id="artistName" bind:value={artistName} />
|
<Input
|
||||||
|
id="artistName"
|
||||||
|
bind:value={artistName}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Avatar -->
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.user_edit.avatar")}</Label>
|
<Label>{$_("admin.user_edit.avatar")}</Label>
|
||||||
{#if avatarId}
|
{#if avatarId}
|
||||||
@@ -168,10 +182,13 @@
|
|||||||
class="h-20 w-20 rounded-full object-cover mb-2"
|
class="h-20 w-20 rounded-full object-cover mb-2"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleAvatarUpload} />
|
<FileDropZone
|
||||||
|
accept="image/*"
|
||||||
|
maxFileSize={10 * MEGABYTE}
|
||||||
|
onUpload={handleAvatarUpload}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Banner -->
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.user_edit.banner")}</Label>
|
<Label>{$_("admin.user_edit.banner")}</Label>
|
||||||
{#if bannerId}
|
{#if bannerId}
|
||||||
@@ -181,10 +198,13 @@
|
|||||||
class="w-full h-24 rounded object-cover mb-2"
|
class="w-full h-24 rounded object-cover mb-2"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} />
|
<FileDropZone
|
||||||
|
accept="image/*"
|
||||||
|
maxFileSize={10 * MEGABYTE}
|
||||||
|
onUpload={handleBannerUpload}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Model photo (used in cards & model page, not for avatar/comments) -->
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.user_edit.model_photo")}</Label>
|
<Label>{$_("admin.user_edit.model_photo")}</Label>
|
||||||
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.model_photo_hint")}</p>
|
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.model_photo_hint")}</p>
|
||||||
@@ -195,10 +215,13 @@
|
|||||||
class="w-full h-48 rounded object-cover mb-2"
|
class="w-full h-48 rounded object-cover mb-2"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload2} />
|
<FileDropZone
|
||||||
|
accept="image/*"
|
||||||
|
maxFileSize={10 * MEGABYTE}
|
||||||
|
onUpload={handlePhotoUpload2}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Admin flag -->
|
|
||||||
<label
|
<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"
|
class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -213,18 +236,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<Button
|
<Button
|
||||||
onclick={handleSave}
|
onclick={handleSave}
|
||||||
disabled={saving}
|
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")}
|
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- Photo gallery -->
|
<!-- Photo gallery card -->
|
||||||
<div class="space-y-3 pt-4 border-t border-border/40">
|
<Card class="bg-card/50 border-primary/20">
|
||||||
|
<CardContent class="space-y-4 pt-6">
|
||||||
<Label>{$_("admin.user_edit.photos")}</Label>
|
<Label>{$_("admin.user_edit.photos")}</Label>
|
||||||
|
|
||||||
{#if data.user.photos && data.user.photos.length > 0}
|
{#if data.user.photos && data.user.photos.length > 0}
|
||||||
@@ -252,6 +276,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import type { Video } from "$lib/types";
|
import type { Video } from "$lib/types";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -61,8 +62,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-3 sm:py-6 sm:pl-6">
|
<Meta title={$_("admin.videos.title")} description={null} />
|
||||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
|
||||||
|
<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>
|
<h1 class="text-2xl font-bold">{$_("admin.videos.title")}</h1>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground"
|
||||||
@@ -78,7 +81,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- 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
|
<Input
|
||||||
placeholder={$_("admin.videos.search_placeholder")}
|
placeholder={$_("admin.videos.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -206,7 +209,7 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{#if data.total > data.limit}
|
{#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">
|
<span class="text-sm text-muted-foreground">
|
||||||
{$_("admin.users.showing", {
|
{$_("admin.users.showing", {
|
||||||
values: {
|
values: {
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
import { Textarea } from "$lib/components/ui/textarea";
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -102,15 +104,20 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-3 sm:p-6 max-w-2xl">
|
<Meta title={$_("admin.video_form.edit_title")} description={null} />
|
||||||
<div class="flex items-center gap-4 mb-6">
|
|
||||||
<Button variant="ghost" href="/admin/videos" size="sm">
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
<div class="mb-6">
|
||||||
</Button>
|
<h1 class="text-2xl font-bold">{data.video.title}</h1>
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.video_form.edit_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>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<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="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||||
@@ -118,11 +125,17 @@
|
|||||||
id="title"
|
id="title"
|
||||||
bind:value={title}
|
bind:value={title}
|
||||||
placeholder={$_("admin.video_form.title_placeholder")}
|
placeholder={$_("admin.video_form.title_placeholder")}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||||
<Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -133,6 +146,7 @@
|
|||||||
bind:value={description}
|
bind:value={description}
|
||||||
placeholder={$_("admin.video_form.description_placeholder")}
|
placeholder={$_("admin.video_form.description_placeholder")}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -165,7 +179,10 @@
|
|||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.tags")}</Label>
|
<Label>{$_("admin.common.tags")}</Label>
|
||||||
<TagsInput bind:value={tags} />
|
<TagsInput
|
||||||
|
bind:value={tags}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -192,7 +209,7 @@
|
|||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.video_form.models")}</Label>
|
<Label>{$_("admin.video_form.models")}</Label>
|
||||||
<Select type="multiple" bind:value={selectedModelIds}>
|
<Select type="multiple" bind:value={selectedModelIds}>
|
||||||
<SelectTrigger class="w-full">
|
<SelectTrigger class="w-full bg-background/50 border-primary/20">
|
||||||
{#if selectedModelIds.length}
|
{#if selectedModelIds.length}
|
||||||
{$_("admin.video_form.models_selected", {
|
{$_("admin.video_form.models_selected", {
|
||||||
values: { count: selectedModelIds.length },
|
values: { count: selectedModelIds.length },
|
||||||
@@ -219,15 +236,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<Button
|
<Button
|
||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
disabled={saving}
|
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")}
|
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
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();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -97,15 +99,15 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-3 sm:p-6 max-w-2xl">
|
<Meta title={$_("admin.video_form.new_title")} description={null} />
|
||||||
<div class="flex items-center gap-4 mb-6">
|
|
||||||
<Button variant="ghost" href="/admin/videos" size="sm">
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
<div class="mb-6">
|
||||||
</Button>
|
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.video_form.new_title")}</h1>
|
<h1 class="text-2xl font-bold">{$_("admin.video_form.new_title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<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="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||||
@@ -116,11 +118,17 @@
|
|||||||
if (!slug) slug = generateSlug(title);
|
if (!slug) slug = generateSlug(title);
|
||||||
}}
|
}}
|
||||||
placeholder={$_("admin.video_form.title_placeholder")}
|
placeholder={$_("admin.video_form.title_placeholder")}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||||
<Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -131,28 +139,32 @@
|
|||||||
bind:value={description}
|
bind:value={description}
|
||||||
placeholder={$_("admin.video_form.description_placeholder")}
|
placeholder={$_("admin.video_form.description_placeholder")}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.cover_image")}</Label>
|
<Label>{$_("admin.common.cover_image")}</Label>
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||||
{#if imageId}<p class="text-xs text-green-600 mt-1">
|
{#if imageId}
|
||||||
{$_("admin.common.image_uploaded")} ✓
|
<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")} ✓</p>
|
||||||
</p>{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.video_form.video_file")}</Label>
|
<Label>{$_("admin.video_form.video_file")}</Label>
|
||||||
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||||
{#if movieId}<p class="text-xs text-green-600 mt-1">
|
{#if movieId}
|
||||||
{$_("admin.video_form.video_uploaded")} ✓
|
<p class="text-xs text-green-600 mt-1">{$_("admin.video_form.video_uploaded")} ✓</p>
|
||||||
</p>{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.tags")}</Label>
|
<Label>{$_("admin.common.tags")}</Label>
|
||||||
<TagsInput bind:value={tags} />
|
<TagsInput
|
||||||
|
bind:value={tags}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -177,7 +189,7 @@
|
|||||||
|
|
||||||
{#if data.models.length > 0}
|
{#if data.models.length > 0}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>Models</Label>
|
<Label>{$_("admin.video_form.models")}</Label>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each data.models as model (model.id)}
|
{#each data.models as model (model.id)}
|
||||||
<Button
|
<Button
|
||||||
@@ -197,15 +209,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<Button
|
<Button
|
||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
disabled={saving}
|
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")}
|
{saving ? $_("admin.common.creating") : $_("admin.video_form.create")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,66 +1,5 @@
|
|||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import type { PageServerLoad } from "./$types";
|
|
||||||
import { gql } from "graphql-request";
|
|
||||||
import { getGraphQLClient } from "$lib/api";
|
|
||||||
|
|
||||||
const LEADERBOARD_QUERY = gql`
|
export function load() {
|
||||||
query Leaderboard($limit: Int, $offset: Int) {
|
throw redirect(301, "/play/leaderboard");
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
12
packages/frontend/src/routes/me/+layout.server.ts
Normal file
12
packages/frontend/src/routes/me/+layout.server.ts
Normal 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!),
|
||||||
|
};
|
||||||
|
}
|
||||||
112
packages/frontend/src/routes/me/+layout.svelte
Normal file
112
packages/frontend/src/routes/me/+layout.svelte
Normal 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>
|
||||||
@@ -1,25 +1,4 @@
|
|||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import { getAnalytics, getFolders, getRecordings } from "$lib/services";
|
export function load() {
|
||||||
import { isModel } from "$lib/api";
|
throw redirect(302, "/me/profile");
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
12
packages/frontend/src/routes/me/analytics/+page.server.ts
Normal file
12
packages/frontend/src/routes/me/analytics/+page.server.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
138
packages/frontend/src/routes/me/analytics/+page.svelte
Normal file
138
packages/frontend/src/routes/me/analytics/+page.svelte
Normal 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>
|
||||||
284
packages/frontend/src/routes/me/profile/+page.svelte
Normal file
284
packages/frontend/src/routes/me/profile/+page.svelte
Normal 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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
throw redirect(301, "/play/recordings");
|
||||||
|
}
|
||||||
122
packages/frontend/src/routes/me/recordings/+page.svelte
Normal file
122
packages/frontend/src/routes/me/recordings/+page.svelte
Normal 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>
|
||||||
163
packages/frontend/src/routes/me/security/+page.svelte
Normal file
163
packages/frontend/src/routes/me/security/+page.svelte
Normal 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>
|
||||||
8
packages/frontend/src/routes/play/+layout.server.ts
Normal file
8
packages/frontend/src/routes/play/+layout.server.ts
Normal 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 };
|
||||||
|
}
|
||||||
123
packages/frontend/src/routes/play/+layout.svelte
Normal file
123
packages/frontend/src/routes/play/+layout.svelte
Normal 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>
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
import { getRecording } from "$lib/services";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import type { Recording } from "$lib/types";
|
|
||||||
|
|
||||||
export async function load({ locals, url, fetch }) {
|
export function load() {
|
||||||
const recordingId = url.searchParams.get("recording");
|
throw redirect(302, "/play/buttplug");
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
19
packages/frontend/src/routes/play/buttplug/+page.server.ts
Normal file
19
packages/frontend/src/routes/play/buttplug/+page.server.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,13 +4,13 @@
|
|||||||
import type * as ButtplugTypes from "@sexy.pivoine.art/buttplug";
|
import type * as ButtplugTypes from "@sexy.pivoine.art/buttplug";
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
import Button from "$lib/components/ui/button/button.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import DeviceCard from "$lib/components/device-card/device-card.svelte";
|
import DeviceCard from "$lib/components/device-card/device-card.svelte";
|
||||||
import RecordingSaveDialog from "./components/recording-save-dialog.svelte";
|
import RecordingSaveDialog from "../components/recording-save-dialog.svelte";
|
||||||
import DeviceMappingDialog from "./components/device-mapping-dialog.svelte";
|
import DeviceMappingDialog from "../components/device-mapping-dialog.svelte";
|
||||||
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
|
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
|
||||||
import { toast } from "svelte-sonner";
|
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
|
// Runtime buttplug values — loaded dynamically from the buttplug nginx container
|
||||||
let client: ButtplugTypes.ButtplugClient;
|
let client: ButtplugTypes.ButtplugClient;
|
||||||
@@ -40,7 +40,6 @@
|
|||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const connector = new ButtplugWasmClientConnector();
|
const connector = new ButtplugWasmClientConnector();
|
||||||
// await ButtplugWasmClientConnector.activateLogging("info");
|
|
||||||
await client.connect(connector);
|
await client.connect(connector);
|
||||||
client.on("deviceadded", onDeviceAdded);
|
client.on("deviceadded", onDeviceAdded);
|
||||||
client.on("deviceremoved", (dev: ButtplugTypes.ButtplugClientDevice) => {
|
client.on("deviceremoved", (dev: ButtplugTypes.ButtplugClientDevice) => {
|
||||||
@@ -61,7 +60,6 @@
|
|||||||
const device = convertDevice(dev);
|
const device = convertDevice(dev);
|
||||||
devices.push(device);
|
devices.push(device);
|
||||||
|
|
||||||
// Try to read battery level — access through the reactive array so Svelte detects the mutation
|
|
||||||
const idx = devices.length - 1;
|
const idx = devices.length - 1;
|
||||||
if (device.hasBattery) {
|
if (device.hasBattery) {
|
||||||
try {
|
try {
|
||||||
@@ -94,16 +92,13 @@
|
|||||||
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
|
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
|
||||||
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
|
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
|
||||||
|
|
||||||
// Capture event if recording
|
|
||||||
if (isRecording && recordingStartTime) {
|
if (isRecording && recordingStartTime) {
|
||||||
captureEvent(device, actuatorIdx, value);
|
captureEvent(device, actuatorIdx, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRecording() {
|
function startRecording() {
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
isRecording = true;
|
isRecording = true;
|
||||||
recordingStartTime = performance.now();
|
recordingStartTime = performance.now();
|
||||||
recordedEvents = [];
|
recordedEvents = [];
|
||||||
@@ -130,7 +125,7 @@
|
|||||||
device_name: device.name,
|
device_name: device.name,
|
||||||
actuator_index: actuatorIdx,
|
actuator_index: actuatorIdx,
|
||||||
actuator_type: actuator.outputType,
|
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) => ({
|
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
|
||||||
name: d.name,
|
name: d.name,
|
||||||
index: d.info.index,
|
index: d.info.index,
|
||||||
@@ -173,33 +172,20 @@
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/sexy/recordings", {
|
await createRecording({
|
||||||
method: "POST",
|
title: saveData.title,
|
||||||
headers: {
|
description: saveData.description,
|
||||||
"Content-Type": "application/json",
|
duration: Math.round(recordingDuration),
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: data.title,
|
|
||||||
description: data.description,
|
|
||||||
duration: recordingDuration,
|
|
||||||
events: recordedEvents,
|
events: recordedEvents,
|
||||||
device_info: deviceInfo,
|
device_info: deviceInfo,
|
||||||
tags: data.tags,
|
tags: saveData.tags,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Recording saved successfully!");
|
toast.success("Recording saved successfully!");
|
||||||
showSaveDialog = false;
|
showSaveDialog = false;
|
||||||
recordedEvents = [];
|
recordedEvents = [];
|
||||||
recordingDuration = 0;
|
recordingDuration = 0;
|
||||||
|
|
||||||
// Optionally navigate to dashboard
|
|
||||||
// goto("/me?tab=recordings");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save recording:", error);
|
console.error("Failed to save recording:", error);
|
||||||
toast.error("Failed to save recording. Please try again.");
|
toast.error("Failed to save recording. Please try again.");
|
||||||
@@ -212,24 +198,19 @@
|
|||||||
recordingDuration = 0;
|
recordingDuration = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playback functions
|
|
||||||
function startPlayback() {
|
function startPlayback() {
|
||||||
if (!data.recording) {
|
if (!data.recording) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0) {
|
||||||
toast.error("Please connect devices before playing recording");
|
toast.error("Please connect devices before playing recording");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we need to map devices
|
|
||||||
if (deviceMappings.size === 0 && (data.recording.device_info?.length ?? 0) > 0) {
|
if (deviceMappings.size === 0 && (data.recording.device_info?.length ?? 0) > 0) {
|
||||||
showMappingDialog = true;
|
showMappingDialog = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start playback with existing mappings
|
|
||||||
beginPlayback();
|
beginPlayback();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,8 +240,6 @@
|
|||||||
}
|
}
|
||||||
playbackProgress = 0;
|
playbackProgress = 0;
|
||||||
currentEventIndex = 0;
|
currentEventIndex = 0;
|
||||||
|
|
||||||
// Stop all devices
|
|
||||||
devices.forEach((device) => handleStop(device));
|
devices.forEach((device) => handleStop(device));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +253,6 @@
|
|||||||
|
|
||||||
function resumePlayback() {
|
function resumePlayback() {
|
||||||
if (!data.recording) return;
|
if (!data.recording) return;
|
||||||
|
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
playbackStartTime = performance.now() - playbackProgress;
|
playbackStartTime = performance.now() - playbackProgress;
|
||||||
scheduleNextEvent();
|
scheduleNextEvent();
|
||||||
@@ -295,12 +273,10 @@
|
|||||||
const delay = event.timestamp - currentTime;
|
const delay = event.timestamp - currentTime;
|
||||||
|
|
||||||
if (delay <= 0) {
|
if (delay <= 0) {
|
||||||
// Execute event immediately
|
|
||||||
executeEvent(event);
|
executeEvent(event);
|
||||||
currentEventIndex++;
|
currentEventIndex++;
|
||||||
scheduleNextEvent();
|
scheduleNextEvent();
|
||||||
} else {
|
} else {
|
||||||
// Schedule event
|
|
||||||
playbackTimeoutId = setTimeout(() => {
|
playbackTimeoutId = setTimeout(() => {
|
||||||
executeEvent(event);
|
executeEvent(event);
|
||||||
currentEventIndex++;
|
currentEventIndex++;
|
||||||
@@ -311,31 +287,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function executeEvent(event: RecordedEvent) {
|
function executeEvent(event: RecordedEvent) {
|
||||||
// Get mapped device
|
|
||||||
const device = deviceMappings.get(event.device_name);
|
const device = deviceMappings.get(event.device_name);
|
||||||
if (!device) {
|
if (!device) {
|
||||||
console.warn(`No device mapping for: ${event.device_name}`);
|
console.warn(`No device mapping for: ${event.device_name}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find matching actuator by type
|
|
||||||
const actuator = device.actuators.find((a) => a.outputType === event.actuator_type);
|
const actuator = device.actuators.find((a) => a.outputType === event.actuator_type);
|
||||||
if (!actuator) {
|
if (!actuator) {
|
||||||
console.warn(`Actuator type ${event.actuator_type} not found on ${device.name}`);
|
console.warn(`Actuator type ${event.actuator_type} not found on ${device.name}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert normalized value (0-100) back to device scale
|
|
||||||
const deviceValue = Math.round((event.value / 100) * actuator.maxSteps);
|
const deviceValue = Math.round((event.value / 100) * actuator.maxSteps);
|
||||||
|
|
||||||
// Send command to device via feature
|
|
||||||
const feature = device.info.features.get(actuator.featureIndex);
|
const feature = device.info.features.get(actuator.featureIndex);
|
||||||
if (feature) {
|
if (feature) {
|
||||||
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
|
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
|
||||||
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
|
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update UI
|
|
||||||
actuator.value = deviceValue;
|
actuator.value = deviceValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +315,6 @@
|
|||||||
const targetTime = (percentage / 100) * data.recording.duration;
|
const targetTime = (percentage / 100) * data.recording.duration;
|
||||||
playbackProgress = targetTime;
|
playbackProgress = targetTime;
|
||||||
|
|
||||||
// Find the event index at this time
|
|
||||||
const seekEvents = (data.recording.events ?? []) as RecordedEvent[];
|
const seekEvents = (data.recording.events ?? []) as RecordedEvent[];
|
||||||
currentEventIndex = seekEvents.findIndex((e) => e.timestamp >= targetTime);
|
currentEventIndex = seekEvents.findIndex((e) => e.timestamp >= targetTime);
|
||||||
if (currentEventIndex === -1) {
|
if (currentEventIndex === -1) {
|
||||||
@@ -364,10 +333,6 @@
|
|||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!data.authStatus.authenticated) {
|
|
||||||
goto("/login");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Concatenation prevents Rollup from statically resolving this URL at build time
|
// Concatenation prevents Rollup from statically resolving this URL at build time
|
||||||
const buttplugUrl = "/buttplug/" + "dist/index.js";
|
const buttplugUrl = "/buttplug/" + "dist/index.js";
|
||||||
const bp = await import(/* @vite-ignore */ buttplugUrl);
|
const bp = await import(/* @vite-ignore */ buttplugUrl);
|
||||||
@@ -382,90 +347,41 @@
|
|||||||
|
|
||||||
<Meta title={$_("play.title")} description={$_("play.description")} />
|
<Meta title={$_("play.title")} description={$_("play.description")} />
|
||||||
|
|
||||||
<div
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
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 py-20 relative px-4">
|
|
||||||
<div class="max-w-4xl mx-auto">
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="text-center mb-12">
|
<div class="mb-6">
|
||||||
<h1
|
<h1 class="text-2xl font-bold">{$_("play.title")}</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"
|
|
||||||
>
|
|
||||||
{$_("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>
|
||||||
<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>
|
|
||||||
|
|
||||||
|
<!-- Recording controls (only when devices are connected) -->
|
||||||
{#if devices.length > 0 && !data.recording}
|
{#if devices.length > 0 && !data.recording}
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mb-6">
|
||||||
{#if !isRecording}
|
{#if !isRecording}
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={startRecording}
|
onclick={startRecording}
|
||||||
class="cursor-pointer border-primary/30 hover:bg-primary/10"
|
class="cursor-pointer border-primary/30 hover:bg-primary/10"
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--record-circle-line] w-5 h-5 mr-2"></span>
|
<span class="icon-[ri--record-circle-line] w-4 h-4 mr-2"></span>
|
||||||
Start Recording
|
Start Recording
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
|
||||||
onclick={stopRecording}
|
onclick={stopRecording}
|
||||||
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white"
|
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>
|
<span class="icon-[ri--stop-circle-fill] w-4 h-4 mr-2 animate-pulse"></span>
|
||||||
Stop Recording ({recordedEvents.length} events)
|
Stop Recording ({recordedEvents.length} events)
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Playback Controls (only shown when recording is loaded) -->
|
<!-- Playback Controls (only shown when recording is loaded) -->
|
||||||
{#if data.recording}
|
{#if data.recording}
|
||||||
<div class="bg-card/50 border border-primary/20 rounded-lg p-6 backdrop-blur-sm">
|
<div class="bg-card/50 border border-primary/20 rounded-lg p-6 mb-6">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h2 class="text-xl font-semibold text-card-foreground mb-2">
|
<h2 class="text-xl font-semibold text-card-foreground mb-1">
|
||||||
{data.recording.title}
|
{data.recording.title}
|
||||||
</h2>
|
</h2>
|
||||||
{#if data.recording.description}
|
{#if data.recording.description}
|
||||||
@@ -479,9 +395,7 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
<span class="text-sm text-muted-foreground min-w-[50px]">
|
<span class="text-sm text-muted-foreground min-w-[50px]">
|
||||||
{Math.floor(playbackProgress / 1000 / 60)}:{(
|
{Math.floor(playbackProgress / 1000 / 60)}:{(Math.floor(playbackProgress / 1000) % 60)
|
||||||
Math.floor(playbackProgress / 1000) % 60
|
|
||||||
)
|
|
||||||
.toString()
|
.toString()
|
||||||
.padStart(2, "0")}
|
.padStart(2, "0")}
|
||||||
</span>
|
</span>
|
||||||
@@ -523,7 +437,6 @@
|
|||||||
<!-- Playback Buttons -->
|
<!-- Playback Buttons -->
|
||||||
<div class="flex gap-2 justify-center">
|
<div class="flex gap-2 justify-center">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={stopPlayback}
|
onclick={stopPlayback}
|
||||||
disabled={!isPlaying && playbackProgress === 0}
|
disabled={!isPlaying && playbackProgress === 0}
|
||||||
@@ -533,7 +446,6 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{#if !isPlaying}
|
{#if !isPlaying}
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
|
||||||
onclick={playbackProgress > 0 ? resumePlayback : startPlayback}
|
onclick={playbackProgress > 0 ? resumePlayback : startPlayback}
|
||||||
disabled={devices.length === 0}
|
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]"
|
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
|
||||||
@@ -543,7 +455,6 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
|
||||||
onclick={pausePlayback}
|
onclick={pausePlayback}
|
||||||
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
|
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
|
||||||
>
|
>
|
||||||
@@ -570,11 +481,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
<!-- Devices grid or empty state -->
|
||||||
<div class="container mx-auto px-4 py-12">
|
{#if devices.length > 0}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{#if devices}
|
|
||||||
{#each devices as device (device.name)}
|
{#each devices as device (device.name)}
|
||||||
<DeviceCard
|
<DeviceCard
|
||||||
{device}
|
{device}
|
||||||
@@ -582,15 +492,34 @@
|
|||||||
onStop={() => handleStop(device)}
|
onStop={() => handleStop(device)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/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}
|
{/if}
|
||||||
</div>
|
</Button>
|
||||||
|
</Empty.Content>
|
||||||
{#if devices?.length === 0}
|
</Empty.Root>
|
||||||
<div class="text-center py-12">
|
|
||||||
<p class="text-muted-foreground text-lg mb-4">
|
|
||||||
{$_("play.no_results")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -618,4 +547,3 @@
|
|||||||
onCancel={handleMappingCancel}
|
onCancel={handleMappingCancel}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
188
packages/frontend/src/routes/play/leaderboard/+page.svelte
Normal file
188
packages/frontend/src/routes/play/leaderboard/+page.svelte
Normal 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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { getRecordings } from "$lib/services";
|
||||||
|
|
||||||
|
export async function load({ fetch }) {
|
||||||
|
return {
|
||||||
|
recordings: await getRecordings(fetch).catch(() => []),
|
||||||
|
};
|
||||||
|
}
|
||||||
122
packages/frontend/src/routes/play/recordings/+page.svelte
Normal file
122
packages/frontend/src/routes/play/recordings/+page.svelte
Normal 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>
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
<span class="icon-[ri--trophy-line] w-6 h-6 text-primary"></span>
|
<span class="icon-[ri--trophy-line] w-6 h-6 text-primary"></span>
|
||||||
{$_("gamification.stats")}
|
{$_("gamification.stats")}
|
||||||
</h2>
|
</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>
|
<span class="icon-[ri--bar-chart-line] w-4 h-4 mr-2"></span>
|
||||||
{$_("gamification.leaderboard")}
|
{$_("gamification.leaderboard")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user