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/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/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

View File

@@ -29,6 +29,7 @@ export const users = pgTable(
role: roleEnum("role").notNull().default("viewer"),
avatar: text("avatar").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),
email_verified: boolean("email_verified").notNull().default(false),
email_verify_token: text("email_verify_token"),

View File

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

View File

@@ -55,6 +55,7 @@ export const UserType = builder.objectRef<User>("User").implement({
is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
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"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
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 }),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
date_created: t.expose("date_created", { type: "DateTime" }),
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"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
photo: t.exposeString("photo", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }),
photos: t.expose("photos", { type: [ModelPhotoType] }),

View File

@@ -15,12 +15,19 @@ import { buildContext } from "./graphql/context";
import { db } from "./db/connection";
import { redis } from "./lib/auth";
import { logger } from "./lib/logger";
import { migrate } from "drizzle-orm/node-postgres/migrator";
const PORT = parseInt(process.env.PORT || "4000");
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:3000";
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 });
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,
"tag": "0002_remove_archived_recording_status",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1741420000000,
"tag": "0003_model_photo",
"breakpoints": true
}
]
}

View File

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

View File

@@ -190,13 +190,8 @@
inert={!isMobileMenuOpen || undefined}
>
<!-- 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 />
<span
class="text-xl font-extrabold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent"
>
{$_("brand.name")}
</span>
</div>
<div class="flex-1 py-6 px-5 space-y-6">

View File

@@ -145,7 +145,12 @@
{#if isViewerOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
<!-- 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 -->
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">

View File

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

View File

@@ -962,6 +962,11 @@ export default {
artist_name: "Artist name",
avatar: "Avatar",
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_hint: "Grants full admin access to the dashboard",
photos: "Photo gallery",

View File

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

View File

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

View File

@@ -24,27 +24,29 @@
<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 flex items-center gap-2 py-3 border-b border-border/40">
<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="text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0 mr-2"
class="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
>
{$_("admin.nav.back_mobile")}
</a>
{#each navLinks as link (link.href)}
<a
href={link.href}
class={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
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`}></span>
{link.name}
<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">

View File

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

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from "svelte";
import { goto } from "$app/navigation";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
@@ -16,18 +17,36 @@
const { data } = $props();
let title = $state(data.article.title);
let slug = $state(data.article.slug);
let excerpt = $state(data.article.excerpt ?? "");
let content = $state(data.article.content ?? "");
let category = $state(data.article.category ?? "");
let tags = $state<string[]>(data.article.tags ?? []);
let featured = $state(data.article.featured ?? false);
let title = $state(untrack(() => data.article.title));
let slug = $state(untrack(() => data.article.slug));
let excerpt = $state(untrack(() => data.article.excerpt ?? ""));
let content = $state(untrack(() => data.article.content ?? ""));
let category = $state(untrack(() => data.article.category ?? ""));
let tags = $state<string[]>(untrack(() => data.article.tags ?? []));
let featured = $state(untrack(() => data.article.featured ?? false));
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 authorId = $state(data.article.author?.id ?? "");
let imageId = $state<string | null>(untrack(() => data.article.image ?? null));
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 saving = $state(false);
let editorTab = $state<"write" | "preview">("write");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import SexyBackground from "$lib/components/background/background.svelte";
import { onMount } from "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";
@@ -27,20 +27,29 @@
const { data } = $props();
let recordings = $state(data.recordings);
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(data.authStatus.user!.first_name);
let lastName = $state(data.authStatus.user!.last_name);
let artistName = $state(data.authStatus.user!.artist_name);
let description = $state(data.authStatus.user!.description);
let tags = $state(data.authStatus.user!.tags ?? undefined);
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(data.authStatus.user!.email);
let email = $state(untrack(() => data.authStatus.user!.email));
let password = $state("");
let confirmPassword = $state("");

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,8 @@
let { open, recordedDevices, connectedDevices, onConfirm, onCancel }: Props = $props();
// 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
function isCompatible(recordedDevice: DeviceInfo, connectedDevice: BluetoothDevice): boolean {

View File

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

View File

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

View File

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