Compare commits

..

1 Commits

Author SHA1 Message Date
24f53983d7 fix: switch buttplug WASM to --target web for browser compatibility
Some checks failed
Build and Push Backend Image / build (push) Successful in 47s
Build and Push Buttplug Image / build (push) Has been cancelled
Build and Push Frontend Image / build (push) Has been cancelled
--target bundler generates static WASM ESM imports that only work
through a bundler (vite-plugin-wasm). --target web generates fetch-based
WASM loading via import.meta.url which browsers handle natively.

- Change wasm-pack build target from bundler to web
- Call wasmModule.default() (init) after import in maybeLoadWasm
- Restore rollupOptions.external so WASM stays a separate fetch
- Removes the need for vite-plugin-wasm in any consumer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 14:07:14 +01:00
86 changed files with 5057 additions and 3812 deletions

View File

@@ -7,17 +7,9 @@ on:
- develop
tags:
- "v*.*.*"
paths:
- "packages/backend/**"
- "packages/types/**"
- "Dockerfile.backend"
pull_request:
branches:
- main
paths:
- "packages/backend/**"
- "packages/types/**"
- "Dockerfile.backend"
workflow_dispatch:
env:

View File

@@ -7,17 +7,9 @@ on:
- develop
tags:
- "v*.*.*"
paths:
- "packages/frontend/**"
- "packages/types/**"
- "Dockerfile"
pull_request:
branches:
- main
paths:
- "packages/frontend/**"
- "packages/types/**"
- "Dockerfile"
workflow_dispatch:
env:

View File

@@ -3,7 +3,7 @@
# ============================================================================
# Base stage - shared dependencies
# ============================================================================
FROM node:22.14.0-slim AS base
FROM node:22.11.0-slim AS base
# Enable corepack for pnpm
RUN npm install -g corepack@latest && corepack enable
@@ -32,9 +32,6 @@ COPY packages ./packages
# Install all dependencies
RUN pnpm install --frozen-lockfile
# Generate SvelteKit type definitions (creates .svelte-kit/tsconfig.json)
RUN pnpm --filter @sexy.pivoine.art/frontend exec svelte-kit sync
# Build frontend
RUN pnpm --filter @sexy.pivoine.art/frontend build
@@ -44,7 +41,7 @@ RUN CI=true pnpm install -rP
# ============================================================================
# Runner stage - minimal production image
# ============================================================================
FROM node:22.14.0-slim AS runner
FROM node:22.11.0-slim AS runner
# Install dumb-init for proper signal handling
RUN apt-get update && apt-get install -y \

View File

@@ -3,7 +3,7 @@
# ============================================================================
# Builder stage
# ============================================================================
FROM node:22.14.0-slim AS builder
FROM node:22.11.0-slim AS builder
RUN npm install -g corepack@latest && corepack enable
@@ -34,7 +34,7 @@ RUN pnpm rebuild argon2 sharp
# ============================================================================
# Runner stage
# ============================================================================
FROM node:22.14.0-slim AS runner
FROM node:22.11.0-slim AS runner
RUN apt-get update && apt-get install -y \
dumb-init \

View File

@@ -3,7 +3,7 @@
# ============================================================================
# Builder stage - compile Rust/WASM and TypeScript
# ============================================================================
FROM node:22.14.0-slim AS builder
FROM node:22.11.0-slim AS builder
# Install build dependencies for Rust
RUN apt-get update && apt-get install -y \

View File

@@ -5,8 +5,8 @@
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build:frontend": "pnpm --filter @sexy.pivoine.art/frontend build",
"build:backend": "pnpm --filter @sexy.pivoine.art/backend build",
"build:frontend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/frontend build",
"build:backend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/backend build",
"dev:buttplug": "pnpm --filter @sexy.pivoine.art/buttplug serve",
"dev:data": "docker compose up -d postgres redis",
"dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev",
@@ -23,7 +23,7 @@
"email": "valknar@pivoine.art"
},
"license": "MIT",
"packageManager": "pnpm@10.31.0",
"packageManager": "pnpm@10.19.0",
"pnpm": {
"onlyBuiltDependencies": [
"argon2",

View File

@@ -14,15 +14,14 @@
"check": "tsc --noEmit"
},
"dependencies": {
"@sexy.pivoine.art/types": "workspace:*",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^10.0.2",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.1.1",
"@pothos/core": "^4.4.0",
"@pothos/plugin-errors": "^4.2.0",
"@sexy.pivoine.art/types": "workspace:*",
"argon2": "^0.43.0",
"bullmq": "^5.70.4",
"drizzle-orm": "^0.44.1",
"fastify": "^5.4.0",
"fluent-ffmpeg": "^2.1.3",

View File

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

View File

@@ -9,7 +9,6 @@ import "./resolvers/recordings.js";
import "./resolvers/comments.js";
import "./resolvers/gamification.js";
import "./resolvers/stats.js";
import "./resolvers/queues.js";
import { builder } from "./builder";
export const schema = builder.toSchema();

View File

@@ -9,7 +9,7 @@ interface ReplyLike {
}
import { hash, verify as verifyArgon } from "../../lib/argon";
import { setSession, deleteSession } from "../../lib/auth";
import { enqueueVerification, enqueuePasswordReset } from "../../lib/email";
import { sendVerification, sendPasswordReset } from "../../lib/email";
import { slugify } from "../../lib/slugify";
import { nanoid } from "nanoid";
@@ -131,9 +131,9 @@ builder.mutationField("register", (t) =>
});
try {
await enqueueVerification(args.email, verifyToken);
await sendVerification(args.email, verifyToken);
} catch (e) {
console.warn("Failed to enqueue verification email:", (e as Error).message);
console.warn("Failed to send verification email:", (e as Error).message);
}
return true;
},
@@ -190,9 +190,9 @@ builder.mutationField("requestPasswordReset", (t) =>
.where(eq(users.id, user[0].id));
try {
await enqueuePasswordReset(args.email, token);
await sendPasswordReset(args.email, token);
} catch (e) {
console.warn("Failed to enqueue password reset email:", (e as Error).message);
console.warn("Failed to send password reset email:", (e as Error).message);
}
return true;
},

View File

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

View File

@@ -1,151 +0,0 @@
import { GraphQLError } from "graphql";
import type { Job } from "bullmq";
import { builder } from "../builder.js";
import { JobType, QueueInfoType } from "../types/index.js";
import { queues } from "../../queues/index.js";
import { requireAdmin } from "../../lib/acl.js";
const JOB_STATUSES = ["waiting", "active", "completed", "failed", "delayed"] as const;
type JobStatus = (typeof JOB_STATUSES)[number];
async function toJobData(job: Job, queueName: string) {
const status = await job.getState();
return {
id: job.id ?? "",
name: job.name,
queue: queueName,
status,
data: job.data as unknown,
result: job.returnvalue as unknown,
failedReason: job.failedReason ?? null,
attemptsMade: job.attemptsMade,
createdAt: new Date(job.timestamp),
processedAt: job.processedOn ? new Date(job.processedOn) : null,
finishedAt: job.finishedOn ? new Date(job.finishedOn) : null,
progress: typeof job.progress === "number" ? job.progress : null,
};
}
builder.queryField("adminQueues", (t) =>
t.field({
type: [QueueInfoType],
resolve: async (_root, _args, ctx) => {
requireAdmin(ctx);
return Promise.all(
Object.entries(queues).map(async ([name, queue]) => {
const counts = await queue.getJobCounts(
"waiting",
"active",
"completed",
"failed",
"delayed",
"paused",
);
const isPaused = await queue.isPaused();
return {
name,
counts: {
waiting: counts.waiting ?? 0,
active: counts.active ?? 0,
completed: counts.completed ?? 0,
failed: counts.failed ?? 0,
delayed: counts.delayed ?? 0,
paused: counts.paused ?? 0,
},
isPaused,
};
}),
);
},
}),
);
builder.queryField("adminQueueJobs", (t) =>
t.field({
type: [JobType],
args: {
queue: t.arg.string({ required: true }),
status: t.arg.string(),
limit: t.arg.int(),
offset: t.arg.int(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
const limit = args.limit ?? 25;
const offset = args.offset ?? 0;
const statuses: JobStatus[] = args.status ? [args.status as JobStatus] : [...JOB_STATUSES];
const jobs = await queue.getJobs(statuses, offset, offset + limit - 1);
return Promise.all(jobs.map((job) => toJobData(job, args.queue)));
},
}),
);
builder.mutationField("adminRetryJob", (t) =>
t.field({
type: "Boolean",
args: {
queue: t.arg.string({ required: true }),
jobId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
const job = await queue.getJob(args.jobId);
if (!job) throw new GraphQLError(`Job "${args.jobId}" not found`);
await job.retry();
return true;
},
}),
);
builder.mutationField("adminRemoveJob", (t) =>
t.field({
type: "Boolean",
args: {
queue: t.arg.string({ required: true }),
jobId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
const job = await queue.getJob(args.jobId);
if (!job) throw new GraphQLError(`Job "${args.jobId}" not found`);
await job.remove();
return true;
},
}),
);
builder.mutationField("adminPauseQueue", (t) =>
t.field({
type: "Boolean",
args: { queue: t.arg.string({ required: true }) },
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
await queue.pause();
return true;
},
}),
);
builder.mutationField("adminResumeQueue", (t) =>
t.field({
type: "Boolean",
args: { queue: t.arg.string({ required: true }) },
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
await queue.resume();
return true;
},
}),
);

View File

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

View File

