Compare commits

..

10 Commits

Author SHA1 Message Date
af4a11b73c style: apply prettier formatting to svelte and ts files
Some checks failed
Build and Push Backend Image / build (push) Successful in 1m3s
Build and Push Frontend Image / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:49:43 +01:00
627ce75719 fix: restore \$state on SvelteMap in device-mapping-dialog
The variable is fully reassigned in an \$effect, so \$state is required
for reactivity. Suppress the no-unnecessary-state-wrap lint rule with a
comment explaining the reason.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:47:21 +01:00
446e9f835b fix: use writable \$derived for search inputs, remove unnecessary \$state wrap
- Replace \$state + \$effect pattern with writable \$derived (Svelte 5.25+)
  for all searchValue instances across list pages — cleaner and lint-compliant
- Remove now-unused untrack imports from those files
- Drop \$state() wrapper around SvelteMap in device-mapping-dialog
  (SvelteMap is already reactive)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:46:15 +01:00
422f97417e fix: resolve vite-plugin-svelte warnings
- image-viewer: replace backdrop div with button for a11y
- file-drop-zone: wrap prop check in \$effect to avoid state_referenced_locally
- about: use \$derived for stats array
- magazine: use \$derived for featuredArticle
- play: add role/keyboard support to seek bar slider; fix \$state on SvelteMap in device-mapping-dialog
- admin/videos/[id]: add <track kind="captions"> to video element

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:41:58 +01:00
edee98b552 fix: use untrack() in \$state initialisers to silence state_referenced_locally warnings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:31:51 +01:00
b9b98f178f fix: sync reactive state with data prop using \$effect
Replaces bare \$state(data.x) initialisers (which only capture the
initial value) with \$state + \$effect pairs so that state stays in sync
whenever page data is invalidated or the URL changes. Affects all list
pages (searchValue) and all edit/detail pages (form fields).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:06:30 +01:00
dc1850126b fix: run DB migrations automatically at backend startup
Instead of relying on a manual `pnpm db:migrate` step (which was
connecting to a different postgres than the Docker container), the
backend now calls drizzle migrate() before the server starts. This
ensures migrations always run against the correct database on startup.

Also fixes the Dockerfile to copy migrations into dist/migrations so
the path resolves correctly in the compiled output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:00:36 +01:00
4d81266cb1 feat: add dedicated model photo separate from avatar
Adds a `photo` field to the users table (and a migration) that serves
as a dedicated profile/card image for models. This is now used in model
cards and on the model single page, while `avatar` is reserved for
comments, article authors, and the user profile page.

- DB: `photo` column on `users` with FK to `files`
- GraphQL: exposed on ModelType, UserType, AdminUserDetailType; photoId arg on adminUpdateUser
- Services: photo field in MODELS_QUERY, MODEL_BY_SLUG_QUERY, ADMIN_GET/UPDATE_USER
- Frontend: model cards and single page use `photo ?? avatar` fallback
- Admin: model photo upload section in user edit page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 10:54:27 +01:00
2980c0b637 fix: remove brand name text from mobile flyout header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 10:42:22 +01:00
7af9c0d7ca fix: fix admin mobile nav overflow breaking layout on small screens
Mobile nav now scrolls horizontally with hidden scrollbar; nav items
don't shrink and show icon-only on xs, icon+label on sm and up.
Added scrollbar-none utility to app.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 10:39:12 +01:00
32 changed files with 238 additions and 83 deletions

View File

@@ -55,7 +55,7 @@ COPY --from=builder --chown=node:node /app/package.json ./package.json
COPY --from=builder --chown=node:node /app/packages/backend/dist ./packages/backend/dist COPY --from=builder --chown=node:node /app/packages/backend/dist ./packages/backend/dist
COPY --from=builder --chown=node:node /app/packages/backend/node_modules ./packages/backend/node_modules COPY --from=builder --chown=node:node /app/packages/backend/node_modules ./packages/backend/node_modules
COPY --from=builder --chown=node:node /app/packages/backend/package.json ./packages/backend/package.json COPY --from=builder --chown=node:node /app/packages/backend/package.json ./packages/backend/package.json
COPY --from=builder --chown=node:node /app/packages/backend/src/migrations ./packages/backend/migrations COPY --from=builder --chown=node:node /app/packages/backend/src/migrations ./packages/backend/dist/migrations
RUN mkdir -p /data/uploads && chown node:node /data/uploads RUN mkdir -p /data/uploads && chown node:node /data/uploads

