i18n: internationalize all admin pages
Add full i18n coverage for the admin section — locale keys, layout nav, users, videos, and articles pages (list, new, edit). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -896,6 +896,142 @@ export default {
|
||||
head: {
|
||||
title: "SexyArt | {title}",
|
||||
},
|
||||
admin: {
|
||||
nav: {
|
||||
back_to_site: "← Back to site",
|
||||
back_mobile: "← Back",
|
||||
title: "Admin",
|
||||
users: "Users",
|
||||
videos: "Videos",
|
||||
articles: "Articles",
|
||||
},
|
||||
common: {
|
||||
save_changes: "Save changes",
|
||||
saving: "Saving…",
|
||||
creating: "Creating…",
|
||||
deleting: "Deleting…",
|
||||
featured: "Featured",
|
||||
premium: "Premium",
|
||||
write: "Write",
|
||||
preview: "Preview",
|
||||
cover_image: "Cover image",
|
||||
tags: "Tags",
|
||||
publish_date: "Publish date",
|
||||
title_field: "Title *",
|
||||
slug_field: "Slug *",
|
||||
title_slug_required: "Title and slug are required",
|
||||
image_uploaded: "Image uploaded",
|
||||
image_upload_failed: "Image upload failed",
|
||||
},
|
||||
users: {
|
||||
title: "Users",
|
||||
total: "{total} total",
|
||||
search_placeholder: "Search email or name…",
|
||||
filter_all: "All",
|
||||
col_user: "User",
|
||||
col_email: "Email",
|
||||
col_role: "Role",
|
||||
col_joined: "Joined",
|
||||
col_actions: "Actions",
|
||||
role_viewer: "Viewer",
|
||||
role_model: "Model",
|
||||
admin_badge: "Admin",
|
||||
no_results: "No users found",
|
||||
showing: "Showing {start}–{end} of {total}",
|
||||
role_updated: "Role updated to {role}",
|
||||
role_update_failed: "Failed to update role",
|
||||
delete_title: "Delete user",
|
||||
delete_description: "Are you sure you want to permanently delete {name}? This cannot be undone.",
|
||||
delete_success: "User deleted",
|
||||
delete_error: "Failed to delete user",
|
||||
},
|
||||
user_edit: {
|
||||
first_name: "First name",
|
||||
last_name: "Last name",
|
||||
artist_name: "Artist name",
|
||||
avatar: "Avatar",
|
||||
banner: "Banner",
|
||||
is_admin: "Administrator",
|
||||
is_admin_hint: "Grants full admin access to the dashboard",
|
||||
photos: "Photo gallery",
|
||||
no_photos: "No photos yet.",
|
||||
avatar_uploaded: "Avatar uploaded",
|
||||
avatar_failed: "Avatar upload failed",
|
||||
banner_uploaded: "Banner uploaded",
|
||||
banner_failed: "Banner upload failed",
|
||||
photos_added: "{count} photos added",
|
||||
photo_upload_failed: "Failed to upload {name}",
|
||||
photo_remove_failed: "Failed to remove photo",
|
||||
save_success: "Saved",
|
||||
save_error: "Save failed",
|
||||
},
|
||||
videos: {
|
||||
title: "Videos",
|
||||
new_video: "New video",
|
||||
col_video: "Video",
|
||||
col_badges: "Badges",
|
||||
col_plays: "Plays",
|
||||
col_likes: "Likes",
|
||||
no_results: "No videos yet",
|
||||
delete_title: "Delete video",
|
||||
delete_description: "Permanently delete {title}? This cannot be undone.",
|
||||
delete_success: "Video deleted",
|
||||
delete_error: "Failed to delete video",
|
||||
},
|
||||
video_form: {
|
||||
new_title: "New video",
|
||||
edit_title: "Edit video",
|
||||
title_placeholder: "Video title",
|
||||
slug_placeholder: "video-slug",
|
||||
description: "Description",
|
||||
description_placeholder: "Optional description",
|
||||
video_file: "Video file",
|
||||
current_file: "Current file: {id}",
|
||||
models: "Models",
|
||||
no_models: "No models",
|
||||
models_selected: "{count} models selected",
|
||||
cover_uploaded: "Cover image uploaded",
|
||||
video_uploaded: "Video uploaded",
|
||||
video_upload_failed: "Video upload failed",
|
||||
create_success: "Video created",
|
||||
create_error: "Failed to create video",
|
||||
update_success: "Video updated",
|
||||
update_error: "Failed to update video",
|
||||
create: "Create video",
|
||||
},
|
||||
articles: {
|
||||
title: "Articles",
|
||||
new_article: "New article",
|
||||
col_article: "Article",
|
||||
col_category: "Category",
|
||||
col_published: "Published",
|
||||
no_results: "No articles yet",
|
||||
delete_title: "Delete article",
|
||||
delete_description: "Permanently delete {title}? This cannot be undone.",
|
||||
delete_success: "Article deleted",
|
||||
delete_error: "Failed to delete article",
|
||||
},
|
||||
article_form: {
|
||||
new_title: "New article",
|
||||
edit_title: "Edit article",
|
||||
title_placeholder: "Article title",
|
||||
slug_placeholder: "article-slug",
|
||||
excerpt: "Excerpt",
|
||||
excerpt_placeholder: "Short summary…",
|
||||
content: "Content (Markdown)",
|
||||
content_placeholder: "Write in Markdown…",
|
||||
preview_placeholder: "Preview will appear here…",
|
||||
category: "Category",
|
||||
category_placeholder: "e.g. news, tutorial…",
|
||||
author: "Author",
|
||||
no_author: "No author",
|
||||
create_success: "Article created",
|
||||
create_error: "Failed to create article",
|
||||
update_success: "Article updated",
|
||||
update_error: "Failed to update article",
|
||||
create: "Create article",
|
||||
},
|
||||
},
|
||||
gamification: {
|
||||
leaderboard: "Leaderboard",
|
||||
leaderboard_description: "Compete with other creators and players for the top spot",
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
const { children } = $props();
|
||||
|
||||
const navLinks = [
|
||||
{ name: "Users", href: "/admin/users", icon: "icon-[ri--team-line]" },
|
||||
{ name: "Videos", href: "/admin/videos", icon: "icon-[ri--film-line]" },
|
||||
{ name: "Articles", href: "/admin/articles", icon: "icon-[ri--article-line]" },
|
||||
];
|
||||
const navLinks = $derived([
|
||||
{ name: $_("admin.nav.users"), href: "/admin/users", icon: "icon-[ri--team-line]" },
|
||||
{ name: $_("admin.nav.videos"), href: "/admin/videos", icon: "icon-[ri--film-line]" },
|
||||
{ name: $_("admin.nav.articles"), href: "/admin/articles", icon: "icon-[ri--article-line]" },
|
||||
]);
|
||||
|
||||
function isActive(href: string) {
|
||||
return page.url.pathname.startsWith(href);
|
||||
@@ -20,7 +21,7 @@
|
||||
<!-- Mobile top nav -->
|
||||
<div class="lg:hidden flex items-center gap-2 py-3 border-b border-border/40">
|
||||
<a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0 mr-2">
|
||||
← Back
|
||||
{$_("admin.nav.back_mobile")}
|
||||
</a>
|
||||
{#each navLinks as link (link.href)}
|
||||
<a
|
||||
@@ -43,9 +44,9 @@
|
||||
<aside class="hidden lg:flex w-56 shrink-0 flex-col border-r border-border/40">
|
||||
<div class="px-4 py-5 border-b border-border/40">
|
||||
<a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
← Back to site
|
||||
{$_("admin.nav.back_to_site")}
|
||||
</a>
|
||||
<h1 class="mt-2 text-base font-bold text-foreground">Admin</h1>
|
||||
<h1 class="mt-2 text-base font-bold text-foreground">{$_("admin.nav.title")}</h1>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 p-3 space-y-1">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { deleteArticle } from "$lib/services";
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
@@ -26,12 +27,12 @@
|
||||
deleting = true;
|
||||
try {
|
||||
await deleteArticle(deleteTarget.id);
|
||||
toast.success("Article deleted");
|
||||
toast.success($_("admin.articles.delete_success"));
|
||||
deleteOpen = false;
|
||||
deleteTarget = null;
|
||||
await invalidateAll();
|
||||
} catch {
|
||||
toast.error("Failed to delete article");
|
||||
toast.error($_("admin.articles.delete_error"));
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
@@ -40,9 +41,9 @@
|
||||
|
||||
<div class="p-3 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">Articles</h1>
|
||||
<h1 class="text-2xl font-bold">{$_("admin.articles.title")}</h1>
|
||||
<Button href="/admin/articles/new">
|
||||
<span class="icon-[ri--add-line] h-4 w-4 mr-1"></span>New article
|
||||
<span class="icon-[ri--add-line] h-4 w-4 mr-1"></span>{$_("admin.articles.new_article")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -50,10 +51,10 @@
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Article</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">Category</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">Published</th>
|
||||
<th class="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">{$_("admin.articles.col_article")}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">{$_("admin.articles.col_category")}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">{$_("admin.articles.col_published")}</th>
|
||||
<th class="px-4 py-3 text-right font-medium text-muted-foreground">{$_("admin.users.col_actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/30">
|
||||
@@ -79,7 +80,7 @@
|
||||
{#if article.featured}
|
||||
<span
|
||||
class="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium"
|
||||
>Featured</span
|
||||
>{$_("admin.common.featured")}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -110,7 +111,7 @@
|
||||
{#if data.articles.length === 0}
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-8 text-center text-muted-foreground">
|
||||
No articles yet
|
||||
{$_("admin.articles.no_results")}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
@@ -122,15 +123,15 @@
|
||||
<Dialog.Root bind:open={deleteOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Delete article</Dialog.Title>
|
||||
<Dialog.Title>{$_("admin.articles.delete_title")}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Permanently delete <strong>{deleteTarget?.title}</strong>? This cannot be undone.
|
||||
{$_("admin.articles.delete_description", { values: { title: deleteTarget?.title } })}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
|
||||
<Button variant="outline" onclick={() => (deleteOpen = false)}>{$_("common.cancel")}</Button>
|
||||
<Button variant="destructive" disabled={deleting} onclick={handleDelete}>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
{deleting ? $_("admin.common.deleting") : $_("common.delete")}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { updateArticle, uploadFile } from "$lib/services";
|
||||
import { marked } from "marked";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
@@ -40,9 +41,9 @@
|
||||
try {
|
||||
const res = await uploadFile(fd);
|
||||
imageId = res.id;
|
||||
toast.success("Image uploaded");
|
||||
toast.success($_("admin.common.image_uploaded"));
|
||||
} catch {
|
||||
toast.error("Image upload failed");
|
||||
toast.error($_("admin.common.image_upload_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,10 +63,10 @@
|
||||
featured,
|
||||
publishDate: publishDate || undefined,
|
||||
});
|
||||
toast.success("Article updated");
|
||||
toast.success($_("admin.article_form.update_success"));
|
||||
goto("/admin/articles");
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to update article");
|
||||
toast.error(e?.message ?? $_("admin.article_form.update_error"));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -75,43 +76,43 @@
|
||||
<div class="p-3 sm:p-6">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" href="/admin/articles" size="sm">
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>Back
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
||||
</Button>
|
||||
<h1 class="text-2xl font-bold">Edit article</h1>
|
||||
<h1 class="text-2xl font-bold">{$_("admin.article_form.edit_title")}</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5 max-w-4xl">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="title">Title *</Label>
|
||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||
<Input id="title" bind:value={title} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="slug">Slug *</Label>
|
||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||
<Input id="slug" bind:value={slug} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="excerpt">Excerpt</Label>
|
||||
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
||||
<Textarea id="excerpt" bind:value={excerpt} rows={2} />
|
||||
</div>
|
||||
|
||||
<!-- Markdown editor with live preview -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Content (Markdown)</Label>
|
||||
<Label>{$_("admin.article_form.content")}</Label>
|
||||
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||
onclick={() => (editorTab = "write")}
|
||||
>Write</button>
|
||||
>{$_("admin.common.write")}</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||
onclick={() => (editorTab = "preview")}
|
||||
>Preview</button>
|
||||
>{$_("admin.common.preview")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
||||
@@ -125,14 +126,14 @@
|
||||
{#if preview}
|
||||
{@html preview}
|
||||
{:else}
|
||||
<p class="text-muted-foreground italic text-sm">Preview will appear here…</p>
|
||||
<p class="text-muted-foreground italic text-sm">{$_("admin.article_form.preview_placeholder")}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Cover image</Label>
|
||||
<Label>{$_("admin.common.cover_image")}</Label>
|
||||
{#if imageId}
|
||||
<img
|
||||
src={getAssetUrl(imageId, "thumbnail")}
|
||||
@@ -145,7 +146,7 @@
|
||||
|
||||
<!-- Author -->
|
||||
<div class="space-y-1.5">
|
||||
<Label>Author</Label>
|
||||
<Label>{$_("admin.article_form.author")}</Label>
|
||||
<Select type="single" bind:value={authorId}>
|
||||
<SelectTrigger class="w-full">
|
||||
{#if selectedAuthor}
|
||||
@@ -154,11 +155,11 @@
|
||||
{/if}
|
||||
{selectedAuthor.artist_name}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">No author</span>
|
||||
<span class="text-muted-foreground">{$_("admin.article_form.no_author")}</span>
|
||||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">No author</SelectItem>
|
||||
<SelectItem value="">{$_("admin.article_form.no_author")}</SelectItem>
|
||||
{#each data.authors as author (author.id)}
|
||||
<SelectItem value={author.id}>
|
||||
{#if author.avatar}
|
||||
@@ -173,30 +174,30 @@
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="category">Category</Label>
|
||||
<Label for="category">{$_("admin.article_form.category")}</Label>
|
||||
<Input id="category" bind:value={category} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="publishDate">Publish date</Label>
|
||||
<Label for="publishDate">{$_("admin.common.publish_date")}</Label>
|
||||
<Input id="publishDate" type="datetime-local" bind:value={publishDate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Tags</Label>
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput bind:value={tags} />
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||
<span class="text-sm">Featured</span>
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<Button onclick={handleSubmit} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||
</Button>
|
||||
<Button variant="outline" href="/admin/articles">Cancel</Button>
|
||||
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { createArticle, uploadFile } from "$lib/services";
|
||||
import { marked } from "marked";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
@@ -39,15 +40,15 @@
|
||||
try {
|
||||
const res = await uploadFile(fd);
|
||||
imageId = res.id;
|
||||
toast.success("Image uploaded");
|
||||
toast.success($_("admin.common.image_uploaded"));
|
||||
} catch {
|
||||
toast.error("Image upload failed");
|
||||
toast.error($_("admin.common.image_upload_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title || !slug) {
|
||||
toast.error("Title and slug are required");
|
||||
toast.error($_("admin.common.title_slug_required"));
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
@@ -63,10 +64,10 @@
|
||||
featured,
|
||||
publishDate: publishDate || undefined,
|
||||
});
|
||||
toast.success("Article created");
|
||||
toast.success($_("admin.article_form.create_success"));
|
||||
goto("/admin/articles");
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to create article");
|
||||
toast.error(e?.message ?? $_("admin.article_form.create_error"));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -76,57 +77,57 @@
|
||||
<div class="p-3 sm:p-6">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" href="/admin/articles" size="sm">
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>Back
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
||||
</Button>
|
||||
<h1 class="text-2xl font-bold">New article</h1>
|
||||
<h1 class="text-2xl font-bold">{$_("admin.article_form.new_title")}</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5 max-w-4xl">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="title">Title *</Label>
|
||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
bind:value={title}
|
||||
oninput={() => {
|
||||
if (!slug) slug = generateSlug(title);
|
||||
}}
|
||||
placeholder="Article title"
|
||||
placeholder={$_("admin.article_form.title_placeholder")}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="slug">Slug *</Label>
|
||||
<Input id="slug" bind:value={slug} placeholder="article-slug" />
|
||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||
<Input id="slug" bind:value={slug} placeholder={$_("admin.article_form.slug_placeholder")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="excerpt">Excerpt</Label>
|
||||
<Textarea id="excerpt" bind:value={excerpt} placeholder="Short summary…" rows={2} />
|
||||
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
||||
<Textarea id="excerpt" bind:value={excerpt} placeholder={$_("admin.article_form.excerpt_placeholder")} rows={2} />
|
||||
</div>
|
||||
|
||||
<!-- Markdown editor with live preview -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Content (Markdown)</Label>
|
||||
<Label>{$_("admin.article_form.content")}</Label>
|
||||
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||
onclick={() => (editorTab = "write")}
|
||||
>Write</button>
|
||||
>{$_("admin.common.write")}</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||
onclick={() => (editorTab = "preview")}
|
||||
>Preview</button>
|
||||
>{$_("admin.common.preview")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: single pane toggled; Desktop: side by side -->
|
||||
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
||||
<Textarea
|
||||
bind:value={content}
|
||||
placeholder="Write in Markdown…"
|
||||
placeholder={$_("admin.article_form.content_placeholder")}
|
||||
class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
|
||||
/>
|
||||
<div
|
||||
@@ -135,44 +136,44 @@
|
||||
{#if preview}
|
||||
{@html preview}
|
||||
{:else}
|
||||
<p class="text-muted-foreground italic text-sm">Preview will appear here…</p>
|
||||
<p class="text-muted-foreground italic text-sm">{$_("admin.article_form.preview_placeholder")}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Cover image</Label>
|
||||
<Label>{$_("admin.common.cover_image")}</Label>
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
{#if imageId}<p class="text-xs text-green-600 mt-1">Image uploaded ✓</p>{/if}
|
||||
{#if imageId}<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")} ✓</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="category">Category</Label>
|
||||
<Input id="category" bind:value={category} placeholder="e.g. news, tutorial…" />
|
||||
<Label for="category">{$_("admin.article_form.category")}</Label>
|
||||
<Input id="category" bind:value={category} placeholder={$_("admin.article_form.category_placeholder")} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="publishDate">Publish date</Label>
|
||||
<Label for="publishDate">{$_("admin.common.publish_date")}</Label>
|
||||
<Input id="publishDate" type="datetime-local" bind:value={publishDate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Tags</Label>
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput bind:value={tags} />
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||
<span class="text-sm">Featured</span>
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<Button onclick={handleSubmit} disabled={saving}>
|
||||
{saving ? "Creating…" : "Create article"}
|
||||
{saving ? $_("admin.common.creating") : $_("admin.article_form.create")}
|
||||
</Button>
|
||||
<Button variant="outline" href="/admin/articles">Cancel</Button>
|
||||
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { page } from "$app/state";
|
||||
import { SvelteURLSearchParams } from "svelte/reactivity";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { adminUpdateUser, adminDeleteUser } from "$lib/services";
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
@@ -48,10 +49,10 @@
|
||||
updatingId = user.id;
|
||||
try {
|
||||
await adminUpdateUser({ userId: user.id, role: newRole });
|
||||
toast.success(`Role updated to ${newRole}`);
|
||||
toast.success($_("admin.users.role_updated", { values: { role: newRole } }));
|
||||
await invalidateAll();
|
||||
} catch {
|
||||
toast.error("Failed to update role");
|
||||
toast.error($_("admin.users.role_update_failed"));
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
@@ -67,12 +68,12 @@
|
||||
deleting = true;
|
||||
try {
|
||||
await adminDeleteUser(deleteTarget.id);
|
||||
toast.success("User deleted");
|
||||
toast.success($_("admin.users.delete_success"));
|
||||
deleteOpen = false;
|
||||
deleteTarget = null;
|
||||
await invalidateAll();
|
||||
} catch {
|
||||
toast.error("Failed to delete user");
|
||||
toast.error($_("admin.users.delete_error"));
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
@@ -85,14 +86,14 @@
|
||||
|
||||
<div class="p-3 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">Users</h1>
|
||||
<span class="text-sm text-muted-foreground">{data.total} total</span>
|
||||
<h1 class="text-2xl font-bold">{$_("admin.users.title")}</h1>
|
||||
<span class="text-sm text-muted-foreground">{$_("admin.users.total", { values: { total: data.total } })}</span>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3 mb-4">
|
||||
<Input
|
||||
placeholder="Search email or name…"
|
||||
placeholder={$_("admin.users.search_placeholder")}
|
||||
class="max-w-xs"
|
||||
value={searchValue}
|
||||
oninput={(e) => {
|
||||
@@ -108,7 +109,7 @@
|
||||
variant={data.role === role || (!data.role && role === "") ? "default" : "outline"}
|
||||
onclick={() => setRole(role)}
|
||||
>
|
||||
{role || "All"}
|
||||
{role ? $_(`admin.users.role_${role}`) : $_("admin.users.filter_all")}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -119,11 +120,11 @@
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">User</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">Email</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Role</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">Joined</th>
|
||||
<th class="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">{$_("admin.users.col_user")}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">{$_("admin.users.col_email")}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">{$_("admin.users.col_role")}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">{$_("admin.users.col_joined")}</th>
|
||||
<th class="px-4 py-3 text-right font-medium text-muted-foreground">{$_("admin.users.col_actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/30">
|
||||
@@ -148,7 +149,7 @@
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium truncate">{user.artist_name || user.first_name || "—"}</span>
|
||||
{#if user.is_admin}
|
||||
<Badge variant="default" class="shrink-0 text-[10px] px-1.5 py-0">Admin</Badge>
|
||||
<Badge variant="default" class="shrink-0 text-[10px] px-1.5 py-0">{$_("admin.users.admin_badge")}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground sm:hidden truncate block">{user.email}</span>
|
||||
@@ -167,8 +168,8 @@
|
||||
{user.role}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="viewer">Viewer</SelectItem>
|
||||
<SelectItem value="model">Model</SelectItem>
|
||||
<SelectItem value="viewer">{$_("admin.users.role_viewer")}</SelectItem>
|
||||
<SelectItem value="model">{$_("admin.users.role_model")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
@@ -194,7 +195,7 @@
|
||||
|
||||
{#if data.items.length === 0}
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">No users found</td>
|
||||
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">{$_("admin.users.no_results")}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
@@ -205,7 +206,7 @@
|
||||
{#if data.total > data.limit}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Showing {data.offset + 1}–{Math.min(data.offset + data.limit, data.total)} of {data.total}
|
||||
{$_("admin.users.showing", { values: { start: data.offset + 1, end: Math.min(data.offset + data.limit, data.total), total: data.total } })}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@@ -218,7 +219,7 @@
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
{$_("common.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -230,7 +231,7 @@
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
Next
|
||||
{$_("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,17 +242,15 @@
|
||||
<Dialog.Root bind:open={deleteOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Delete user</Dialog.Title>
|
||||
<Dialog.Title>{$_("admin.users.delete_title")}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Are you sure you want to permanently delete <strong
|
||||
>{deleteTarget?.artist_name || deleteTarget?.email}</strong
|
||||
>? This cannot be undone.
|
||||
{$_("admin.users.delete_description", { values: { name: deleteTarget?.artist_name || deleteTarget?.email } })}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
|
||||
<Button variant="outline" onclick={() => (deleteOpen = false)}>{$_("common.cancel")}</Button>
|
||||
<Button variant="destructive" disabled={deleting} onclick={handleDelete}>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
{deleting ? $_("admin.common.deleting") : $_("common.delete")}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
uploadFile,
|
||||
} from "$lib/services";
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
@@ -31,9 +32,9 @@
|
||||
try {
|
||||
const res = await uploadFile(fd);
|
||||
avatarId = res.id;
|
||||
toast.success("Avatar uploaded");
|
||||
toast.success($_("admin.user_edit.avatar_uploaded"));
|
||||
} catch {
|
||||
toast.error("Avatar upload failed");
|
||||
toast.error($_("admin.user_edit.avatar_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +46,9 @@
|
||||
try {
|
||||
const res = await uploadFile(fd);
|
||||
bannerId = res.id;
|
||||
toast.success("Banner uploaded");
|
||||
toast.success($_("admin.user_edit.banner_uploaded"));
|
||||
} catch {
|
||||
toast.error("Banner upload failed");
|
||||
toast.error($_("admin.user_edit.banner_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,21 +60,21 @@
|
||||
const res = await uploadFile(fd);
|
||||
await adminAddUserPhoto(data.user.id, res.id);
|
||||
} catch {
|
||||
toast.error(`Failed to upload ${file.name}`);
|
||||
toast.error($_("admin.user_edit.photo_upload_failed", { values: { name: file.name } }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
toast.success(`${files.length} photo${files.length > 1 ? "s" : ""} added`);
|
||||
toast.success($_("admin.user_edit.photos_added", { values: { count: files.length } }));
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
async function removePhoto(fileId: string) {
|
||||
try {
|
||||
await adminRemoveUserPhoto(data.user.id, fileId);
|
||||
toast.success("Photo removed");
|
||||
toast.success($_("admin.user_edit.save_success"));
|
||||
await invalidateAll();
|
||||
} catch {
|
||||
toast.error("Failed to remove photo");
|
||||
toast.error($_("admin.user_edit.photo_remove_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,9 +90,9 @@
|
||||
bannerId: bannerId || undefined,
|
||||
isAdmin,
|
||||
});
|
||||
toast.success("Saved");
|
||||
toast.success($_("admin.user_edit.save_success"));
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Save failed");
|
||||
toast.error(e?.message ?? $_("admin.user_edit.save_error"));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -101,11 +102,11 @@
|
||||
<div class="p-3 sm:p-6 max-w-2xl">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" href="/admin/users" size="sm">
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>Back
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
||||
</Button>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1>
|
||||
<p class="text-xs text-muted-foreground">{data.user.email} · {data.user.role}{data.user.is_admin ? " · admin" : ""}</p>
|
||||
<p class="text-xs text-muted-foreground">{data.user.email} · {data.user.role}{data.user.is_admin ? " · " + $_("admin.users.admin_badge").toLowerCase() : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,23 +114,23 @@
|
||||
<!-- Basic info -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="firstName">First name</Label>
|
||||
<Label for="firstName">{$_("admin.user_edit.first_name")}</Label>
|
||||
<Input id="firstName" bind:value={firstName} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="lastName">Last name</Label>
|
||||
<Label for="lastName">{$_("admin.user_edit.last_name")}</Label>
|
||||
<Input id="lastName" bind:value={lastName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="artistName">Artist name</Label>
|
||||
<Label for="artistName">{$_("admin.user_edit.artist_name")}</Label>
|
||||
<Input id="artistName" bind:value={artistName} />
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="space-y-1.5">
|
||||
<Label>Avatar</Label>
|
||||
<Label>{$_("admin.user_edit.avatar")}</Label>
|
||||
{#if avatarId}
|
||||
<img
|
||||
src={getAssetUrl(avatarId, "thumbnail")}
|
||||
@@ -142,7 +143,7 @@
|
||||
|
||||
<!-- Banner -->
|
||||
<div class="space-y-1.5">
|
||||
<Label>Banner</Label>
|
||||
<Label>{$_("admin.user_edit.banner")}</Label>
|
||||
{#if bannerId}
|
||||
<img
|
||||
src={getAssetUrl(bannerId, "preview")}
|
||||
@@ -157,20 +158,20 @@
|
||||
<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">
|
||||
<input type="checkbox" bind:checked={isAdmin} class="h-4 w-4 rounded accent-primary shrink-0" />
|
||||
<div>
|
||||
<span class="text-sm font-medium">Administrator</span>
|
||||
<p class="text-xs text-muted-foreground">Grants full admin access to the dashboard</p>
|
||||
<span class="text-sm font-medium">{$_("admin.user_edit.is_admin")}</span>
|
||||
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.is_admin_hint")}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button onclick={handleSave} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Photo gallery -->
|
||||
<div class="space-y-3 pt-4 border-t border-border/40">
|
||||
<Label>Photo gallery</Label>
|
||||
<Label>{$_("admin.user_edit.photos")}</Label>
|
||||
|
||||
{#if data.user.photos && data.user.photos.length > 0}
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
@@ -192,7 +193,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">No photos yet.</p>
|
||||
<p class="text-sm text-muted-foreground">{$_("admin.user_edit.no_photos")}</p>
|
||||
{/if}
|
||||
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { deleteVideo } from "$lib/services";
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
@@ -24,12 +25,12 @@
|
||||
deleting = true;
|
||||
try {
|
||||
await deleteVideo(deleteTarget.id);
|
||||
toast.success("Video deleted");
|
||||
toast.success($_("admin.videos.delete_success"));
|
||||
deleteOpen = false;
|
||||
deleteTarget = null;
|
||||
await invalidateAll();
|
||||
} catch {
|
||||
toast.error("Failed to delete video");
|
||||
toast.error($_("admin.videos.delete_error"));
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
@@ -38,9 +39,9 @@
|
||||
|
||||
<div class="p-3 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">Videos</h1>
|
||||
<h1 class="text-2xl font-bold">{$_("admin.videos.title")}</h1>
|
||||
<Button href="/admin/videos/new">
|
||||
<span class="icon-[ri--add-line] h-4 w-4 mr-1"></span>New video
|
||||
<span class="icon-[ri--add-line] h-4 w-4 mr-1"></span>{$_("admin.videos.new_video")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -48,11 +49,11 @@
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Video</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">Badges</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">Plays</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">Likes</th>
|
||||
<th class="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">{$_("admin.videos.col_video")}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">{$_("admin.videos.col_badges")}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">{$_("admin.videos.col_plays")}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">{$_("admin.videos.col_likes")}</th>
|
||||
<th class="px-4 py-3 text-right font-medium text-muted-foreground">{$_("admin.users.col_actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/30">
|
||||
@@ -82,10 +83,10 @@
|
||||
<td class="px-4 py-3 hidden sm:table-cell">
|
||||
<div class="flex gap-1">
|
||||
{#if video.premium}
|
||||
<Badge variant="outline" class="text-yellow-600 border-yellow-500/40 bg-yellow-500/10">Premium</Badge>
|
||||
<Badge variant="outline" class="text-yellow-600 border-yellow-500/40 bg-yellow-500/10">{$_("admin.common.premium")}</Badge>
|
||||
{/if}
|
||||
{#if video.featured}
|
||||
<Badge variant="default">Featured</Badge>
|
||||
<Badge variant="default">{$_("admin.common.featured")}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
@@ -111,7 +112,7 @@
|
||||
|
||||
{#if data.videos.length === 0}
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">No videos yet</td>
|
||||
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">{$_("admin.videos.no_results")}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
@@ -122,15 +123,15 @@
|
||||
<Dialog.Root bind:open={deleteOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Delete video</Dialog.Title>
|
||||
<Dialog.Title>{$_("admin.videos.delete_title")}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Permanently delete <strong>{deleteTarget?.title}</strong>? This cannot be undone.
|
||||
{$_("admin.videos.delete_description", { values: { title: deleteTarget?.title } })}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
|
||||
<Button variant="outline" onclick={() => (deleteOpen = false)}>{$_("common.cancel")}</Button>
|
||||
<Button variant="destructive" disabled={deleting} onclick={handleDelete}>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
{deleting ? $_("admin.common.deleting") : $_("common.delete")}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { updateVideo, setVideoModels, uploadFile } from "$lib/services";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
@@ -37,9 +38,9 @@
|
||||
try {
|
||||
const res = await uploadFile(fd);
|
||||
imageId = res.id;
|
||||
toast.success("Cover image uploaded");
|
||||
toast.success($_("admin.video_form.cover_uploaded"));
|
||||
} catch {
|
||||
toast.error("Image upload failed");
|
||||
toast.error($_("admin.common.image_upload_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,9 +52,9 @@
|
||||
try {
|
||||
const res = await uploadFile(fd);
|
||||
movieId = res.id;
|
||||
toast.success("Video uploaded");
|
||||
toast.success($_("admin.video_form.video_uploaded"));
|
||||
} catch {
|
||||
toast.error("Video upload failed");
|
||||
toast.error($_("admin.video_form.video_upload_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +74,10 @@
|
||||
uploadDate: uploadDate || undefined,
|
||||
});
|
||||
await setVideoModels(data.video.id, selectedModelIds);
|
||||
toast.success("Video updated");
|
||||
toast.success($_("admin.video_form.update_success"));
|
||||
goto("/admin/videos");
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to update video");
|
||||
toast.error(e?.message ?? $_("admin.video_form.update_error"));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -86,30 +87,30 @@
|
||||
<div class="p-3 sm:p-6 max-w-2xl">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" href="/admin/videos" size="sm">
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>Back
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
||||
</Button>
|
||||
<h1 class="text-2xl font-bold">Edit video</h1>
|
||||
<h1 class="text-2xl font-bold">{$_("admin.video_form.edit_title")}</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="title">Title *</Label>
|
||||
<Input id="title" bind:value={title} placeholder="Video title" />
|
||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||
<Input id="title" bind:value={title} placeholder={$_("admin.video_form.title_placeholder")} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="slug">Slug *</Label>
|
||||
<Input id="slug" bind:value={slug} placeholder="video-slug" />
|
||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||
<Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="description">Description</Label>
|
||||
<Textarea id="description" bind:value={description} rows={3} />
|
||||
<Label for="description">{$_("admin.video_form.description")}</Label>
|
||||
<Textarea id="description" bind:value={description} placeholder={$_("admin.video_form.description_placeholder")} rows={3} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Cover image</Label>
|
||||
<Label>{$_("admin.common.cover_image")}</Label>
|
||||
{#if imageId}
|
||||
<img
|
||||
src={getAssetUrl(imageId, "thumbnail")}
|
||||
@@ -121,43 +122,43 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Video file</Label>
|
||||
<Label>{$_("admin.video_form.video_file")}</Label>
|
||||
{#if movieId}
|
||||
<p class="text-xs text-muted-foreground mb-1">Current file: {movieId}</p>
|
||||
<p class="text-xs text-muted-foreground mb-1">{$_("admin.video_form.current_file", { values: { id: movieId } })}</p>
|
||||
{/if}
|
||||
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Tags</Label>
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput bind:value={tags} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="uploadDate">Publish date</Label>
|
||||
<Label for="uploadDate">{$_("admin.common.publish_date")}</Label>
|
||||
<Input id="uploadDate" type="datetime-local" bind:value={uploadDate} />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={premium} class="rounded" />
|
||||
<span class="text-sm">Premium</span>
|
||||
<span class="text-sm">{$_("admin.common.premium")}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||
<span class="text-sm">Featured</span>
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if data.models.length > 0}
|
||||
<div class="space-y-1.5">
|
||||
<Label>Models</Label>
|
||||
<Label>{$_("admin.video_form.models")}</Label>
|
||||
<Select type="multiple" bind:value={selectedModelIds}>
|
||||
<SelectTrigger class="w-full">
|
||||
{#if selectedModelIds.length}
|
||||
{selectedModelIds.length} model{selectedModelIds.length > 1 ? "s" : ""} selected
|
||||
{$_("admin.video_form.models_selected", { values: { count: selectedModelIds.length } })}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">No models</span>
|
||||
<span class="text-muted-foreground">{$_("admin.video_form.no_models")}</span>
|
||||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -176,9 +177,9 @@
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<Button onclick={handleSubmit} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||
</Button>
|
||||
<Button variant="outline" href="/admin/videos">Cancel</Button>
|
||||
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { createVideo, setVideoModels, uploadFile } from "$lib/services";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
@@ -38,9 +39,9 @@
|
||||
try {
|
||||
const res = await uploadFile(fd);
|
||||
imageId = res.id;
|
||||
toast.success("Cover image uploaded");
|
||||
toast.success($_("admin.video_form.cover_uploaded"));
|
||||
} catch {
|
||||
toast.error("Image upload failed");
|
||||
toast.error($_("admin.common.image_upload_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +53,9 @@
|
||||
try {
|
||||
const res = await uploadFile(fd);
|
||||
movieId = res.id;
|
||||
toast.success("Video uploaded");
|
||||
toast.success($_("admin.video_form.video_uploaded"));
|
||||
} catch {
|
||||
toast.error("Video upload failed");
|
||||
toast.error($_("admin.video_form.video_upload_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +67,7 @@
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title || !slug) {
|
||||
toast.error("Title and slug are required");
|
||||
toast.error($_("admin.common.title_slug_required"));
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
@@ -85,10 +86,10 @@
|
||||
if (selectedModelIds.length > 0) {
|
||||
await setVideoModels(video.id, selectedModelIds);
|
||||
}
|
||||
toast.success("Video created");
|
||||
toast.success($_("admin.video_form.create_success"));
|
||||
goto("/admin/videos");
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to create video");
|
||||
toast.error(e?.message ?? $_("admin.video_form.create_error"));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -98,70 +99,70 @@
|
||||
<div class="p-3 sm:p-6 max-w-2xl">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" href="/admin/videos" size="sm">
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>Back
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
||||
</Button>
|
||||
<h1 class="text-2xl font-bold">New video</h1>
|
||||
<h1 class="text-2xl font-bold">{$_("admin.video_form.new_title")}</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="title">Title *</Label>
|
||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
bind:value={title}
|
||||
oninput={() => {
|
||||
if (!slug) slug = generateSlug(title);
|
||||
}}
|
||||
placeholder="Video title"
|
||||
placeholder={$_("admin.video_form.title_placeholder")}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="slug">Slug *</Label>
|
||||
<Input id="slug" bind:value={slug} placeholder="video-slug" />
|
||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||
<Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="description">Description</Label>
|
||||
<Label for="description">{$_("admin.video_form.description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder="Optional description"
|
||||
placeholder={$_("admin.video_form.description_placeholder")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Cover image</Label>
|
||||
<Label>{$_("admin.common.cover_image")}</Label>
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
{#if imageId}<p class="text-xs text-green-600 mt-1">Image uploaded ✓</p>{/if}
|
||||
{#if imageId}<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")} ✓</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Video file</Label>
|
||||
<Label>{$_("admin.video_form.video_file")}</Label>
|
||||
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||
{#if movieId}<p class="text-xs text-green-600 mt-1">Video uploaded ✓</p>{/if}
|
||||
{#if movieId}<p class="text-xs text-green-600 mt-1">{$_("admin.video_form.video_uploaded")} ✓</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Tags</Label>
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput bind:value={tags} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="uploadDate">Publish date</Label>
|
||||
<Label for="uploadDate">{$_("admin.common.publish_date")}</Label>
|
||||
<Input id="uploadDate" type="datetime-local" bind:value={uploadDate} />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={premium} class="rounded" />
|
||||
<span class="text-sm">Premium</span>
|
||||
<span class="text-sm">{$_("admin.common.premium")}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||
<span class="text-sm">Featured</span>
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -188,9 +189,9 @@
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<Button onclick={handleSubmit} disabled={saving}>
|
||||
{saving ? "Creating…" : "Create video"}
|
||||
{saving ? $_("admin.common.creating") : $_("admin.video_form.create")}
|
||||
</Button>
|
||||
<Button variant="outline" href="/admin/videos">Cancel</Button>
|
||||
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user