refactor: align article author with VideoModel, streamline selects, fix flyout inert
- Remove ArticleAuthor type; article.author now reuses VideoModel (id, artist_name, slug, avatar) - updateArticle accepts authorId; author selectable in admin article edit page - Article edit: single Select with bind:value + $derived selectedAuthor display - Video edit: replace pill toggles with Select type="multiple" bind:value for models - Video table: replace inline badge spans with Badge component - Magazine: display artist_name throughout, author bio links to model profile - Fix flyout aria-hidden warning: replace with inert attribute Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,10 +9,10 @@ async function enrichArticle(db: any, article: any) {
|
||||
if (article.author) {
|
||||
const authorUser = await db
|
||||
.select({
|
||||
first_name: users.first_name,
|
||||
last_name: users.last_name,
|
||||
id: users.id,
|
||||
artist_name: users.artist_name,
|
||||
slug: users.slug,
|
||||
avatar: users.avatar,
|
||||
description: users.description,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, article.author))
|
||||
@@ -132,6 +132,7 @@ builder.mutationField("updateArticle", (t) =>
|
||||
excerpt: t.arg.string(),
|
||||
content: t.arg.string(),
|
||||
imageId: t.arg.string(),
|
||||
authorId: t.arg.string(),
|
||||
tags: t.arg.stringList(),
|
||||
category: t.arg.string(),
|
||||
featured: t.arg.boolean(),
|
||||
@@ -145,6 +146,7 @@ builder.mutationField("updateArticle", (t) =>
|
||||
if (args.excerpt !== undefined) updates.excerpt = args.excerpt;
|
||||
if (args.content !== undefined) updates.content = args.content;
|
||||
if (args.imageId !== undefined) updates.image = args.imageId;
|
||||
if (args.authorId !== undefined) updates.author = args.authorId;
|
||||
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
||||
if (args.category !== undefined) updates.category = args.category;
|
||||
if (args.featured !== undefined && args.featured !== null) updates.featured = args.featured;
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
Video,
|
||||
ModelPhoto,
|
||||
Model,
|
||||
ArticleAuthor,
|
||||
Article,
|
||||
CommentUser,
|
||||
Comment,
|
||||
@@ -139,15 +138,6 @@ export const ModelType = builder.objectRef<Model>("Model").implement({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ArticleAuthorType = builder.objectRef<ArticleAuthor>("ArticleAuthor").implement({
|
||||
fields: (t) => ({
|
||||
first_name: t.exposeString("first_name", { nullable: true }),
|
||||
last_name: t.exposeString("last_name", { nullable: true }),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ArticleType = builder.objectRef<Article>("Article").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
@@ -160,7 +150,7 @@ export const ArticleType = builder.objectRef<Article>("Article").implement({
|
||||
publish_date: t.expose("publish_date", { type: "DateTime" }),
|
||||
category: t.exposeString("category", { nullable: true }),
|
||||
featured: t.exposeBoolean("featured", { nullable: true }),
|
||||
author: t.expose("author", { type: ArticleAuthorType, nullable: true }),
|
||||
author: t.expose("author", { type: VideoModelType, nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
<!-- Flyout panel -->
|
||||
<div
|
||||
class={`fixed inset-y-0 left-0 z-50 w-80 max-w-[85vw] bg-card/95 backdrop-blur-xl shadow-2xl shadow-primary/20 border-r border-border/30 transform transition-transform duration-300 ease-in-out lg:hidden overflow-y-auto flex flex-col ${isMobileMenuOpen ? "translate-x-0" : "-translate-x-full"}`}
|
||||
aria-hidden={!isMobileMenuOpen}
|
||||
inert={!isMobileMenuOpen || undefined}
|
||||
>
|
||||
<!-- Panel header -->
|
||||
<div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
|
||||
|
||||
@@ -229,10 +229,10 @@ const ARTICLES_QUERY = gql`
|
||||
category
|
||||
featured
|
||||
author {
|
||||
first_name
|
||||
last_name
|
||||
id
|
||||
artist_name
|
||||
slug
|
||||
avatar
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,10 +259,10 @@ const ARTICLE_BY_SLUG_QUERY = gql`
|
||||
category
|
||||
featured
|
||||
author {
|
||||
first_name
|
||||
last_name
|
||||
id
|
||||
artist_name
|
||||
slug
|
||||
avatar
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1387,8 +1387,9 @@ const ADMIN_LIST_ARTICLES_QUERY = gql`
|
||||
featured
|
||||
content
|
||||
author {
|
||||
first_name
|
||||
last_name
|
||||
id
|
||||
artist_name
|
||||
slug
|
||||
avatar
|
||||
}
|
||||
}
|
||||
@@ -1467,6 +1468,7 @@ const UPDATE_ARTICLE_MUTATION = gql`
|
||||
$excerpt: String
|
||||
$content: String
|
||||
$imageId: String
|
||||
$authorId: String
|
||||
$tags: [String!]
|
||||
$category: String
|
||||
$featured: Boolean
|
||||
@@ -1479,6 +1481,7 @@ const UPDATE_ARTICLE_MUTATION = gql`
|
||||
excerpt: $excerpt
|
||||
content: $content
|
||||
imageId: $imageId
|
||||
authorId: $authorId
|
||||
tags: $tags
|
||||
category: $category
|
||||
featured: $featured
|
||||
@@ -1498,6 +1501,7 @@ export async function updateArticle(input: {
|
||||
excerpt?: string;
|
||||
content?: string;
|
||||
imageId?: string;
|
||||
authorId?: string | null;
|
||||
tags?: string[];
|
||||
category?: string;
|
||||
featured?: boolean;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { adminListArticles } from "$lib/services";
|
||||
import { adminListArticles, adminListUsers } from "$lib/services";
|
||||
import { error } from "@sveltejs/kit";
|
||||
|
||||
export async function load({ params, fetch, cookies }) {
|
||||
const token = cookies.get("session_token") || "";
|
||||
const articles = await adminListArticles(fetch, token).catch(() => []);
|
||||
const [articles, modelsResult] = await Promise.all([
|
||||
adminListArticles(fetch, token).catch(() => []),
|
||||
adminListUsers({ role: "model", limit: 200 }, fetch, token).catch(() => ({ items: [], total: 0 })),
|
||||
]);
|
||||
const article = articles.find((a) => a.id === params.id);
|
||||
if (!article) throw error(404, "Article not found");
|
||||
return { article };
|
||||
return { article, authors: modelsResult.items };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -24,6 +25,8 @@
|
||||
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 selectedAuthor = $derived(data.authors.find((a) => a.id === authorId) ?? null);
|
||||
let saving = $state(false);
|
||||
let editorTab = $state<"write" | "preview">("write");
|
||||
|
||||
@@ -53,6 +56,7 @@
|
||||
excerpt: excerpt || undefined,
|
||||
content: content || undefined,
|
||||
imageId: imageId || undefined,
|
||||
authorId: authorId || null,
|
||||
tags,
|
||||
category: category || undefined,
|
||||
featured,
|
||||
@@ -139,6 +143,34 @@
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
<div class="space-y-1.5">
|
||||
<Label>Author</Label>
|
||||
<Select type="single" bind:value={authorId}>
|
||||
<SelectTrigger class="w-full">
|
||||
{#if selectedAuthor}
|
||||
{#if selectedAuthor.avatar}
|
||||
<img src={getAssetUrl(selectedAuthor.avatar, "mini")} alt="" class="h-5 w-5 rounded-full object-cover shrink-0" />
|
||||
{/if}
|
||||
{selectedAuthor.artist_name}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">No author</span>
|
||||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">No author</SelectItem>
|
||||
{#each data.authors as author (author.id)}
|
||||
<SelectItem value={author.id}>
|
||||
{#if author.avatar}
|
||||
<img src={getAssetUrl(author.avatar, "mini")} alt="" class="h-5 w-5 rounded-full object-cover shrink-0" />
|
||||
{/if}
|
||||
{author.artist_name}
|
||||
</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="category">Category</Label>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { deleteVideo } from "$lib/services";
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import type { Video } from "$lib/types";
|
||||
|
||||
@@ -81,15 +82,10 @@
|
||||
<td class="px-4 py-3 hidden sm:table-cell">
|
||||
<div class="flex gap-1">
|
||||
{#if video.premium}
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-500/10 text-yellow-600"
|
||||
>Premium</span
|
||||
>
|
||||
<Badge variant="outline" class="text-yellow-600 border-yellow-500/40 bg-yellow-500/10">Premium</Badge>
|
||||
{/if}
|
||||
{#if video.featured}
|
||||
<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary"
|
||||
>Featured</span
|
||||
>
|
||||
<Badge variant="default">Featured</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -56,12 +57,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleModel(id: string) {
|
||||
selectedModelIds = selectedModelIds.includes(id)
|
||||
? selectedModelIds.filter((m) => m !== id)
|
||||
: [...selectedModelIds, id];
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving = true;
|
||||
try {
|
||||
@@ -155,23 +150,27 @@
|
||||
</div>
|
||||
|
||||
{#if data.models.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label>Models</Label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Select type="multiple" bind:value={selectedModelIds}>
|
||||
<SelectTrigger class="w-full">
|
||||
{#if selectedModelIds.length}
|
||||
{selectedModelIds.length} model{selectedModelIds.length > 1 ? "s" : ""} selected
|
||||
{:else}
|
||||
<span class="text-muted-foreground">No models</span>
|
||||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each data.models as model (model.id)}
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
|
||||
selectedModelIds.includes(model.id)
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border/40 text-muted-foreground hover:border-primary/40"
|
||||
}`}
|
||||
onclick={() => toggleModel(model.id)}
|
||||
>
|
||||
{model.artist_name || model.id}
|
||||
</button>
|
||||
<SelectItem value={model.id}>
|
||||
{#if model.avatar}
|
||||
<img src={getAssetUrl(model.avatar, "mini")} alt="" class="h-5 w-5 rounded-full object-cover shrink-0" />
|
||||
{/if}
|
||||
{model.artist_name}
|
||||
</SelectItem>
|
||||
{/each}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
const matchesSearch =
|
||||
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
article.excerpt?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
article.author?.first_name?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
article.author?.artist_name?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = categoryFilter === "all" || article.category === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
@@ -190,11 +190,11 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
src={getAssetUrl(featuredArticle.author?.avatar, "mini")}
|
||||
alt={featuredArticle.author?.first_name}
|
||||
alt={featuredArticle.author?.artist_name}
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">{featuredArticle.author?.first_name}</p>
|
||||
<p class="font-medium">{featuredArticle.author?.artist_name}</p>
|
||||
<div class="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>{timeAgo.format(new Date(featuredArticle.publish_date))}</span>
|
||||
<span>•</span>
|
||||
@@ -288,11 +288,11 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
src={getAssetUrl(article.author?.avatar, "mini")}
|
||||
alt={article.author?.first_name}
|
||||
alt={article.author?.artist_name}
|
||||
class="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{article.author?.first_name}</p>
|
||||
<p class="text-sm font-medium">{article.author?.artist_name}</p>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||
{timeAgo.format(new Date(article.publish_date))}
|
||||
|
||||
@@ -141,32 +141,21 @@
|
||||
|
||||
<!-- Author Bio -->
|
||||
{#if data.article.author}
|
||||
{@const author = data.article.author}
|
||||
<Card class="p-0 bg-gradient-to-r from-card/50 to-card">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
src={getAssetUrl(data.article.author.avatar, "mini")}
|
||||
alt={data.article.author.first_name}
|
||||
src={getAssetUrl(author.avatar, "mini")}
|
||||
alt={author.artist_name}
|
||||
class="w-16 h-16 rounded-full object-cover ring-2 ring-primary/20"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-lg mb-2">
|
||||
About {data.article.author.first_name}
|
||||
</h3>
|
||||
{#if data.article.author.description}
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{data.article.author.description}
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.article.author.website}
|
||||
<div class="flex gap-4 text-sm">
|
||||
<a
|
||||
href={"https://" + data.article.author.website}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{data.article.author.website}
|
||||
<h3 class="font-semibold text-lg mb-2">About {author.artist_name}</h3>
|
||||
{#if author.slug}
|
||||
<a href="/models/{author.slug}" class="text-sm text-primary hover:underline">
|
||||
View profile
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,14 +87,6 @@ export interface Model {
|
||||
|
||||
// ─── Article ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ArticleAuthor {
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
avatar: string | null;
|
||||
description: string | null;
|
||||
website?: string | null;
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -106,7 +98,7 @@ export interface Article {
|
||||
publish_date: Date;
|
||||
category: string | null;
|
||||
featured: boolean | null;
|
||||
author?: ArticleAuthor | null;
|
||||
author?: VideoModel | null;
|
||||
}
|
||||
|
||||
// ─── Comment ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user