View File

@@ -29,6 +29,7 @@ export const users = pgTable(
role: roleEnum("role").notNull().default("viewer"), role: roleEnum("role").notNull().default("viewer"),
avatar: text("avatar").references(() => files.id, { onDelete: "set null" }), avatar: text("avatar").references(() => files.id, { onDelete: "set null" }),
banner: text("banner").references(() => files.id, { onDelete: "set null" }), banner: text("banner").references(() => files.id, { onDelete: "set null" }),
photo: text("photo").references(() => files.id, { onDelete: "set null" }),
is_admin: boolean("is_admin").notNull().default(false), is_admin: boolean("is_admin").notNull().default(false),
email_verified: boolean("email_verified").notNull().default(false), email_verified: boolean("email_verified").notNull().default(false),
email_verify_token: text("email_verify_token"), email_verify_token: text("email_verify_token"),

View File

@@ -134,6 +134,7 @@ builder.mutationField("adminUpdateUser", (t) =>
artistName: t.arg.string(), artistName: t.arg.string(),
avatarId: t.arg.string(), avatarId: t.arg.string(),
bannerId: t.arg.string(), bannerId: t.arg.string(),
photoId: t.arg.string(),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
requireAdmin(ctx); requireAdmin(ctx);
@@ -149,6 +150,7 @@ builder.mutationField("adminUpdateUser", (t) =>
updates.artist_name = args.artistName; updates.artist_name = args.artistName;
if (args.avatarId !== undefined && args.avatarId !== null) updates.avatar = args.avatarId; if (args.avatarId !== undefined && args.avatarId !== null) updates.avatar = args.avatarId;
if (args.bannerId !== undefined && args.bannerId !== null) updates.banner = args.bannerId; if (args.bannerId !== undefined && args.bannerId !== null) updates.banner = args.bannerId;
if (args.photoId !== undefined && args.photoId !== null) updates.photo = args.photoId;
const updated = await ctx.db const updated = await ctx.db
.update(users) .update(users)

View File

@@ -55,6 +55,7 @@ export const UserType = builder.objectRef<User>("User").implement({
is_admin: t.exposeBoolean("is_admin"), is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }), banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"), email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
}), }),
@@ -75,6 +76,7 @@ export const CurrentUserType = builder.objectRef<User>("CurrentUser").implement(
is_admin: t.exposeBoolean("is_admin"), is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }), banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"), email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
}), }),
@@ -133,6 +135,7 @@ export const ModelType = builder.objectRef<Model>("Model").implement({
description: t.exposeString("description", { nullable: true }), description: t.exposeString("description", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }), banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }), tags: t.exposeStringList("tags", { nullable: true }),
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }), photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }),
@@ -416,6 +419,7 @@ export const AdminUserDetailType = builder.objectRef<AdminUserDetail>("AdminUser
is_admin: t.exposeBoolean("is_admin"), is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }), banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"), email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }), date_created: t.expose("date_created", { type: "DateTime" }),
photos: t.expose("photos", { type: [ModelPhotoType] }), photos: t.expose("photos", { type: [ModelPhotoType] }),

View File

