Compare commits
10 Commits
76d71ee7c3
...
af4a11b73c
| Author | SHA1 | Date | |
|---|---|---|---|
| af4a11b73c | |||
| 627ce75719 | |||
| 446e9f835b | |||
| 422f97417e | |||
| edee98b552 | |||
| b9b98f178f | |||
| dc1850126b | |||
| 4d81266cb1 | |||
| 2980c0b637 | |||
| 7af9c0d7ca |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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] }),
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
1
packages/backend/src/migrations/0003_model_photo.sql
Normal file
1
packages/backend/src/migrations/0003_model_photo.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "photo" text REFERENCES "files"("id") ON DELETE set null;
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user