@@ -333,74 +333,6 @@ export const AchievementType = builder.objectRef<Achievement>("Achievement").imp
}),
});
// --- Queue / Job types (admin only, not in shared types package) ---
type JobCounts = {
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
paused: number;
};
type JobData = {
id: string;
name: string;
queue: string;
status: string;
data: unknown;
result: unknown;
failedReason: string | null;
attemptsMade: number;
createdAt: Date;
processedAt: Date | null;
finishedAt: Date | null;
progress: number | null;
};
type QueueInfoData = {
name: string;
counts: JobCounts;
isPaused: boolean;
};
export const JobCountsType = builder.objectRef<JobCounts>("JobCounts").implement({
fields: (t) => ({
waiting: t.exposeInt("waiting"),
active: t.exposeInt("active"),
completed: t.exposeInt("completed"),
failed: t.exposeInt("failed"),
delayed: t.exposeInt("delayed"),
paused: t.exposeInt("paused"),
}),
});
export const JobType = builder.objectRef<JobData>("Job").implement({
fields: (t) => ({
id: t.exposeString("id"),
name: t.exposeString("name"),
queue: t.exposeString("queue"),
status: t.exposeString("status"),
data: t.expose("data", { type: "JSON" }),
result: t.expose("result", { type: "JSON", nullable: true }),
failedReason: t.exposeString("failedReason", { nullable: true }),
attemptsMade: t.exposeInt("attemptsMade"),
createdAt: t.expose("createdAt", { type: "DateTime" }),
processedAt: t.expose("processedAt", { type: "DateTime", nullable: true }),
finishedAt: t.expose("finishedAt", { type: "DateTime", nullable: true }),
progress: t.exposeFloat("progress", { nullable: true }),
}),
});
export const QueueInfoType = builder.objectRef<QueueInfoData>("QueueInfo").implement({
fields: (t) => ({
name: t.exposeString("name"),
counts: t.expose("counts", { type: JobCountsType }),
isPaused: t.exposeBoolean("isPaused"),
}),
});
export const VideoListType = builder
.objectRef<{ items: Video[]; total: number }>("VideoList")
.implement({

View File

@@ -16,8 +16,6 @@ import { db } from "./db/connection";
import { redis } from "./lib/auth";
import { logger } from "./lib/logger";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { startMailWorker } from "./queues/workers/mail";
import { startGamificationWorker } from "./queues/workers/gamification";
const PORT = parseInt(process.env.PORT || "4000");
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
@@ -30,11 +28,6 @@ async function main() {
await migrate(db, { migrationsFolder });
logger.info("Migrations complete");
// Start background workers
startMailWorker();
startGamificationWorker();
logger.info("Queue workers started");
const fastify = Fastify({ loggerInstance: logger });
await fastify.register(fastifyCookie, {

View File

@@ -1,5 +1,4 @@
import nodemailer from "nodemailer";
import { mailQueue } from "../queues/index.js";
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || "localhost",
@@ -33,13 +32,3 @@ export async function sendPasswordReset(email: string, token: string): Promise<v
html: `<p>Click <a href="${BASE_URL}/password/reset?token=${token}">here</a> to reset your password.</p>`,
});
}
const jobOpts = { attempts: 3, backoff: { type: "exponential" as const, delay: 5000 } };
export async function enqueueVerification(email: string, token: string): Promise<void> {
await mailQueue.add("sendVerification", { email, token }, jobOpts);
}
export async function enqueuePasswordReset(email: string, token: string): Promise<void> {
await mailQueue.add("sendPasswordReset", { email, token }, jobOpts);
}

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
function parseRedisUrl(url: string): { host: string; port: number; password?: string } {
const parsed = new URL(url);
return {
host: parsed.hostname,
port: parseInt(parsed.port) || 6379,
password: parsed.password || undefined,
};
}
// BullMQ creates its own IORedis connections from these options.
// maxRetriesPerRequest: null is required for workers.
export const redisConnectionOpts = {
...parseRedisUrl(process.env.REDIS_URL || "redis://localhost:6379"),
maxRetriesPerRequest: null as null,
enableReadyCheck: false,
};

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
node_modules/
dist/
wasm/
target/
pkg/

1394
packages/buttplug/dist/index.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@
"name": "@sexy.pivoine.art/buttplug",
"version": "1.0.0",
"type": "module",
"private": true,
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@@ -23,7 +23,6 @@ type FFICallback = js_sys::Function;
type FFICallbackContext = u32;
#[derive(Clone, Copy)]
#[allow(dead_code)]
pub struct FFICallbackContextWrapper(FFICallbackContext);
unsafe impl Send for FFICallbackContextWrapper {
@@ -51,7 +50,7 @@ pub fn send_server_message(
let buf = json.as_bytes();
let this = JsValue::null();
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
let _ = callback.call1(&this, &JsValue::from(uint8buf));
callback.call1(&this, &JsValue::from(uint8buf));
}
}
@@ -120,7 +119,7 @@ pub fn buttplug_client_send_json_message(
let buf = json.as_bytes();
let this = JsValue::null();
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
let _ = callback.call1(&this, &JsValue::from(uint8buf));
callback.call1(&this, &JsValue::from(uint8buf));
}
});
}

View File