@@ -15,12 +15,19 @@ import { buildContext } from "./graphql/context";
import { db } from "./db/connection"; import { db } from "./db/connection";
import { redis } from "./lib/auth"; import { redis } from "./lib/auth";
import { logger } from "./lib/logger"; import { logger } from "./lib/logger";
import { migrate } from "drizzle-orm/node-postgres/migrator";
const PORT = parseInt(process.env.PORT || "4000"); const PORT = parseInt(process.env.PORT || "4000");
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads"; const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:3000"; const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:3000";
async function main() { async function main() {
// Run pending DB migrations before starting the server
const migrationsFolder = path.join(__dirname, "migrations");
logger.info(`Running migrations from ${migrationsFolder}`);
await migrate(db, { migrationsFolder });
logger.info("Migrations complete");
const fastify = Fastify({ loggerInstance: logger }); const fastify = Fastify({ loggerInstance: logger });
await fastify.register(fastifyCookie, { await fastify.register(fastifyCookie, {

View File

@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "photo" text REFERENCES "files"("id") ON DELETE set null;

View File

@@ -22,6 +22,13 @@
"when": 1741337600000, "when": 1741337600000,
"tag": "0002_remove_archived_recording_status", "tag": "0002_remove_archived_recording_status",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1741420000000,
"tag": "0003_model_photo",
"breakpoints": true
} }
] ]
} }

View File

@@ -3,6 +3,13 @@
@plugin "@iconify/tailwind4"; @plugin "@iconify/tailwind4";
@utility scrollbar-none {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
@custom-variant hover (&:hover); @custom-variant hover (&:hover);

View File

@@ -190,13 +190,8 @@
inert={!isMobileMenuOpen || undefined} inert={!isMobileMenuOpen || undefined}
> >
<!-- Panel header --> <!-- Panel header -->
<div class="flex items-center gap-3 px-5 h-16 shrink-0 border-b border-border/30"> <div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
<Logo /> <Logo />
<span
class="text-xl font-extrabold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent"
>
{$_("brand.name")}
</span>
</div> </div>
<div class="flex-1 py-6 px-5 space-y-6"> <div class="flex-1 py-6 px-5 space-y-6">

View File

@@ -145,7 +145,12 @@
{#if isViewerOpen} {#if isViewerOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in"> <div class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
<!-- Backdrop --> <!-- Backdrop -->
<div class="absolute inset-0 bg-black/95 backdrop-blur-xl" onclick={closeViewer}></div> <button
type="button"
class="absolute inset-0 bg-black/95 backdrop-blur-xl cursor-default"
onclick={closeViewer}
aria-label="Close viewer"
></button>
<!-- Viewer Content --> <!-- Viewer Content -->
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up"> <div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">

View File

@@ -23,11 +23,13 @@
...rest ...rest
}: FileDropZoneProps = $props(); }: FileDropZoneProps = $props();
if (maxFiles !== undefined && fileCount === undefined) { $effect(() => {
console.warn( if (maxFiles !== undefined && fileCount === undefined) {
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt", console.warn(
); "Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
} );
}
});
let uploading = $state(false); let uploading = $state(false);

View File