@@ -184,7 +184,6 @@ impl HardwareSpecializer for WebBluetoothHardwareSpecializer {
pub enum WebBluetoothEvent {
// This is the only way we have to get our endpoints back to device creation
// right now. My god this is a mess.
#[allow(dead_code)]
Connected(Vec<Endpoint>),
Disconnected,
}
@@ -202,7 +201,6 @@ pub enum WebBluetoothDeviceCommand {
HardwareSubscribeCmd,
oneshot::Sender<Result<(), ButtplugDeviceError>>,
),
#[allow(dead_code)]
Unsubscribe(
HardwareUnsubscribeCmd,
oneshot::Sender<Result<(), ButtplugDeviceError>>,
@@ -273,7 +271,7 @@ async fn run_webbluetooth_loop(
//let web_btle_device = WebBluetoothDeviceImpl::new(device, char_map);
info!("device created!");
let endpoints = char_map.keys().into_iter().cloned().collect();
let _ = device_local_event_sender
device_local_event_sender
.send(WebBluetoothEvent::Connected(endpoints))
.await;
while let Some(msg) = device_command_receiver.recv().await {
@@ -339,7 +337,6 @@ async fn run_webbluetooth_loop(
#[derive(Debug)]
pub struct WebBluetoothHardware {
device_command_sender: mpsc::Sender<WebBluetoothDeviceCommand>,
#[allow(dead_code)]
device_event_receiver: mpsc::Receiver<WebBluetoothEvent>,
event_sender: broadcast::Sender<HardwareEvent>,
}

1
packages/buttplug/wasm/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*

64
packages/buttplug/wasm/index.d.ts vendored Normal file
View File

@@ -0,0 +1,64 @@
/* tslint:disable */
/* eslint-disable */
export function buttplug_activate_env_logger(_max_level: string): void;
export function buttplug_client_send_json_message(server_ptr: number, buf: Uint8Array, callback: Function): void;
export function buttplug_create_embedded_wasm_server(callback: Function): number;
export function buttplug_free_embedded_wasm_server(ptr: number): void;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly create_test_dcm: (a: number, b: number) => void;
readonly buttplug_activate_env_logger: (a: number, b: number) => void;
readonly buttplug_client_send_json_message: (a: number, b: number, c: number, d: any) => void;
readonly buttplug_create_embedded_wasm_server: (a: any) => number;
readonly buttplug_free_embedded_wasm_server: (a: number) => void;
readonly wasm_bindgen__closure__destroy__h72b504abf7ea70fd: (a: number, b: number) => void;
readonly wasm_bindgen__closure__destroy__ha3c8e2c9b0cf79cd: (a: number, b: number) => void;
readonly wasm_bindgen__closure__destroy__h0f95d90d24796def: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd: (a: number, b: number) => void;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __externref_table_alloc: () => number;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_exn_store: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;

View File

@@ -0,0 +1,814 @@
/* @ts-self-types="./index.d.ts" */
/**
* @param {string} _max_level
*/
export function buttplug_activate_env_logger(_max_level) {
const ptr0 = passStringToWasm0(_max_level, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
wasm.buttplug_activate_env_logger(ptr0, len0);
}
/**
* @param {number} server_ptr
* @param {Uint8Array} buf
* @param {Function} callback
*/
export function buttplug_client_send_json_message(server_ptr, buf, callback) {
const ptr0 = passArray8ToWasm0(buf, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
wasm.buttplug_client_send_json_message(server_ptr, ptr0, len0, callback);
}
/**
* @param {Function} callback
* @returns {number}
*/
export function buttplug_create_embedded_wasm_server(callback) {
const ret = wasm.buttplug_create_embedded_wasm_server(callback);
return ret >>> 0;
}
/**
* @param {number} ptr
*/
export function buttplug_free_embedded_wasm_server(ptr) {
wasm.buttplug_free_embedded_wasm_server(ptr);
}
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbg___wbindgen_debug_string_a1b3fd0656850da8: function(arg0, arg1) {
const ret = debugString(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg___wbindgen_is_function_82aa5b8e9371b250: function(arg0) {
const ret = typeof(arg0) === 'function';
return ret;
},
__wbg___wbindgen_is_object_61452b678ecf7ecf: function(arg0) {
const val = arg0;
const ret = typeof(val) === 'object' && val !== null;
return ret;
},
__wbg___wbindgen_is_string_91960b7ba9d4d76b: function(arg0) {
const ret = typeof(arg0) === 'string';
return ret;
},
__wbg___wbindgen_is_undefined_7b12045c262a3121: function(arg0) {
const ret = arg0 === undefined;
return ret;
},
__wbg___wbindgen_throw_83ebd457a191bc2a: function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
},
__wbg__wbg_cb_unref_4fc42a417bb095f4: function(arg0) {
arg0._wbg_cb_unref();
},
__wbg_bluetooth_5967024a158f671e: function(arg0) {
const ret = arg0.bluetooth;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_buffer_bfae6dde33a7e5a0: function(arg0) {
const ret = arg0.buffer;
return ret;
},
__wbg_byteLength_5fbecf2b9f6cc625: function(arg0) {
const ret = arg0.byteLength;
return ret;
},
__wbg_call_72a54043615c73e3: function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.call(arg1, arg2);
return ret;
}, arguments); },
__wbg_connect_61050e3a8e3f5f1c: function(arg0) {
const ret = arg0.connect();
return ret;
},
__wbg_crypto_38df2bab126b63dc: function(arg0) {
const ret = arg0.crypto;
return ret;
},
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.error(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
},
__wbg_gatt_aeca65a254a75f07: function(arg0) {
const ret = arg0.gatt;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_getCharacteristic_4daa5211272f941c: function(arg0, arg1, arg2) {
const ret = arg0.getCharacteristic(getStringFromWasm0(arg1, arg2));
return ret;
},
__wbg_getPrimaryService_8b6197119664f448: function(arg0, arg1, arg2) {
const ret = arg0.getPrimaryService(getStringFromWasm0(arg1, arg2));
return ret;
},
__wbg_getRandomValues_3f44b700395062e5: function() { return handleError(function (arg0, arg1) {
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
}, arguments); },
__wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) {
arg0.getRandomValues(arg1);
}, arguments); },
__wbg_getRandomValues_ef8a9e8b447216e2: function() { return handleError(function (arg0, arg1) {
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
}, arguments); },
__wbg_get_bda2de250e7f67d3: function() { return handleError(function (arg0, arg1) {
const ret = Reflect.get(arg0, arg1);
return ret;
}, arguments); },
__wbg_id_c3a9ae039584e9f6: function(arg0, arg1) {
const ret = arg1.id;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg_instanceof_Window_3bc43738919f4587: function(arg0) {
let result;
try {
result = arg0 instanceof Window;
} catch (_) {
result = false;
}
const ret = result;
return ret;
},
__wbg_length_684e7f4ac265724c: function(arg0) {
const ret = arg0.length;
return ret;
},
__wbg_log_0c201ade58bb55e1: function(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.log(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3), getStringFromWasm0(arg4, arg5), getStringFromWasm0(arg6, arg7));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
},
__wbg_log_ce2c4456b290c5e7: function(arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.log(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
},
__wbg_mark_b4d943f3bc2d2404: function(arg0, arg1) {
performance.mark(getStringFromWasm0(arg0, arg1));
},
__wbg_measure_84362959e621a2c1: function() { return handleError(function (arg0, arg1, arg2, arg3) {
let deferred0_0;
let deferred0_1;
let deferred1_0;
let deferred1_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
deferred1_0 = arg2;
deferred1_1 = arg3;
performance.measure(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
wasm.__wbindgen_free(deferred1_0, deferred1_1, 1);
}
}, arguments); },
__wbg_msCrypto_bd5a034af96bcba6: function(arg0) {
const ret = arg0.msCrypto;
return ret;
},
__wbg_name_0a4944b9b89a9be1: function(arg0, arg1) {
const ret = arg1.name;
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg_navigator_91b141c3f3b6b96b: function(arg0) {
const ret = arg0.navigator;
return ret;
},
__wbg_new_18cda2e4779f118c: function(arg0) {
const ret = new Uint8Array(arg0);
return ret;
},
__wbg_new_227d7c05414eb861: function() {
const ret = new Error();
return ret;
},
__wbg_new_5c365a7570baea64: function() {
const ret = new Object();
return ret;
},
__wbg_new_with_byte_offset_ba99c41da925551a: function(arg0, arg1) {
const ret = new Uint8Array(arg0, arg1 >>> 0);
return ret;
},
__wbg_new_with_length_875a3f1ab82a1a1f: function(arg0) {
const ret = new Uint8Array(arg0 >>> 0);
return ret;
},
__wbg_node_84ea875411254db1: function(arg0) {
const ret = arg0.node;
return ret;
},
__wbg_now_55c5352b4b61d145: function(arg0) {
const ret = arg0.now();
return ret;
},
__wbg_now_7627eff456aa5959: function(arg0) {
const ret = arg0.now();
return ret;
},
__wbg_performance_aa4d78060a5b8a2f: function(arg0) {
const ret = arg0.performance;
return ret;
},
__wbg_process_44c7a14e11e9f69e: function(arg0) {
const ret = arg0.process;
return ret;
},
__wbg_prototypesetcall_7c3092bff32833dc: function(arg0, arg1, arg2) {
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
},
__wbg_queueMicrotask_17a58d631cc9ab4b: function(arg0) {
queueMicrotask(arg0);
},
__wbg_queueMicrotask_4114767fcf2790b9: function(arg0) {
const ret = arg0.queueMicrotask;
return ret;
},
__wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) {
arg0.randomFillSync(arg1);
}, arguments); },
__wbg_readValue_c83601164f2a0105: function(arg0) {
const ret = arg0.readValue();
return ret;
},
__wbg_requestDevice_26b08548120ca1d9: function(arg0, arg1) {
const ret = arg0.requestDevice(arg1);
return ret;
},
__wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () {
const ret = module.require;
return ret;
}, arguments); },
__wbg_resolve_67a1b1ca24efbc5c: function(arg0) {
const ret = Promise.resolve(arg0);
return ret;
},
__wbg_setTimeout_05a790c35d76ff25: function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.setTimeout(arg1, arg2);
return ret;
}, arguments); },
__wbg_set_filters_7e7f78143ddef831: function(arg0, arg1, arg2) {
arg0.filters = getArrayJsValueViewFromWasm0(arg1, arg2);
},
__wbg_set_name_be5429dc123f1dd9: function(arg0, arg1, arg2) {
arg0.name = getStringFromWasm0(arg1, arg2);
},
__wbg_set_name_prefix_41b281c72726c519: function(arg0, arg1, arg2) {
arg0.namePrefix = getStringFromWasm0(arg1, arg2);
},
__wbg_set_oncharacteristicvaluechanged_d54b71ecfbe76b96: function(arg0, arg1) {
arg0.oncharacteristicvaluechanged = arg1;
},
__wbg_set_ongattserverdisconnected_960815a2e872d5a0: function(arg0, arg1) {
arg0.ongattserverdisconnected = arg1;
},
__wbg_set_optional_services_e183aa24417dc7cb: function(arg0, arg1, arg2) {
arg0.optionalServices = getArrayJsValueViewFromWasm0(arg1, arg2);
},
__wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
const ret = arg1.stack;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg_startNotifications_acc2fc1198e7dd6f: function(arg0) {
const ret = arg0.startNotifications();
return ret;
},
__wbg_static_accessor_GLOBAL_833a66cb4996dbd8: function() {
const ret = typeof global === 'undefined' ? null : global;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_GLOBAL_THIS_fc74cdbdccd80770: function() {
const ret = typeof globalThis === 'undefined' ? null : globalThis;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_SELF_066699022f35d48b: function() {
const ret = typeof self === 'undefined' ? null : self;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_WINDOW_f821c7eb05393790: function() {
const ret = typeof window === 'undefined' ? null : window;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_subarray_22ac454570db4e4f: function(arg0, arg1, arg2) {
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
return ret;
},
__wbg_target_188bf8368fa5f001: function(arg0) {
const ret = arg0.target;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_then_420f698ab0b99678: function(arg0, arg1) {
const ret = arg0.then(arg1);
return ret;
},
__wbg_then_95c29fbd346ee84e: function(arg0, arg1, arg2) {
const ret = arg0.then(arg1, arg2);
return ret;
},
__wbg_value_d1ef9362b7bf7a47: function(arg0) {
const ret = arg0.value;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_versions_276b2795b1c6a219: function(arg0) {
const ret = arg0.versions;
return ret;
},
__wbg_writeValue_99570c64ee612498: function() { return handleError(function (arg0, arg1) {
const ret = arg0.writeValue(arg1);
return ret;
}, arguments); },
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 3402, function: Function { arguments: [Externref], shim_idx: 3403, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h72b504abf7ea70fd, wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 3413, function: Function { arguments: [], shim_idx: 3414, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__ha3c8e2c9b0cf79cd, wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("BluetoothDevice")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("BluetoothRemoteGATTCharacteristic")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("BluetoothRemoteGATTServer")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("BluetoothRemoteGATTService")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("DataView")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("Event")], shim_idx: 86, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243);
return ret;
},
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 86, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8);
return ret;
},
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("undefined")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9);
return ret;
},
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
const ret = getArrayU8FromWasm0(arg0, arg1);
return ret;
},
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
},
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./index_bg.js": import0,
};
}
function wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function addToExternrefTable0(obj) {
const idx = wasm.__externref_table_alloc();
wasm.__wbindgen_externrefs.set(idx, obj);
return idx;
}
const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(state => state.dtor(state.a, state.b));
function debugString(val) {
// primitive types
const type = typeof val;
if (type == 'number' || type == 'boolean' || val == null) {
return `${val}`;
}
if (type == 'string') {
return `"${val}"`;
}
if (type == 'symbol') {
const description = val.description;
if (description == null) {
return 'Symbol';
} else {
return `Symbol(${description})`;
}
}
if (type == 'function') {
const name = val.name;
if (typeof name == 'string' && name.length > 0) {
return `Function(${name})`;
} else {
return 'Function';
}
}
// objects
if (Array.isArray(val)) {
const length = val.length;
let debug = '[';
if (length > 0) {
debug += debugString(val[0]);
}
for(let i = 1; i < length; i++) {
debug += ', ' + debugString(val[i]);
}
debug += ']';
return debug;
}
// Test for built-in
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
let className;
if (builtInMatches && builtInMatches.length > 1) {
className = builtInMatches[1];
} else {
// Failed to match the standard '[object ClassName]'
return toString.call(val);
}
if (className == 'Object') {
// we're a user defined class or Object
// JSON.stringify avoids problems with cycles, and is generally much
// easier than looping through ownProperties of `val`.
try {
return 'Object(' + JSON.stringify(val) + ')';
} catch (_) {
return 'Object';
}
}
// errors
if (val instanceof Error) {
return `${val.name}: ${val.message}\n${val.stack}`;
}
// TODO we could test for more things here, like `Set`s and `Map`s.
return className;
}
function getArrayJsValueViewFromWasm0(ptr, len) {
ptr = ptr >>> 0;
const mem = getDataViewMemory0();
const result = [];
for (let i = ptr; i < ptr + 4 * len; i += 4) {
result.push(wasm.__wbindgen_externrefs.get(mem.getUint32(i, true)));
}
return result;
}
function getArrayU8FromWasm0(ptr, len) {
ptr = ptr >>> 0;
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return decodeText(ptr, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function handleError(f, args) {
try {
return f.apply(this, args);
} catch (e) {
const idx = addToExternrefTable0(e);
wasm.__wbindgen_exn_store(idx);
}
}
function isLikeNone(x) {
return x === undefined || x === null;
}
function makeMutClosure(arg0, arg1, dtor, f) {
const state = { a: arg0, b: arg1, cnt: 1, dtor };
const real = (...args) => {
// First up with a closure we increment the internal reference
// count. This ensures that the Rust closure environment won't
// be deallocated while we're invoking it.
state.cnt++;
const a = state.a;
state.a = 0;
try {
return f(a, state.b, ...args);
} finally {
state.a = a;
real._wbg_cb_unref();
}
};
real._wbg_cb_unref = () => {
if (--state.cnt === 0) {
state.dtor(state.a, state.b);
state.a = 0;
CLOSURE_DTORS.unregister(state);
}
};
CLOSURE_DTORS.register(real, state, state);
return real;
}
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasm;
function __wbg_finalize_init(instance, module) {
wasm = instance.exports;
wasmModule = module;
cachedDataViewMemory0 = null;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('index_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };

View File

@@ -0,0 +1,770 @@
let wasm;
export function __wbg_set_wasm(val) {
wasm = val;
}
function isLikeNone(x) {
return x === undefined || x === null;
}
function addToExternrefTable0(obj) {
const idx = wasm.__externref_table_alloc();
wasm.__wbindgen_export_1.set(idx, obj);
return idx;
}
function handleError(f, args) {
try {
return f.apply(this, args);
} catch (e) {
const idx = addToExternrefTable0(e);
wasm.__wbindgen_exn_store(idx);
}
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
const lTextDecoder =
typeof TextDecoder === "undefined" ? (0, module.require)("util").TextDecoder : TextDecoder;
let cachedTextDecoder = new lTextDecoder("utf-8", { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new lTextDecoder("utf-8", { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return decodeText(ptr, len);
}
function getArrayU8FromWasm0(ptr, len) {
ptr = ptr >>> 0;
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
}
let WASM_VECTOR_LEN = 0;
const lTextEncoder =
typeof TextEncoder === "undefined" ? (0, module.require)("util").TextEncoder : TextEncoder;
const cachedTextEncoder = new lTextEncoder("utf-8");
const encodeString =
typeof cachedTextEncoder.encodeInto === "function"
? function (arg, view) {
return cachedTextEncoder.encodeInto(arg, view);
}
: function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length,
};
};
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0()
.subarray(ptr, ptr + buf.length)
.set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7f) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, (len = offset + arg.length * 3), 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = encodeString(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (
cachedDataViewMemory0 === null ||
cachedDataViewMemory0.buffer.detached === true ||
(cachedDataViewMemory0.buffer.detached === undefined &&
cachedDataViewMemory0.buffer !== wasm.memory.buffer)
) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function debugString(val) {
// primitive types
const type = typeof val;
if (type == "number" || type == "boolean" || val == null) {
return `${val}`;
}
if (type == "string") {
return `"${val}"`;
}
if (type == "symbol") {
const description = val.description;
if (description == null) {
return "Symbol";
} else {
return `Symbol(${description})`;
}
}
if (type == "function") {
const name = val.name;
if (typeof name == "string" && name.length > 0) {
return `Function(${name})`;
} else {
return "Function";
}
}
// objects
if (Array.isArray(val)) {
const length = val.length;
let debug = "[";
if (length > 0) {
debug += debugString(val[0]);
}
for (let i = 1; i < length; i++) {
debug += ", " + debugString(val[i]);
}
debug += "]";
return debug;
}
// Test for built-in
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
let className;
if (builtInMatches && builtInMatches.length > 1) {
className = builtInMatches[1];
} else {
// Failed to match the standard '[object ClassName]'
return toString.call(val);
}
if (className == "Object") {
// we're a user defined class or Object
// JSON.stringify avoids problems with cycles, and is generally much
// easier than looping through ownProperties of `val`.
try {
return "Object(" + JSON.stringify(val) + ")";
} catch (_) {
return "Object";
}
}
// errors
if (val instanceof Error) {
return `${val.name}: ${val.message}\n${val.stack}`;
}
// TODO we could test for more things here, like `Set`s and `Map`s.
return className;
}
const CLOSURE_DTORS =
typeof FinalizationRegistry === "undefined"
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry((state) => {
wasm.__wbindgen_export_6.get(state.dtor)(state.a, state.b);
});
function makeMutClosure(arg0, arg1, dtor, f) {
const state = { a: arg0, b: arg1, cnt: 1, dtor };
const real = (...args) => {
// First up with a closure we increment the internal reference
// count. This ensures that the Rust closure environment won't
// be deallocated while we're invoking it.
state.cnt++;
const a = state.a;
state.a = 0;
try {
return f(a, state.b, ...args);
} finally {
if (--state.cnt === 0) {
wasm.__wbindgen_export_6.get(state.dtor)(a, state.b);
CLOSURE_DTORS.unregister(state);
} else {
state.a = a;
}
}
};
real.original = state;
CLOSURE_DTORS.register(real, state, state);
return real;
}
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
/**
* @param {number} server_ptr
* @param {Uint8Array} buf
* @param {Function} callback
*/
export function buttplug_client_send_json_message(server_ptr, buf, callback) {
const ptr0 = passArray8ToWasm0(buf, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
wasm.buttplug_client_send_json_message(server_ptr, ptr0, len0, callback);
}
/**
* @param {number} ptr
*/
export function buttplug_free_embedded_wasm_server(ptr) {
wasm.buttplug_free_embedded_wasm_server(ptr);
}
/**
* @param {string} _max_level
*/
export function buttplug_activate_env_logger(_max_level) {
const ptr0 = passStringToWasm0(_max_level, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
wasm.buttplug_activate_env_logger(ptr0, len0);
}
/**
* @param {Function} callback
* @returns {number}
*/
export function buttplug_create_embedded_wasm_server(callback) {
const ret = wasm.buttplug_create_embedded_wasm_server(callback);
return ret >>> 0;
}
function __wbg_adapter_8(arg0, arg1, arg2) {
wasm.closure3251_externref_shim(arg0, arg1, arg2);
}
function __wbg_adapter_11(arg0, arg1, arg2) {
wasm.closure202_externref_shim(arg0, arg1, arg2);
}
function __wbg_adapter_16(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h239e120689ecd2a1(arg0, arg1);
}
export function __wbg_bluetooth_b95c3b6935c8a51a(arg0) {
const ret = arg0.bluetooth;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_buffer_068eb5b0fbad96d8(arg0) {
const ret = arg0.buffer;
return ret;
}
export function __wbg_byteLength_59ab6482fa6cb38b(arg0) {
const ret = arg0.byteLength;
return ret;
}
export function __wbg_call_2f8d426a20a307fe() {
return handleError(function (arg0, arg1) {
const ret = arg0.call(arg1);
return ret;
}, arguments);
}
export function __wbg_call_f53f0647ceb9c567() {
return handleError(function (arg0, arg1, arg2) {
const ret = arg0.call(arg1, arg2);
return ret;
}, arguments);
}
export function __wbg_connect_b91d2ba90a1ff675(arg0) {
const ret = arg0.connect();
return ret;
}
export function __wbg_crypto_86f2631e91b51511(arg0) {
const ret = arg0.crypto;
return ret;
}
export function __wbg_error_7534b8e9a36f1ab4(arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.error(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
}
export function __wbg_gatt_06b2bdb9e7f8fb84(arg0) {
const ret = arg0.gatt;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_getCharacteristic_1cb1de10f54aa049(arg0, arg1, arg2) {
const ret = arg0.getCharacteristic(getStringFromWasm0(arg1, arg2));
return ret;
}
export function __wbg_getPrimaryService_f8c0b8be4fded7fd(arg0, arg1, arg2) {
const ret = arg0.getPrimaryService(getStringFromWasm0(arg1, arg2));
return ret;
}
export function __wbg_getRandomValues_1c61fac11405ffdc() {
return handleError(function (arg0, arg1) {
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
}, arguments);
}
export function __wbg_getRandomValues_9c5c1b115e142bb8() {
return handleError(function (arg0, arg1) {
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
}, arguments);
}
export function __wbg_getRandomValues_b3f15fcbfabb0f8b() {
return handleError(function (arg0, arg1) {
arg0.getRandomValues(arg1);
}, arguments);
}
export function __wbg_get_27b4bcbec57323ca() {
return handleError(function (arg0, arg1) {
const ret = Reflect.get(arg0, arg1);
return ret;
}, arguments);
}
export function __wbg_id_d769eab9a0939b42(arg0, arg1) {
const ret = arg1.id;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg_instanceof_Window_7f29e5c72acbfd60(arg0) {
let result;
try {
result = arg0 instanceof Window;
} catch (_) {
result = false;
}
const ret = result;
return ret;
}
export function __wbg_length_904c0910ed998bf3(arg0) {
const ret = arg0.length;
return ret;
}
export function __wbg_log_0cc1b7768397bcfe(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.log(
getStringFromWasm0(arg0, arg1),
getStringFromWasm0(arg2, arg3),
getStringFromWasm0(arg4, arg5),
getStringFromWasm0(arg6, arg7),
);
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
}
export function __wbg_log_cb9e190acc5753fb(arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.log(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
}
export function __wbg_mark_7438147ce31e9d4b(arg0, arg1) {
performance.mark(getStringFromWasm0(arg0, arg1));
}
export function __wbg_measure_fb7825c11612c823() {
return handleError(function (arg0, arg1, arg2, arg3) {
let deferred0_0;
let deferred0_1;
let deferred1_0;
let deferred1_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
deferred1_0 = arg2;
deferred1_1 = arg3;
performance.measure(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
wasm.__wbindgen_free(deferred1_0, deferred1_1, 1);
}
}, arguments);
}
export function __wbg_msCrypto_d562bbe83e0d4b91(arg0) {
const ret = arg0.msCrypto;
return ret;
}
export function __wbg_name_cf5d973f5e1d9b1e(arg0, arg1) {
const ret = arg1.name;
var ptr1 = isLikeNone(ret)
? 0
: passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg_navigator_b6d1cae68d750613(arg0) {
const ret = arg0.navigator;
return ret;
}
export function __wbg_new_1930cbb8d9ffc31b() {
const ret = new Object();
return ret;
}
export function __wbg_new_8a6f238a6ece86ea() {
const ret = new Error();
return ret;
}
export function __wbg_new_9190433fb67ed635(arg0) {
const ret = new Uint8Array(arg0);
return ret;
}
export function __wbg_new_e969dc3f68d25093() {
const ret = [];
return ret;
}
export function __wbg_newnoargs_a81330f6e05d8aca(arg0, arg1) {
const ret = new Function(getStringFromWasm0(arg0, arg1));
return ret;
}
export function __wbg_newwithbyteoffset_b204dc995f2352f4(arg0, arg1) {
const ret = new Uint8Array(arg0, arg1 >>> 0);
return ret;
}
export function __wbg_newwithlength_ed0ee6c1edca86fc(arg0) {
const ret = new Uint8Array(arg0 >>> 0);
return ret;
}
export function __wbg_node_e1f24f89a7336c2e(arg0) {
const ret = arg0.node;
return ret;
}
export function __wbg_now_0dc4920a47cf7280(arg0) {
const ret = arg0.now();
return ret;
}
export function __wbg_now_1f875e5cd673bc3c(arg0) {
const ret = arg0.now();
return ret;
}
export function __wbg_performance_6adc3b899e448a23(arg0) {
const ret = arg0.performance;
return ret;
}
export function __wbg_process_3975fd6c72f520aa(arg0) {
const ret = arg0.process;
return ret;
}
export function __wbg_prototypesetcall_c5f74efd31aea86b(arg0, arg1, arg2) {
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
}
export function __wbg_push_cd3ac7d5b094565d(arg0, arg1) {
const ret = arg0.push(arg1);
return ret;
}
export function __wbg_queueMicrotask_bcc6e26d899696db(arg0) {
const ret = arg0.queueMicrotask;
return ret;
}
export function __wbg_queueMicrotask_f24a794d09c42640(arg0) {
queueMicrotask(arg0);
}
export function __wbg_randomFillSync_f8c153b79f285817() {
return handleError(function (arg0, arg1) {
arg0.randomFillSync(arg1);
}, arguments);
}
export function __wbg_readValue_9d41d1a73a07165f(arg0) {
const ret = arg0.readValue();
return ret;
}
export function __wbg_requestDevice_17840c36162ed342(arg0, arg1) {
const ret = arg0.requestDevice(arg1);
return ret;
}
export function __wbg_require_b74f47fc2d022fd6() {
return handleError(function () {
const ret = module.require;
return ret;
}, arguments);
}
export function __wbg_resolve_5775c0ef9222f556(arg0) {
const ret = Promise.resolve(arg0);
return ret;
}
export function __wbg_setTimeout_63008613644b07af() {
return handleError(function (arg0, arg1, arg2) {
const ret = arg0.setTimeout(arg1, arg2);
return ret;
}, arguments);
}
export function __wbg_setfilters_b142ba75a84ace1a(arg0, arg1) {
arg0.filters = arg1;
}
export function __wbg_setname_decc08ea308195a6(arg0, arg1, arg2) {
arg0.name = getStringFromWasm0(arg1, arg2);
}
export function __wbg_setnameprefix_3c1f973506cd8f08(arg0, arg1, arg2) {
arg0.namePrefix = getStringFromWasm0(arg1, arg2);
}
export function __wbg_setoncharacteristicvaluechanged_a126981aa4e69da5(arg0, arg1) {
arg0.oncharacteristicvaluechanged = arg1;
}
export function __wbg_setongattserverdisconnected_d43d7f3a48a58fa4(arg0, arg1) {
arg0.ongattserverdisconnected = arg1;
}
export function __wbg_setoptionalservices_b6c19589e7aa503e(arg0, arg1) {
arg0.optionalServices = arg1;
}
export function __wbg_stack_0ed75d68575b0f3c(arg0, arg1) {
const ret = arg1.stack;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg_startNotifications_e1edb2183f9289f5(arg0) {
const ret = arg0.startNotifications();
return ret;
}
export function __wbg_static_accessor_GLOBAL_1f13249cc3acc96d() {
const ret = typeof global === "undefined" ? null : global;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_static_accessor_GLOBAL_THIS_df7ae94b1e0ed6a3() {
const ret = typeof globalThis === "undefined" ? null : globalThis;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_static_accessor_SELF_6265471db3b3c228() {
const ret = typeof self === "undefined" ? null : self;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_static_accessor_WINDOW_16fb482f8ec52863() {
const ret = typeof window === "undefined" ? null : window;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_subarray_a219824899e59712(arg0, arg1, arg2) {
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
return ret;
}
export function __wbg_target_bfb4281bfa013115(arg0) {
const ret = arg0.target;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_then_8d2fcccde5380a03(arg0, arg1, arg2) {
const ret = arg0.then(arg1, arg2);
return ret;
}
export function __wbg_then_9cc266be2bf537b6(arg0, arg1) {
const ret = arg0.then(arg1);
return ret;
}
export function __wbg_value_5e240e44f81872c5(arg0) {
const ret = arg0.value;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_versions_4e31226f5e8dc909(arg0) {
const ret = arg0.versions;
return ret;
}
export function __wbg_wbindgencbdrop_a85ed476c6a370b9(arg0) {
const obj = arg0.original;
if (obj.cnt-- == 1) {
obj.a = 0;
return true;
}
const ret = false;
return ret;
}
export function __wbg_wbindgendebugstring_bb652b1bc2061b6d(arg0, arg1) {
const ret = debugString(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg_wbindgenisfunction_ea72b9d66a0e1705(arg0) {
const ret = typeof arg0 === "function";
return ret;
}
export function __wbg_wbindgenisobject_dfe064a121d87553(arg0) {
const val = arg0;
const ret = typeof val === "object" && val !== null;
return ret;
}
export function __wbg_wbindgenisstring_4b74e4111ba029e6(arg0) {
const ret = typeof arg0 === "string";
return ret;
}
export function __wbg_wbindgenisundefined_71f08a6ade4354e7(arg0) {
const ret = arg0 === undefined;
return ret;
}
export function __wbg_wbindgenthrow_4c11a24fca429ccf(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
}
export function __wbg_writeValue_586734e0fc6e7c73() {
return handleError(function (arg0, arg1) {
const ret = arg0.writeValue(arg1);
return ret;
}, arguments);
}
export function __wbindgen_cast_0f76fca626397b3d(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 201, function: Function { arguments: [NamedExternref("Event")], shim_idx: 202, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, 201, __wbg_adapter_11);
return ret;
}
export function __wbindgen_cast_2241b6af4c4b2941(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
}
export function __wbindgen_cast_30a893855537e77b(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 201, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 202, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, 201, __wbg_adapter_11);
return ret;
}
export function __wbindgen_cast_76c05e7c8d82d9b7(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 3269, function: Function { arguments: [], shim_idx: 3270, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, 3269, __wbg_adapter_16);
return ret;
}
export function __wbindgen_cast_cb3d47e2c086b274(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 3250, function: Function { arguments: [Externref], shim_idx: 3251, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, 3250, __wbg_adapter_8);
return ret;
}
export function __wbindgen_cast_cb9088102bce6b30(arg0, arg1) {
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
const ret = getArrayU8FromWasm0(arg0, arg1);
return ret;
}
export function __wbindgen_init_externref_table() {
const table = wasm.__wbindgen_export_1;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
}

Binary file not shown.

View File

@@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const create_test_dcm: (a: number, b: number) => void;
export const buttplug_activate_env_logger: (a: number, b: number) => void;
export const buttplug_client_send_json_message: (a: number, b: number, c: number, d: any) => void;
export const buttplug_create_embedded_wasm_server: (a: any) => number;
export const buttplug_free_embedded_wasm_server: (a: number) => void;
export const wasm_bindgen__closure__destroy__h72b504abf7ea70fd: (a: number, b: number) => void;
export const wasm_bindgen__closure__destroy__ha3c8e2c9b0cf79cd: (a: number, b: number) => void;
export const wasm_bindgen__closure__destroy__h0f95d90d24796def: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd: (a: number, b: number) => void;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __externref_table_alloc: () => number;
export const __wbindgen_externrefs: WebAssembly.Table;
export const __wbindgen_exn_store: (a: number) => void;
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_start: () => void;

View File

@@ -0,0 +1,32 @@
{
"name": "buttplug_wasm",
"type": "module",
"collaborators": [
"Nonpolynomial Labs, LLC <kyle@nonpolynomial.com>"
],
"description": "WASM Interop for the Buttplug Intimate Hardware Control Library",
"version": "10.0.0",
"license": "BSD-3-Clause",
"repository": {
"type": "git",
"url": "https://github.com/buttplugio/buttplug.git"
},
"files": [
"index_bg.wasm",
"index.js",
"index.d.ts"
],
"main": "index.js",
"homepage": "http://buttplug.io",
"types": "index.d.ts",
"sideEffects": [
"./snippets/*"
],
"keywords": [
"usb",
"serial",
"hardware",
"bluetooth",
"teledildonics"
]
}

View File

@@ -1,6 +1,7 @@
{
"name": "@sexy.pivoine.art/frontend",
"version": "1.0.0",
"author": "valknarogg",
"type": "module",
"private": true,
"scripts": {
@@ -14,7 +15,7 @@
"@sexy.pivoine.art/buttplug": "workspace:*",
"@iconify-json/ri": "^1.2.10",
"@iconify/tailwind4": "^1.2.1",
"@internationalized/date": "^3.12.0",
"@internationalized/date": "^3.11.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/adapter-static": "^3.0.10",
@@ -29,6 +30,7 @@
"glob": "^13.0.6",
"mode-watcher": "^1.1.0",
"prettier-plugin-svelte": "^3.5.1",
"super-sitemap": "^1.0.7",
"svelte": "^5.53.7",
"svelte-check": "^4.4.4",
"svelte-sonner": "^1.0.8",
@@ -37,7 +39,8 @@
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vite-plugin-wasm": "3.5.0"
},
"dependencies": {
"@sexy.pivoine.art/types": "workspace:*",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,23 +91,6 @@ export default {
me: {
title: "Dashboard",
welcome: "Welcome back, {name}",
nav: {
profile: "Profile",
security: "Security",
recordings: "Recordings",
analytics: "Analytics",
back_to_site: "Back to site",
back_mobile: "Back",
},
analytics: {
title: "Analytics",
description: "Track your content performance and audience engagement",
total_videos: "Total Videos",
total_likes: "Total Likes",
total_plays: "Total Plays",
video_performance: "Video Performance",
video_performance_description: "Detailed metrics for each video",
},
view_profile: "View Public Profile",
settings: {
title: "Settings",
@@ -151,10 +134,6 @@ export default {
delete_confirm: "Are you sure you want to delete this recording?",
delete_success: "Recording deleted successfully",
delete_error: "Failed to delete recording",
publish_success: "Recording published successfully",
publish_error: "Failed to publish recording",
unpublish_success: "Recording unpublished",
unpublish_error: "Failed to unpublish recording",
},
},
recording_card: {
@@ -165,8 +144,6 @@ export default {
status_draft: "Draft",
status_published: "Published",
play: "Play",
publish: "Publish",
unpublish: "Unpublish",
edit: "Edit",
delete: "Delete",
public: "Public",
@@ -822,19 +799,11 @@ export default {
questions_email: "support@pivoine.art",
},
play: {
title: "Play",
title: "SexyPlay",
description: "Bring your toys.",
scan: "Start Scan",
scanning: "Scanning...",
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",
},
no_results: "No Devices founds",
},
error: {
not_found: "Oops! Page Not Found",
@@ -936,15 +905,14 @@ export default {
},
admin: {
nav: {
back_to_site: "Back to site",
back_mobile: "Back",
back_to_site: "Back to site",
back_mobile: "Back",
title: "Admin",
users: "Users",
videos: "Videos",
articles: "Articles",
comments: "Comments",
recordings: "Recordings",
queues: "Queues",
},
common: {
save_changes: "Save changes",
@@ -959,8 +927,8 @@ export default {
cover_image: "Cover image",
tags: "Tags",
publish_date: "Publish date",
title_field: "Title",
slug_field: "Slug",
title_field: "Title *",
slug_field: "Slug *",
title_slug_required: "Title and slug are required",
image_uploaded: "Image uploaded",
image_upload_failed: "Image upload failed",
@@ -1090,36 +1058,6 @@ export default {
delete_success: "Recording deleted",
delete_error: "Failed to delete recording",
},
queues: {
title: "Job Queues",
pause: "Pause",
resume: "Resume",
paused_badge: "Paused",
retry: "Retry",
remove: "Remove",
retry_success: "Job retried",
retry_error: "Failed to retry job",
remove_success: "Job removed",
remove_error: "Failed to remove job",
pause_success: "Queue paused",
pause_error: "Failed to pause queue",
resume_success: "Queue resumed",
resume_error: "Failed to resume queue",
col_id: "ID",
col_name: "Name",
col_status: "Status",
col_attempts: "Attempts",
col_created: "Created",
col_actions: "Actions",
no_jobs: "No jobs found",
status_all: "All",
status_waiting: "Waiting",
status_active: "Active",
status_completed: "Completed",
status_failed: "Failed",
status_delayed: "Delayed",
failed_reason: "Reason: {reason}",
},
article_form: {
new_title: "New article",
edit_title: "Edit article",

View File

@@ -902,26 +902,6 @@ export async function createRecording(
);
}
const UPDATE_RECORDING_MUTATION = gql`
mutation UpdateRecording($id: String!, $status: String, $public: Boolean) {
updateRecording(id: $id, status: $status, public: $public) {
id
status
public
}
}
`;
export async function updateRecording(id: string, fields: { status?: string; public?: boolean }) {
return loggedApiCall("updateRecording", async () => {
const data = await getGraphQLClient().request<{ updateRecording: Recording }>(
UPDATE_RECORDING_MUTATION,
{ id, ...fields },
);
return data.updateRecording;
});
}
const DELETE_RECORDING_MUTATION = gql`
mutation DeleteRecording($id: String!) {
deleteRecording(id: $id)
@@ -1896,145 +1876,3 @@ export async function adminDeleteRecording(id: string): Promise<void> {
await getGraphQLClient().request(ADMIN_DELETE_RECORDING_MUTATION, { id });
});
}
// --- Queues ---
export type JobCounts = {
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
paused: number;
};
export type QueueInfo = {
name: string;
counts: JobCounts;
isPaused: boolean;
};
export type Job = {
id: string;
name: string;
queue: string;
status: string;
data: Record<string, unknown>;
result: unknown;
failedReason: string | null;
attemptsMade: number;
createdAt: string;
processedAt: string | null;
finishedAt: string | null;
progress: number | null;
};
const ADMIN_QUEUES_QUERY = gql`
query AdminQueues {
adminQueues {
name
isPaused
counts {
waiting
active
completed
failed
delayed
paused
}
}
}
`;
export async function getAdminQueues(
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<QueueInfo[]> {
return loggedApiCall("getAdminQueues", async () => {
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminQueues: QueueInfo[] }>(ADMIN_QUEUES_QUERY);
return data.adminQueues;
});
}
const ADMIN_QUEUE_JOBS_QUERY = gql`
query AdminQueueJobs($queue: String!, $status: String, $limit: Int, $offset: Int) {
adminQueueJobs(queue: $queue, status: $status, limit: $limit, offset: $offset) {
id
name
queue
status
data
result
failedReason
attemptsMade
createdAt
processedAt
finishedAt
progress
}
}
`;
export async function getAdminQueueJobs(
queue: string,
status?: string,
limit?: number,
offset?: number,
): Promise<Job[]> {
return loggedApiCall("getAdminQueueJobs", async () => {
const data = await getGraphQLClient().request<{ adminQueueJobs: Job[] }>(
ADMIN_QUEUE_JOBS_QUERY,
{ queue, status, limit, offset },
);
return data.adminQueueJobs;
});
}
const ADMIN_RETRY_JOB_MUTATION = gql`
mutation AdminRetryJob($queue: String!, $jobId: String!) {
adminRetryJob(queue: $queue, jobId: $jobId)
}
`;
export async function adminRetryJob(queue: string, jobId: string): Promise<void> {
return loggedApiCall("adminRetryJob", async () => {
await getGraphQLClient().request(ADMIN_RETRY_JOB_MUTATION, { queue, jobId });
});
}
const ADMIN_REMOVE_JOB_MUTATION = gql`
mutation AdminRemoveJob($queue: String!, $jobId: String!) {
adminRemoveJob(queue: $queue, jobId: $jobId)
}
`;
export async function adminRemoveJob(queue: string, jobId: string): Promise<void> {
return loggedApiCall("adminRemoveJob", async () => {
await getGraphQLClient().request(ADMIN_REMOVE_JOB_MUTATION, { queue, jobId });
});
}
const ADMIN_PAUSE_QUEUE_MUTATION = gql`
mutation AdminPauseQueue($queue: String!) {
adminPauseQueue(queue: $queue)
}
`;
const ADMIN_RESUME_QUEUE_MUTATION = gql`
mutation AdminResumeQueue($queue: String!) {
adminResumeQueue(queue: $queue)
}
`;
export async function adminPauseQueue(queue: string): Promise<void> {
return loggedApiCall("adminPauseQueue", async () => {
await getGraphQLClient().request(ADMIN_PAUSE_QUEUE_MUTATION, { queue });
});
}
export async function adminResumeQueue(queue: string): Promise<void> {
return loggedApiCall("adminResumeQueue", async () => {
await getGraphQLClient().request(ADMIN_RESUME_QUEUE_MUTATION, { queue });
});
}

View File

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

View File

@@ -1,17 +1,8 @@
<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 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 { children } = $props();
const navLinks = $derived([
{ name: $_("admin.nav.users"), href: "/admin/users", icon: "icon-[ri--team-line]" },
@@ -23,11 +14,6 @@
href: "/admin/recordings",
icon: "icon-[ri--record-circle-line]",
},
{
name: $_("admin.nav.queues"),
href: "/admin/queues",
icon: "icon-[ri--stack-line]",
},
]);
function isActive(href: string) {
@@ -42,10 +28,9 @@
<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"
class="shrink-0 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">{$_("admin.nav.back_mobile")}</span>
{$_("admin.nav.back_mobile")}
</a>
{#each navLinks as link (link.href)}
<a
@@ -68,33 +53,10 @@
<!-- 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>
<a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors">
{$_("admin.nav.back_to_site")}
</a>
<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>
<h1 class="mt-2 text-base font-bold text-foreground">{$_("admin.nav.title")}</h1>
</div>
<nav class="flex-1 p-3 space-y-1">

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
import { getAdminQueues } from "$lib/services";
export async function load({ fetch, cookies }) {
const token = cookies.get("session_token") || "";
const queues = await getAdminQueues(fetch, token).catch(() => []);
return { queues };
}

View File

@@ -1,302 +0,0 @@
<script lang="ts">
import { invalidateAll } from "$app/navigation";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import {
getAdminQueueJobs,
adminRetryJob,
adminRemoveJob,
adminPauseQueue,
adminResumeQueue,
} from "$lib/services";
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import type { Job } from "$lib/services";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
const queues = $derived(data.queues);
// null means "user hasn't picked yet" — fall back to first queue
let selectedQueueOverride = $state<string | null>(null);
const selectedQueue = $derived(selectedQueueOverride ?? queues[0]?.name ?? null);
let selectedStatus = $state<string | null>(null);
let jobs = $state<Job[]>([]);
let loadingJobs = $state(false);
let togglingQueue = $state<string | null>(null);
const STATUS_FILTERS = [
{ value: null, label: $_("admin.queues.status_all") },
{ value: "waiting", label: $_("admin.queues.status_waiting") },
{ value: "active", label: $_("admin.queues.status_active") },
{ value: "completed", label: $_("admin.queues.status_completed") },
{ value: "failed", label: $_("admin.queues.status_failed") },
{ value: "delayed", label: $_("admin.queues.status_delayed") },
];
async function loadJobs() {
if (!selectedQueue) return;
loadingJobs = true;
try {
jobs = await getAdminQueueJobs(selectedQueue, selectedStatus ?? undefined, 50, 0);
} finally {
loadingJobs = false;
}
}
async function selectQueue(name: string) {
selectedQueueOverride = name;
selectedStatus = null;
await loadJobs();
}
async function selectStatus(status: string | null) {
selectedStatus = status;
await loadJobs();
}
async function retryJob(job: Job) {
try {
await adminRetryJob(job.queue, job.id);
toast.success($_("admin.queues.retry_success"));
await loadJobs();
await refreshCounts();
} catch {
toast.error($_("admin.queues.retry_error"));
}
}
async function removeJob(job: Job) {
try {
await adminRemoveJob(job.queue, job.id);
toast.success($_("admin.queues.remove_success"));
jobs = jobs.filter((j) => j.id !== job.id);
await refreshCounts();
} catch {
toast.error($_("admin.queues.remove_error"));
}
}
async function toggleQueue(queueName: string, isPaused: boolean) {
togglingQueue = queueName;
try {
if (isPaused) {
await adminResumeQueue(queueName);
toast.success($_("admin.queues.resume_success"));
} else {
await adminPauseQueue(queueName);
toast.success($_("admin.queues.pause_success"));
}
await refreshCounts();
} catch {
toast.error(isPaused ? $_("admin.queues.resume_error") : $_("admin.queues.pause_error"));
} finally {
togglingQueue = null;
}
}
async function refreshCounts() {
await invalidateAll();
}
$effect(() => {
if (selectedQueue) loadJobs();
});
function statusColor(status: string): string {
switch (status) {
case "active":
return "text-blue-500 border-blue-500/30 bg-blue-500/10";
case "completed":
return "text-green-500 border-green-500/30 bg-green-500/10";
case "failed":
return "text-destructive border-destructive/30 bg-destructive/10";
case "delayed":
return "text-yellow-500 border-yellow-500/30 bg-yellow-500/10";
default:
return "text-muted-foreground border-border/40 bg-muted/20";
}
}
function formatDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleString();
}
</script>
<Meta title={$_("admin.queues.title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.queues.title")}</h1>
</div>
<!-- Queue cards -->
<div class="flex flex-wrap gap-3 mb-6">
{#each queues as queue (queue.name)}
{@const isSelected = selectedQueue === queue.name}
<div
role="button"
tabindex="0"
class={`flex-1 min-w-48 rounded-lg border p-4 text-left transition-colors cursor-pointer ${
isSelected
? "border-primary/50 bg-primary/5"
: "border-border/40 bg-card hover:border-border/70"
}`}
onclick={() => selectQueue(queue.name)}
onkeydown={(e) => e.key === "Enter" && selectQueue(queue.name)}
aria-pressed={isSelected}
>
<div class="flex items-center justify-between mb-3">
<span class="font-semibold capitalize">{queue.name}</span>
<div class="flex items-center gap-1.5">
{#if queue.isPaused}
<Badge variant="outline" class="text-yellow-600 border-yellow-500/40 bg-yellow-500/10"
>{$_("admin.queues.paused_badge")}</Badge
>
{/if}
<Button
size="sm"
variant="ghost"
class="h-6 px-2 text-xs"
disabled={togglingQueue === queue.name}
onclick={(e) => {
e.stopPropagation();
toggleQueue(queue.name, queue.isPaused);
}}
>
{queue.isPaused ? $_("admin.queues.resume") : $_("admin.queues.pause")}
</Button>
</div>
</div>
<div class="flex flex-wrap gap-2 text-xs">
{#if queue.counts.waiting > 0}
<span class="text-muted-foreground">{queue.counts.waiting} waiting</span>
{/if}
{#if queue.counts.active > 0}
<span class="text-blue-500">{queue.counts.active} active</span>
{/if}
{#if queue.counts.completed > 0}
<span class="text-green-500">{queue.counts.completed} completed</span>
{/if}
{#if queue.counts.failed > 0}
<span class="text-destructive font-medium">{queue.counts.failed} failed</span>
{/if}
{#if queue.counts.delayed > 0}
<span class="text-yellow-500">{queue.counts.delayed} delayed</span>
{/if}
{#if Object.values(queue.counts).every((v) => v === 0)}
<span class="text-muted-foreground">empty</span>
{/if}
</div>
</div>
{/each}
</div>
{#if selectedQueue}
<!-- Status filter tabs -->
<div class="flex gap-1 mb-4 flex-wrap">
{#each STATUS_FILTERS as f (f.value ?? "all")}
<Button
variant={selectedStatus === f.value ? "default" : "outline"}
onclick={() => selectStatus(f.value)}
>
{f.label}
</Button>
{/each}
</div>
<!-- Jobs table -->
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/30">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
>{$_("admin.queues.col_id")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
>{$_("admin.queues.col_name")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
>{$_("admin.queues.col_status")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">
{$_("admin.queues.col_attempts")}
</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden lg:table-cell">
{$_("admin.queues.col_created")}
</th>
<th class="px-4 py-3 text-right font-medium text-muted-foreground"
>{$_("admin.queues.col_actions")}</th
>
</tr>
</thead>
<tbody class="divide-y divide-border/30">
{#if loadingJobs}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
>{$_("common.loading")}</td
>
</tr>
{:else}
{#each jobs as job (job.id)}
<tr class="hover:bg-muted/10 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id}</td>
<td class="px-4 py-3">
<div>
<p class="font-medium">{job.name}</p>
{#if job.failedReason}
<p class="text-xs text-destructive mt-0.5 max-w-xs truncate">
{$_("admin.queues.failed_reason", { values: { reason: job.failedReason } })}
</p>
{/if}
</div>
</td>
<td class="px-4 py-3">
<Badge variant="outline" class={statusColor(job.status)}>{job.status}</Badge>
</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
>{job.attemptsMade}</td
>
<td class="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs"
>{formatDate(job.createdAt)}</td
>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
{#if job.status === "failed"}
<Button
size="sm"
variant="ghost"
aria-label={$_("admin.queues.retry")}
onclick={() => retryJob(job)}
>
<span class="icon-[ri--restart-line] h-4 w-4"></span>
</Button>
{/if}
<Button
size="sm"
variant="ghost"
aria-label={$_("admin.queues.remove")}
class="text-destructive hover:text-destructive hover:bg-destructive/10"
onclick={() => removeJob(job)}
>
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
</Button>
</div>
</td>
</tr>
{/each}
{#if jobs.length === 0}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
>{$_("admin.queues.no_jobs")}</td
>
</tr>
{/if}
{/if}
</tbody>
</table>
</div>
{/if}
</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,11 +10,9 @@
import { Textarea } from "$lib/components/ui/textarea";
import { TagsInput } from "$lib/components/ui/tags-input";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { Card, CardContent } from "$lib/components/ui/card";
import { getAssetUrl } from "$lib/api";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import { DatePicker } from "$lib/components/ui/date-picker";
import Meta from "$lib/components/meta/meta.svelte";
const { data } = $props();
@@ -104,20 +102,15 @@
}
</script>
<Meta title={$_("admin.video_form.edit_title")} description={null} />
<div class="py-3 sm:py-6 lg:pl-6">
<div class="mb-6">
<h1 class="text-2xl font-bold">{data.video.title}</h1>
<p class="text-xs text-muted-foreground mt-0.5">
{data.video.slug}{data.video.premium ? " · premium" : ""}{data.video.featured
? " · featured"
: ""}
</p>
<div class="p-3 sm:p-6 max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/videos" size="sm">
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
</Button>
<h1 class="text-2xl font-bold">{$_("admin.video_form.edit_title")}</h1>
</div>
<Card class="bg-card/50 border-primary/20 max-w-2xl">
<CardContent class="space-y-5 pt-6">
<div class="space-y-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="title">{$_("admin.common.title_field")}</Label>
@@ -125,17 +118,11 @@
id="title"
bind:value={title}
placeholder={$_("admin.video_form.title_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<Label for="slug">{$_("admin.common.slug_field")}</Label>
<Input
id="slug"
bind:value={slug}
placeholder={$_("admin.video_form.slug_placeholder")}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
<Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
</div>
</div>
@@ -146,7 +133,6 @@
bind:value={description}
placeholder={$_("admin.video_form.description_placeholder")}
rows={3}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
@@ -179,10 +165,7 @@
<div class="space-y-1.5">
<Label>{$_("admin.common.tags")}</Label>
<TagsInput
bind:value={tags}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
<TagsInput bind:value={tags} />
</div>
<div class="space-y-1.5">
@@ -209,7 +192,7 @@
<div class="space-y-1.5">
<Label>{$_("admin.video_form.models")}</Label>
<Select type="multiple" bind:value={selectedModelIds}>
<SelectTrigger class="w-full bg-background/50 border-primary/20">
<SelectTrigger class="w-full">
{#if selectedModelIds.length}
{$_("admin.video_form.models_selected", {
values: { count: selectedModelIds.length },
@@ -236,13 +219,15 @@
</div>
{/if}
<div class="flex gap-3 pt-2">
<Button
onclick={handleSubmit}
disabled={saving}
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button>
</CardContent>
</Card>
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
{
"name": "@sexy.pivoine.art/types",
"version": "1.0.0",
"private": true,
"types": "./src/index.ts",
"exports": {
".": {

378
pnpm-lock.yaml generated
View File

@@ -59,9 +59,6 @@ importers:
argon2:
specifier: ^0.43.0
version: 0.43.1
bullmq:
specifier: ^5.70.4
version: 5.70.4
drizzle-orm:
specifier: ^0.44.1
version: 0.44.7(@types/pg@8.18.0)(knex@3.1.0(pg@8.19.0))(pg@8.19.0)
@@ -183,8 +180,8 @@ importers:
specifier: ^1.2.1
version: 1.2.1(tailwindcss@4.2.1)
'@internationalized/date':
specifier: ^3.12.0
version: 3.12.0
specifier: ^3.11.0
version: 3.11.0
'@lucide/svelte':
specifier: ^0.561.0
version: 0.561.0(svelte@5.53.7)
@@ -217,7 +214,7 @@ importers:
version: 5.0.8
bits-ui:
specifier: 2.16.2
version: 2.16.2(@internationalized/date@3.12.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)
version: 2.16.2(@internationalized/date@3.11.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)
clsx:
specifier: ^2.1.1
version: 2.1.1
@@ -230,6 +227,9 @@ importers:
prettier-plugin-svelte:
specifier: ^3.5.1
version: 3.5.1(prettier@3.8.1)(svelte@5.53.7)
super-sitemap:
specifier: ^1.0.7
version: 1.0.7(svelte@5.53.7)
svelte:
specifier: ^5.53.7
version: 5.53.7
@@ -257,6 +257,9 @@ importers:
vite:
specifier: ^7.3.1
version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)
vite-plugin-wasm:
specifier: 3.5.0
version: 3.5.0(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0))
packages/types:
devDependencies:
@@ -1091,79 +1094,67 @@ packages:
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
@@ -1182,11 +1173,8 @@ packages:
cpu: [x64]
os: [win32]
'@internationalized/date@3.12.0':
resolution: {integrity: sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==}
'@ioredis/commands@1.5.0':
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
'@internationalized/date@3.11.0':
resolution: {integrity: sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==}
'@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
@@ -1223,36 +1211,6 @@ packages:
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
cpu: [arm64]
os: [darwin]
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==}
cpu: [x64]
os: [darwin]
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==}
cpu: [arm64]
os: [linux]
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==}
cpu: [arm]
os: [linux]
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==}
cpu: [x64]
os: [linux]
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==}
cpu: [x64]
os: [win32]
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
@@ -1347,79 +1305,66 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -1546,28 +1491,24 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
@@ -1757,6 +1698,10 @@ packages:
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
ansi-styles@3.2.1:
resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
engines: {node: '>=4'}
argon2@0.43.1:
resolution: {integrity: sha512-TfOzvDWUaQPurCT1hOwIeFNkgrAJDpbBGBGWDgzDsm11nNhImc13WhdGdCU6K7brkp8VpeY07oGtSex0Wmhg8w==}
engines: {node: '>=16.17.0'}
@@ -1765,6 +1710,14 @@ packages:
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
engines: {node: '>= 0.4'}
array-back@3.1.0:
resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==}
engines: {node: '>=6'}
array-back@4.0.2:
resolution: {integrity: sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==}
engines: {node: '>=8'}
async@0.2.10:
resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==}
@@ -1814,14 +1767,15 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
bullmq@5.70.4:
resolution: {integrity: sha512-S58YT/tGdhc4pEPcIahtZRBR1TcTLpss1UKiXimF+Vy4yZwF38pW2IvhHqs4j4dEbZqDt8oi0jGGN/WYQHbPDg==}
ce-la-react@0.3.2:
resolution: {integrity: sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==}
peerDependencies:
react: '>=17.0.0'
chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@@ -1842,6 +1796,9 @@ packages:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1862,6 +1819,14 @@ packages:
colorette@2.0.19:
resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
command-line-args@5.2.1:
resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==}
engines: {node: '>=4.0.0'}
command-line-usage@6.1.3:
resolution: {integrity: sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==}
engines: {node: '>=8.0.0'}
commander@10.0.1:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'}
@@ -1894,10 +1859,6 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cron-parser@4.9.0:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'}
cross-inspect@1.0.1:
resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==}
engines: {node: '>=16.0.0'}
@@ -1955,6 +1916,10 @@ packages:
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1981,6 +1946,11 @@ packages:
devalue@5.6.3:
resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==}
directory-tree@3.6.0:
resolution: {integrity: sha512-rTMWs+zxr0QEbzQKRfwV6SeEy+zIHFkorskI4bhG2o7ayr82c+FC7yWg3yLpurgp6Hs2NGy1NWrKIaDodr2r8A==}
engines: {node: '>=10.0'}
hasBin: true
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -2144,6 +2114,10 @@ packages:
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@@ -2265,6 +2239,13 @@ packages:
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fast-xml-builder@1.0.0:
resolution: {integrity: sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==}
fast-xml-parser@5.4.2:
resolution: {integrity: sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==}
hasBin: true
fastify-plugin@5.1.0:
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
@@ -2294,6 +2275,10 @@ packages:
resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==}
engines: {node: '>=20'}
find-replace@3.0.0:
resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==}
engines: {node: '>=4.0.0'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -2421,6 +2406,10 @@ packages:
resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -2462,10 +2451,6 @@ packages:
resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==}
engines: {node: '>=12.22.0'}
ioredis@5.9.3:
resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==}
engines: {node: '>=12.22.0'}
ipaddr.js@2.3.0:
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
engines: {node: '>= 10'}
@@ -2606,28 +2591,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -2656,6 +2637,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
@@ -2675,10 +2659,6 @@ packages:
lru-queue@0.1.0:
resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -2770,13 +2750,6 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
msgpackr-extract@3.0.3:
resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==}
hasBin: true
msgpackr@1.11.5:
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -2788,17 +2761,10 @@ packages:
next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-addon-api@8.6.0:
resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
engines: {node: ^18 || ^20 || >= 21}
node-gyp-build-optional-packages@5.2.2:
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
hasBin: true
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
@@ -3024,6 +2990,10 @@ packages:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
reduce-flatten@2.0.0:
resolution: {integrity: sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==}
engines: {node: '>=6'}
relative-time-format@1.1.12:
resolution: {integrity: sha512-qaZBjmRIuXLfuLnzgqpFdBPa5W0euSX1tMnoMUHGPphLwJmrt8xbNiOIHrlvYOD6oNJ0M5owPCZyPibI8de5pQ==}
@@ -3114,11 +3084,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
@@ -3180,9 +3145,21 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
strnum@2.2.0:
resolution: {integrity: sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==}
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
super-sitemap@1.0.7:
resolution: {integrity: sha512-fXUphLw0Tss29/SFP2irhWLNUCRbBNMf/XSsncvKpEM++CThfaPT8Bis3973RIb7KatiJAVfJtFH7xPyCp1AYw==}
peerDependencies:
svelte: '>=4.0.0 <6.0.0'
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
@@ -3240,6 +3217,10 @@ packages:
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
table-layout@1.0.2:
resolution: {integrity: sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==}
engines: {node: '>=8.0.0'}
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
@@ -3345,6 +3326,14 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
typical@4.0.0:
resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==}
engines: {node: '>=8'}
typical@5.2.0:
resolution: {integrity: sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==}
engines: {node: '>=8'}
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
@@ -3434,6 +3423,10 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wordwrapjs@4.0.1:
resolution: {integrity: sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==}
engines: {node: '>=8.0.0'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -4112,12 +4105,10 @@ snapshots:
'@img/sharp-win32-x64@0.33.5':
optional: true
'@internationalized/date@3.12.0':
'@internationalized/date@3.11.0':
dependencies:
'@swc/helpers': 0.5.19
'@ioredis/commands@1.5.0': {}
'@ioredis/commands@1.5.1': {}
'@isaacs/cliui@9.0.0': {}
@@ -4153,24 +4144,6 @@ snapshots:
'@lukeed/ms@2.0.2': {}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
optional: true
'@phc/format@1.0.0': {}
'@pinojs/redact@0.4.0': {}
@@ -4624,6 +4597,10 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ansi-styles@3.2.1:
dependencies:
color-convert: 1.9.3
argon2@0.43.1:
dependencies:
'@phc/format': 1.0.0
@@ -4632,6 +4609,10 @@ snapshots:
aria-query@5.3.1: {}
array-back@3.1.0: {}
array-back@4.0.2: {}
async@0.2.10: {}
atomic-sleep@1.0.0: {}
@@ -4661,11 +4642,11 @@ snapshots:
transitivePeerDependencies:
- debug
bits-ui@2.16.2(@internationalized/date@3.12.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7):
bits-ui@2.16.2(@internationalized/date@3.11.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7):
dependencies:
'@floating-ui/core': 1.7.5
'@floating-ui/dom': 1.7.6
'@internationalized/date': 3.12.0
'@internationalized/date': 3.11.0
esm-env: 1.2.2
runed: 0.35.1(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)
svelte: 5.53.7
@@ -4687,22 +4668,16 @@ snapshots:
buffer-from@1.1.2: {}
bullmq@5.70.4:
dependencies:
cron-parser: 4.9.0
ioredis: 5.9.3
msgpackr: 1.11.5
node-abort-controller: 3.1.1
semver: 7.7.4
tslib: 2.8.1
uuid: 11.1.0
transitivePeerDependencies:
- supports-color
ce-la-react@0.3.2(react@19.2.0):
dependencies:
react: 19.2.0
chalk@2.4.2:
dependencies:
ansi-styles: 3.2.1
escape-string-regexp: 1.0.5
supports-color: 5.5.0
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
@@ -4721,6 +4696,10 @@ snapshots:
cluster-key-slot@1.1.2: {}
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -4742,6 +4721,20 @@ snapshots:
colorette@2.0.19:
optional: true
command-line-args@5.2.1:
dependencies:
array-back: 3.1.0
find-replace: 3.0.0
lodash.camelcase: 4.3.0
typical: 4.0.0
command-line-usage@6.1.3:
dependencies:
array-back: 4.0.2
chalk: 2.4.2
table-layout: 1.0.2
typical: 5.2.0
commander@10.0.1:
optional: true
@@ -4764,10 +4757,6 @@ snapshots:
cookie@1.1.1: {}
cron-parser@4.9.0:
dependencies:
luxon: 3.7.2
cross-inspect@1.0.1:
dependencies:
tslib: 2.8.1
@@ -4820,6 +4809,8 @@ snapshots:
decimal.js@10.6.0: {}
deep-extend@0.6.0: {}
deep-is@0.1.4: {}
deepmerge@4.3.1: {}
@@ -4834,6 +4825,11 @@ snapshots:
devalue@5.6.3: {}
directory-tree@3.6.0:
dependencies:
command-line-args: 5.2.1
command-line-usage: 6.1.3
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@@ -5020,6 +5016,8 @@ snapshots:
escape-html@1.0.3: {}
escape-string-regexp@1.0.5: {}
escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)):
@@ -5175,6 +5173,13 @@ snapshots:
fast-uri@3.1.0: {}
fast-xml-builder@1.0.0: {}
fast-xml-parser@5.4.2:
dependencies:
fast-xml-builder: 1.0.0
strnum: 2.2.0
fastify-plugin@5.1.0: {}
fastify@5.7.4:
@@ -5215,6 +5220,10 @@ snapshots:
fast-querystring: 1.1.2
safe-regex2: 5.0.0
find-replace@3.0.0:
dependencies:
array-back: 3.1.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -5332,6 +5341,8 @@ snapshots:
graphql@16.13.1: {}
has-flag@3.0.0: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -5383,20 +5394,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
ioredis@5.9.3:
dependencies:
'@ioredis/commands': 1.5.0
cluster-key-slot: 1.1.2
debug: 4.4.3
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ipaddr.js@2.3.0: {}
is-arrayish@0.3.4: {}
@@ -5545,6 +5542,8 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash.camelcase@4.3.0: {}
lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {}
@@ -5560,8 +5559,6 @@ snapshots:
dependencies:
es5-ext: 0.10.64
luxon@3.7.2: {}
lz-string@1.5.0: {}
magic-string@0.30.21:
@@ -5646,37 +5643,14 @@ snapshots:
ms@2.1.3: {}
msgpackr-extract@3.0.3:
dependencies:
node-gyp-build-optional-packages: 5.2.2
optionalDependencies:
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3
optional: true
msgpackr@1.11.5:
optionalDependencies:
msgpackr-extract: 3.0.3
nanoid@3.3.11: {}
natural-compare@1.4.0: {}
next-tick@1.1.0: {}
node-abort-controller@3.1.1: {}
node-addon-api@8.6.0: {}
node-gyp-build-optional-packages@5.2.2:
dependencies:
detect-libc: 2.1.2
optional: true
node-gyp-build@4.8.4: {}
nodemailer@7.0.13: {}
@@ -5874,6 +5848,8 @@ snapshots:
dependencies:
redis-errors: 1.2.0
reduce-flatten@2.0.0: {}
relative-time-format@1.1.12: {}
require-from-string@2.0.2: {}
@@ -5972,8 +5948,6 @@ snapshots:
semver@7.7.3: {}
semver@7.7.4: {}
set-cookie-parser@2.7.2: {}
set-cookie-parser@3.0.1: {}
@@ -6045,10 +6019,22 @@ snapshots:
statuses@2.0.2: {}
strnum@2.2.0: {}
style-to-object@1.0.14:
dependencies:
inline-style-parser: 0.2.7
super-sitemap@1.0.7(svelte@5.53.7):
dependencies:
directory-tree: 3.6.0
fast-xml-parser: 5.4.2
svelte: 5.53.7
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
supports-preserve-symlinks-flag@1.0.0: {}
svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3):
@@ -6138,6 +6124,13 @@ snapshots:
tabbable@6.4.0: {}
table-layout@1.0.2:
dependencies:
array-back: 4.0.2
deep-extend: 0.6.0
typical: 5.2.0
wordwrapjs: 4.0.1
tailwind-merge@3.5.0: {}
tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1):
@@ -6234,6 +6227,10 @@ snapshots:
typescript@5.9.3: {}
typical@4.0.0: {}
typical@5.2.0: {}
ufo@1.6.3: {}
undici-types@7.18.2: {}
@@ -6288,6 +6285,11 @@ snapshots:
word-wrap@1.2.5: {}
wordwrapjs@4.0.1:
dependencies:
reduce-flatten: 2.0.0
typical: 5.2.0
wrappy@1.0.2: {}
ws@8.19.0: {}