@@ -962,6 +962,11 @@ export default {
artist_name: "Artist name", artist_name: "Artist name",
avatar: "Avatar", avatar: "Avatar",
banner: "Banner", banner: "Banner",
model_photo: "Model photo",
model_photo_hint:
"Used in model cards and on the model profile page. Avatar is used for comments and article authors.",
model_photo_uploaded: "Model photo uploaded",
model_photo_failed: "Model photo upload failed",
is_admin: "Administrator", is_admin: "Administrator",
is_admin_hint: "Grants full admin access to the dashboard", is_admin_hint: "Grants full admin access to the dashboard",
photos: "Photo gallery", photos: "Photo gallery",

View File

@@ -491,6 +491,7 @@ const MODELS_QUERY = gql`
description description
avatar avatar
banner banner
photo
tags tags
date_created date_created
photos { photos {
@@ -540,6 +541,7 @@ const MODEL_BY_SLUG_QUERY = gql`
description description
avatar avatar
banner banner
photo
tags tags
date_created date_created
photos { photos {
@@ -1151,6 +1153,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
$artistName: String $artistName: String
$avatarId: String $avatarId: String
$bannerId: String $bannerId: String
$photoId: String
) { ) {
adminUpdateUser( adminUpdateUser(
userId: $userId userId: $userId
@@ -1161,6 +1164,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
artistName: $artistName artistName: $artistName
avatarId: $avatarId avatarId: $avatarId
bannerId: $bannerId bannerId: $bannerId
photoId: $photoId
) { ) {
id id
email email
@@ -1171,6 +1175,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
is_admin is_admin
avatar avatar
banner banner
photo
date_created date_created
} }
} }
@@ -1185,6 +1190,7 @@ export async function adminUpdateUser(input: {
artistName?: string; artistName?: string;
avatarId?: string; avatarId?: string;
bannerId?: string; bannerId?: string;
photoId?: string;
}) { }) {
return loggedApiCall( return loggedApiCall(
"adminUpdateUser", "adminUpdateUser",
@@ -1228,6 +1234,7 @@ const ADMIN_GET_USER_QUERY = gql`
is_admin is_admin
avatar avatar
banner banner
photo
description description
tags tags
email_verified email_verified

View File

@@ -7,7 +7,7 @@
const { data } = $props(); const { data } = $props();
const stats = [ const stats = $derived([
{ {
icon: "icon-[ri--user-heart-line]", icon: "icon-[ri--user-heart-line]",
value: data.stats.viewers_count, value: data.stats.viewers_count,
@@ -28,7 +28,7 @@
value: $_("about.stats.yearsFormatted", { values: { years: 5 } }), value: $_("about.stats.yearsFormatted", { values: { years: 5 } }),
label: $_("about.stats.experience"), label: $_("about.stats.experience"),
}, },
]; ]);
const team = [ const team = [
{ {

View File

@@ -24,26 +24,28 @@
<div class="min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5"> <div class="min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
<div class="container mx-auto px-4"> <div class="container mx-auto px-4">
<!-- Mobile top nav --> <!-- Mobile top nav -->
<div class="lg:hidden flex items-center gap-2 py-3 border-b border-border/40"> <div class="lg:hidden border-b border-border/40">
<a <div class="flex items-center gap-1 overflow-x-auto py-2 scrollbar-none">
href="/"
class="text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0 mr-2"
>
{$_("admin.nav.back_mobile")}
</a>
{#each navLinks as link (link.href)}
<a <a
href={link.href} href="/"
class={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${ class="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
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> {$_("admin.nav.back_mobile")}
{link.name}
</a> </a>
{/each} {#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> </div>
<!-- Desktop layout --> <!-- Desktop layout -->

View File

@@ -20,7 +20,7 @@
let deleteTarget: Article | null = $state(null); let deleteTarget: Article | null = $state(null);
let deleteOpen = $state(false); let deleteOpen = $state(false);
let deleting = $state(false); let deleting = $state(false);
let searchValue = $state(data.search ?? ""); let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>; let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) { function debounceSearch(value: string) {

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
@@ -16,18 +17,36 @@
const { data } = $props(); const { data } = $props();
let title = $state(data.article.title); let title = $state(untrack(() => data.article.title));
let slug = $state(data.article.slug); let slug = $state(untrack(() => data.article.slug));
let excerpt = $state(data.article.excerpt ?? ""); let excerpt = $state(untrack(() => data.article.excerpt ?? ""));
let content = $state(data.article.content ?? ""); let content = $state(untrack(() => data.article.content ?? ""));
let category = $state(data.article.category ?? ""); let category = $state(untrack(() => data.article.category ?? ""));
let tags = $state<string[]>(data.article.tags ?? []); let tags = $state<string[]>(untrack(() => data.article.tags ?? []));
let featured = $state(data.article.featured ?? false); let featured = $state(untrack(() => data.article.featured ?? false));
let publishDate = $state( let publishDate = $state(
data.article.publish_date ? new Date(data.article.publish_date).toISOString().slice(0, 16) : "", untrack(() =>
data.article.publish_date
? new Date(data.article.publish_date).toISOString().slice(0, 16)
: "",
),
); );
let imageId = $state<string | null>(data.article.image ?? null); let imageId = $state<string | null>(untrack(() => data.article.image ?? null));
let authorId = $state(data.article.author?.id ?? ""); let authorId = $state(untrack(() => data.article.author?.id ?? ""));
$effect(() => {
title = data.article.title;
slug = data.article.slug;
excerpt = data.article.excerpt ?? "";
content = data.article.content ?? "";
category = data.article.category ?? "";
tags = data.article.tags ?? [];
featured = data.article.featured ?? false;
publishDate = data.article.publish_date
? new Date(data.article.publish_date).toISOString().slice(0, 16)
: "";
imageId = data.article.image ?? null;
authorId = data.article.author?.id ?? "";
});
let selectedAuthor = $derived(data.authors.find((a) => a.id === authorId) ?? null); let selectedAuthor = $derived(data.authors.find((a) => a.id === authorId) ?? null);
let saving = $state(false); let saving = $state(false);
let editorTab = $state<"write" | "preview">("write"); let editorTab = $state<"write" | "preview">("write");

View File

@@ -17,7 +17,7 @@
let deleteTarget: { id: number; comment: string } | null = $state(null); let deleteTarget: { id: number; comment: string } | null = $state(null);
let deleteOpen = $state(false); let deleteOpen = $state(false);
let deleting = $state(false); let deleting = $state(false);
let searchValue = $state(data.search ?? ""); let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>; let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) { function debounceSearch(value: string) {

View File

@@ -18,7 +18,7 @@
let deleteTarget: Recording | null = $state(null); let deleteTarget: Recording | null = $state(null);
let deleteOpen = $state(false); let deleteOpen = $state(false);
let deleting = $state(false); let deleting = $state(false);
let searchValue = $state(data.search ?? ""); let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>; let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) { function debounceSearch(value: string) {

View File

@@ -15,7 +15,7 @@
const { data } = $props(); const { data } = $props();
let searchValue = $state(data.search ?? ""); let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>; let searchTimeout: ReturnType<typeof setTimeout>;
let deleteTarget: User | null = $state(null); let deleteTarget: User | null = $state(null);
let deleteOpen = $state(false); let deleteOpen = $state(false);

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { invalidateAll } from "$app/navigation"; import { invalidateAll } from "$app/navigation";
import { import {
@@ -16,13 +17,23 @@
const { data } = $props(); const { data } = $props();
let firstName = $state(data.user.first_name ?? ""); let firstName = $state(untrack(() => data.user.first_name ?? ""));
let lastName = $state(data.user.last_name ?? ""); let lastName = $state(untrack(() => data.user.last_name ?? ""));
let artistName = $state(data.user.artist_name ?? ""); let artistName = $state(untrack(() => data.user.artist_name ?? ""));
let avatarId = $state<string | null>(data.user.avatar ?? null); let avatarId = $state<string | null>(untrack(() => data.user.avatar ?? null));
let bannerId = $state<string | null>(data.user.banner ?? null); let bannerId = $state<string | null>(untrack(() => data.user.banner ?? null));
let isAdmin = $state(data.user.is_admin ?? false); let photoId = $state<string | null>(untrack(() => data.user.photo ?? null));
let isAdmin = $state(untrack(() => data.user.is_admin ?? false));
let saving = $state(false); let saving = $state(false);
$effect(() => {
firstName = data.user.first_name ?? "";
lastName = data.user.last_name ?? "";
artistName = data.user.artist_name ?? "";
avatarId = data.user.avatar ?? null;
bannerId = data.user.banner ?? null;
photoId = data.user.photo ?? null;
isAdmin = data.user.is_admin ?? false;
});
async function handleAvatarUpload(files: File[]) { async function handleAvatarUpload(files: File[]) {
const file = files[0]; const file = files[0];
@@ -52,6 +63,20 @@
} }
} }
async function handlePhotoUpload2(files: File[]) {
const file = files[0];
if (!file) return;
const fd = new FormData();
fd.append("file", file);
try {
const res = await uploadFile(fd);
photoId = res.id;
toast.success($_("admin.user_edit.model_photo_uploaded"));
} catch {
toast.error($_("admin.user_edit.model_photo_failed"));
}
}
async function handlePhotoUpload(files: File[]) { async function handlePhotoUpload(files: File[]) {
for (const file of files) { for (const file of files) {
const fd = new FormData(); const fd = new FormData();
@@ -88,6 +113,7 @@
artistName: artistName || undefined, artistName: artistName || undefined,
avatarId: avatarId || undefined, avatarId: avatarId || undefined,
bannerId: bannerId || undefined, bannerId: bannerId || undefined,
photoId: photoId || undefined,
isAdmin, isAdmin,
}); });
toast.success($_("admin.user_edit.save_success")); toast.success($_("admin.user_edit.save_success"));
@@ -158,6 +184,20 @@
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} /> <FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} />
</div> </div>
<!-- Model photo (used in cards & model page, not for avatar/comments) -->
<div class="space-y-1.5">
<Label>{$_("admin.user_edit.model_photo")}</Label>
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.model_photo_hint")}</p>
{#if photoId}
<img
src={getAssetUrl(photoId, "preview")}
alt=""
class="w-full h-48 rounded object-cover mb-2"
/>
{/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload2} />
</div>
<!-- Admin flag --> <!-- Admin flag -->
<label <label
class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors" class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"

View File

@@ -17,7 +17,7 @@
let deleteTarget: Video | null = $state(null); let deleteTarget: Video | null = $state(null);
let deleteOpen = $state(false); let deleteOpen = $state(false);
let deleting = $state(false); let deleting = $state(false);
let searchValue = $state(data.search ?? ""); let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>; let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) { function debounceSearch(value: string) {

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
@@ -15,20 +16,36 @@
const { data } = $props(); const { data } = $props();
let title = $state(data.video.title); let title = $state(untrack(() => data.video.title));
let slug = $state(data.video.slug); let slug = $state(untrack(() => data.video.slug));
let description = $state(data.video.description ?? ""); let description = $state(untrack(() => data.video.description ?? ""));
let tags = $state<string[]>(data.video.tags ?? []); let tags = $state<string[]>(untrack(() => data.video.tags ?? []));
let premium = $state(data.video.premium ?? false); let premium = $state(untrack(() => data.video.premium ?? false));
let featured = $state(data.video.featured ?? false); let featured = $state(untrack(() => data.video.featured ?? false));
let uploadDate = $state( let uploadDate = $state(
data.video.upload_date ? new Date(data.video.upload_date).toISOString().slice(0, 16) : "", untrack(() =>
data.video.upload_date ? new Date(data.video.upload_date).toISOString().slice(0, 16) : "",
),
); );
let imageId = $state<string | null>(data.video.image ?? null); let imageId = $state<string | null>(untrack(() => data.video.image ?? null));
let movieId = $state<string | null>(data.video.movie ?? null); let movieId = $state<string | null>(untrack(() => data.video.movie ?? null));
let selectedModelIds = $state<string[]>( let selectedModelIds = $state<string[]>(
data.video.models?.map((m: { id: string }) => m.id) ?? [], untrack(() => data.video.models?.map((m: { id: string }) => m.id) ?? []),
); );
$effect(() => {
title = data.video.title;
slug = data.video.slug;
description = data.video.description ?? "";
tags = data.video.tags ?? [];
premium = data.video.premium ?? false;
featured = data.video.featured ?? false;
uploadDate = data.video.upload_date
? new Date(data.video.upload_date).toISOString().slice(0, 16)
: "";
imageId = data.video.image ?? null;
movieId = data.video.movie ?? null;
selectedModelIds = data.video.models?.map((m: { id: string }) => m.id) ?? [];
});
let saving = $state(false); let saving = $state(false);
async function handleImageUpload(files: File[]) { async function handleImageUpload(files: File[]) {
@@ -139,7 +156,9 @@
poster={imageId ? (getAssetUrl(imageId, "preview") ?? undefined) : undefined} poster={imageId ? (getAssetUrl(imageId, "preview") ?? undefined) : undefined}
controls controls
class="w-full rounded-lg bg-black max-h-72 mb-2" class="w-full rounded-lg bg-black max-h-72 mb-2"
></video> >
<track kind="captions" />
</video>
{/if} {/if}
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} /> <FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
</div> </div>

View File

@@ -17,11 +17,12 @@
const timeAgo = new TimeAgo("en"); const timeAgo = new TimeAgo("en");
const { data } = $props(); const { data } = $props();
let searchValue = $state(data.search ?? ""); let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>; let searchTimeout: ReturnType<typeof setTimeout>;
const featuredArticle = const featuredArticle = $derived(
data.page === 1 && !data.search && !data.category ? data.items.find((a) => a.featured) : null; data.page === 1 && !data.search && !data.category ? data.items.find((a) => a.featured) : null,
);
function debounceSearch(value: string) { function debounceSearch(value: string) {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);

View File

@@ -12,7 +12,7 @@
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import SexyBackground from "$lib/components/background/background.svelte"; import SexyBackground from "$lib/components/background/background.svelte";
import { onMount } from "svelte"; import { onMount, untrack } from "svelte";
import { goto, invalidateAll } from "$app/navigation"; import { goto, invalidateAll } from "$app/navigation";
import { getAssetUrl, isModel } from "$lib/api"; import { getAssetUrl, isModel } from "$lib/api";
import * as Alert from "$lib/components/ui/alert"; import * as Alert from "$lib/components/ui/alert";
@@ -27,20 +27,29 @@
const { data } = $props(); const { data } = $props();
let recordings = $state(data.recordings); let recordings = $state(untrack(() => data.recordings));
let deleteTarget = $state<string | null>(null); let deleteTarget = $state<string | null>(null);
let deleteOpen = $state(false); let deleteOpen = $state(false);
let deleting = $state(false); let deleting = $state(false);
let activeTab = $state("settings"); let activeTab = $state("settings");
let firstName = $state(data.authStatus.user!.first_name); let firstName = $state(untrack(() => data.authStatus.user!.first_name));
let lastName = $state(data.authStatus.user!.last_name); let lastName = $state(untrack(() => data.authStatus.user!.last_name));
let artistName = $state(data.authStatus.user!.artist_name); let artistName = $state(untrack(() => data.authStatus.user!.artist_name));
let description = $state(data.authStatus.user!.description); let description = $state(untrack(() => data.authStatus.user!.description));
let tags = $state(data.authStatus.user!.tags ?? undefined); 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(data.authStatus.user!.email); let email = $state(untrack(() => data.authStatus.user!.email));
let password = $state(""); let password = $state("");
let confirmPassword = $state(""); let confirmPassword = $state("");

View File

@@ -14,7 +14,7 @@
const { data } = $props(); const { data } = $props();
let searchValue = $state(data.search ?? ""); let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>; let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) { function debounceSearch(value: string) {
@@ -110,7 +110,7 @@
> >
<div class="relative"> <div class="relative">
<img <img
src={getAssetUrl(model.avatar, "preview")} src={getAssetUrl(model.photo ?? model.avatar, "preview")}
alt={model.artist_name} alt={model.artist_name}
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-300 bg-muted" class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
/> />

View File

@@ -31,7 +31,7 @@
<Meta <Meta
title={data.model.artist_name ?? ""} title={data.model.artist_name ?? ""}
description={data.model.description} description={data.model.description}
image={getAssetUrl(data.model.avatar, "medium")!} image={getAssetUrl(data.model.photo ?? data.model.avatar, "medium")!}
/> />
<div <div
@@ -67,7 +67,7 @@
<!-- Profile Image --> <!-- Profile Image -->
<div class="relative"> <div class="relative">
<img <img
src={getAssetUrl(data.model.avatar, "thumbnail")} src={getAssetUrl(data.model.photo ?? data.model.avatar, "preview")}
alt="${data.model.artist_name}" alt="${data.model.artist_name}"
class="w-32 h-32 rounded-2xl object-cover ring-4 ring-primary/20" class="w-32 h-32 rounded-2xl object-cover ring-4 ring-primary/20"
/> />

View File

@@ -480,12 +480,24 @@
.padStart(2, "0")} .padStart(2, "0")}
</span> </span>
<div <div
role="slider"
tabindex="0"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={data.recording.duration}
aria-valuenow={playbackProgress}
class="flex-1 h-2 bg-muted rounded-full overflow-hidden cursor-pointer relative" class="flex-1 h-2 bg-muted rounded-full overflow-hidden cursor-pointer relative"
onclick={(e) => { onclick={(e) => {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const percentage = ((e.clientX - rect.left) / rect.width) * 100; const percentage = ((e.clientX - rect.left) / rect.width) * 100;
seek(percentage); seek(percentage);
}} }}
onkeydown={(e) => {
if (e.key === "ArrowRight")
seek(((playbackProgress + 1) / data.recording.duration) * 100);
else if (e.key === "ArrowLeft")
seek(((playbackProgress - 1) / data.recording.duration) * 100);
}}
> >
<div <div
class="absolute inset-0 bg-gradient-to-r from-primary to-accent transition-all duration-150" class="absolute inset-0 bg-gradient-to-r from-primary to-accent transition-all duration-150"

View File

@@ -16,7 +16,8 @@
let { open, recordedDevices, connectedDevices, onConfirm, onCancel }: Props = $props(); let { open, recordedDevices, connectedDevices, onConfirm, onCancel }: Props = $props();
// Device mappings: recorded device name -> connected device // Device mappings: recorded device name -> connected device
let mappings = new SvelteMap<string, BluetoothDevice>(); // eslint-disable-next-line svelte/no-unnecessary-state-wrap -- variable is reassigned in $effect, $state is required
let mappings = $state(new SvelteMap<string, BluetoothDevice>());
// Check if a connected device is compatible with a recorded device // Check if a connected device is compatible with a recorded device
function isCompatible(recordedDevice: DeviceInfo, connectedDevice: BluetoothDevice): boolean { function isCompatible(recordedDevice: DeviceInfo, connectedDevice: BluetoothDevice): boolean {

View File

@@ -17,7 +17,7 @@
const timeAgo = new TimeAgo("en"); const timeAgo = new TimeAgo("en");
const { data } = $props(); const { data } = $props();
let searchValue = $state(data.search ?? ""); let searchValue = $derived(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>; let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) { function debounceSearch(value: string) {

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card"; import { Card, CardContent } from "$lib/components/ui/card";
@@ -28,8 +29,12 @@
const { data } = $props(); const { data } = $props();
const timeAgo = new TimeAgo("en"); const timeAgo = new TimeAgo("en");
let isLiked = $state(data.likeStatus.liked); let isLiked = $state(untrack(() => data.likeStatus.liked));
let likesCount = $state(data.video.likes_count || 0); let likesCount = $state(untrack(() => data.video.likes_count || 0));
$effect(() => {
isLiked = data.likeStatus.liked;
likesCount = data.video.likes_count || 0;
});
let isLikeLoading = $state(false); let isLikeLoading = $state(false);
let newComment = $state(""); let newComment = $state("");
let showComments = $state(true); let showComments = $state(true);

View File

@@ -27,6 +27,8 @@ export interface User {
avatar: string | null; avatar: string | null;
/** UUID of the banner file */ /** UUID of the banner file */
banner: string | null; banner: string | null;
/** UUID of the dedicated model profile/card image */
photo: string | null;
email_verified: boolean; email_verified: boolean;
date_created: Date; date_created: Date;
} }
@@ -81,6 +83,8 @@ export interface Model {
description: string | null; description: string | null;
avatar: string | null; avatar: string | null;
banner: string | null; banner: string | null;
/** UUID of the dedicated model profile/card image (distinct from avatar) */
photo: string | null;
tags: string[] | null; tags: string[] | null;
date_created: Date; date_created: Date;
photos?: ModelPhoto[]; photos?: ModelPhoto[];