Compare commits
10 Commits
434e926f77
...
ac63e59906
| Author | SHA1 | Date | |
|---|---|---|---|
| ac63e59906 | |||
| 19d29cbfc6 | |||
| 0ec27117ae | |||
| ed9eb6ef22 | |||
| 609f116b5d | |||
| e943876e70 | |||
| 7d373b3aa3 | |||
| 95fd9f48fc | |||
| 670c18bcb7 | |||
| 9ef490c1e5 |
@@ -29,6 +29,7 @@ export const users = pgTable(
|
||||
role: roleEnum("role").notNull().default("viewer"),
|
||||
avatar: text("avatar").references(() => files.id, { onDelete: "set null" }),
|
||||
banner: text("banner").references(() => files.id, { onDelete: "set null" }),
|
||||
is_admin: boolean("is_admin").notNull().default(false),
|
||||
email_verified: boolean("email_verified").notNull().default(false),
|
||||
email_verify_token: text("email_verify_token"),
|
||||
password_reset_token: text("password_reset_token"),
|
||||
|
||||
@@ -2,17 +2,17 @@ import { builder } from "../builder";
|
||||
import { ArticleType } from "../types/index";
|
||||
import { articles, users } from "../../db/schema/index";
|
||||
import { eq, and, lte, desc } from "drizzle-orm";
|
||||
import { requireRole } from "../../lib/acl";
|
||||
import { requireAdmin } from "../../lib/acl";
|
||||
|
||||
async function enrichArticle(db: any, article: any) {
|
||||
let author = null;
|
||||
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))
|
||||
@@ -78,7 +78,7 @@ builder.queryField("adminListArticles", (t) =>
|
||||
t.field({
|
||||
type: [ArticleType],
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
const articleList = await ctx.db.select().from(articles).orderBy(desc(articles.publish_date));
|
||||
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
|
||||
},
|
||||
@@ -100,7 +100,7 @@ builder.mutationField("createArticle", (t) =>
|
||||
publishDate: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
const inserted = await ctx.db
|
||||
.insert(articles)
|
||||
.values({
|
||||
@@ -132,19 +132,21 @@ 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(),
|
||||
publishDate: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
const updates: Record<string, unknown> = { date_updated: new Date() };
|
||||
if (args.title !== undefined && args.title !== null) updates.title = args.title;
|
||||
if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug;
|
||||
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;
|
||||
@@ -169,7 +171,7 @@ builder.mutationField("deleteArticle", (t) =>
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
await ctx.db.delete(articles).where(eq(articles.id, args.id));
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -32,7 +32,8 @@ builder.mutationField("login", (t) =>
|
||||
const sessionUser = {
|
||||
id: user[0].id,
|
||||
email: user[0].email,
|
||||
role: user[0].role,
|
||||
role: (user[0].role === "admin" ? "viewer" : user[0].role) as "model" | "viewer",
|
||||
is_admin: user[0].is_admin,
|
||||
first_name: user[0].first_name,
|
||||
last_name: user[0].last_name,
|
||||
artist_name: user[0].artist_name,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder";
|
||||
import { RecordingType } from "../types/index";
|
||||
import { recordings, recording_plays } from "../../db/schema/index";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { eq, and, desc, ne } from "drizzle-orm";
|
||||
import { slugify } from "../../lib/slugify";
|
||||
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
||||
|
||||
@@ -21,6 +21,7 @@ builder.queryField("recordings", (t) =>
|
||||
|
||||
const conditions = [eq(recordings.user_id, ctx.currentUser.id)];
|
||||
if (args.status) conditions.push(eq(recordings.status, args.status as any));
|
||||
else conditions.push(ne(recordings.status, "archived" as any));
|
||||
if (args.linkedVideoId) conditions.push(eq(recordings.linked_video, args.linkedVideoId));
|
||||
|
||||
const limit = args.limit || 50;
|
||||
@@ -211,10 +212,7 @@ builder.mutationField("deleteRecording", (t) =>
|
||||
if (!existing[0]) throw new GraphQLError("Recording not found");
|
||||
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
|
||||
|
||||
await ctx.db
|
||||
.update(recordings)
|
||||
.set({ status: "archived", date_updated: new Date() })
|
||||
.where(eq(recordings.id, args.id));
|
||||
await ctx.db.delete(recordings).where(eq(recordings.id, args.id));
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { builder } from "../builder";
|
||||
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
|
||||
import { users, user_photos, files } from "../../db/schema/index";
|
||||
import { eq, ilike, or, count, and } from "drizzle-orm";
|
||||
import { requireRole } from "../../lib/acl";
|
||||
import { requireAdmin } from "../../lib/acl";
|
||||
|
||||
builder.queryField("me", (t) =>
|
||||
t.field({
|
||||
@@ -86,7 +86,7 @@ builder.queryField("adminListUsers", (t) =>
|
||||
offset: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
@@ -126,6 +126,7 @@ builder.mutationField("adminUpdateUser", (t) =>
|
||||
args: {
|
||||
userId: t.arg.string({ required: true }),
|
||||
role: t.arg.string(),
|
||||
isAdmin: t.arg.boolean(),
|
||||
firstName: t.arg.string(),
|
||||
lastName: t.arg.string(),
|
||||
artistName: t.arg.string(),
|
||||
@@ -133,10 +134,11 @@ builder.mutationField("adminUpdateUser", (t) =>
|
||||
bannerId: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
|
||||
const updates: Record<string, unknown> = { date_updated: new Date() };
|
||||
if (args.role !== undefined && args.role !== null) updates.role = args.role as any;
|
||||
if (args.isAdmin !== undefined && args.isAdmin !== null) updates.is_admin = args.isAdmin;
|
||||
if (args.firstName !== undefined && args.firstName !== null)
|
||||
updates.first_name = args.firstName;
|
||||
if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName;
|
||||
@@ -163,7 +165,7 @@ builder.mutationField("adminDeleteUser", (t) =>
|
||||
userId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
if (args.userId === ctx.currentUser!.id) throw new GraphQLError("Cannot delete yourself");
|
||||
await ctx.db.delete(users).where(eq(users.id, args.userId));
|
||||
return true;
|
||||
@@ -179,7 +181,7 @@ builder.queryField("adminGetUser", (t) =>
|
||||
userId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
const user = await ctx.db.select().from(users).where(eq(users.id, args.userId)).limit(1);
|
||||
if (!user[0]) return null;
|
||||
const photoRows = await ctx.db
|
||||
@@ -188,10 +190,11 @@ builder.queryField("adminGetUser", (t) =>
|
||||
.leftJoin(files, eq(user_photos.file_id, files.id))
|
||||
.where(eq(user_photos.user_id, args.userId))
|
||||
.orderBy(user_photos.sort);
|
||||
return {
|
||||
...user[0],
|
||||
photos: photoRows.map((p: any) => ({ id: p.id, filename: p.filename })),
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
const photos = photoRows
|
||||
.filter((p: any) => p.id && !seen.has(p.id) && seen.add(p.id))
|
||||
.map((p: any) => ({ id: p.id, filename: p.filename }));
|
||||
return { ...user[0], photos };
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -204,7 +207,7 @@ builder.mutationField("adminAddUserPhoto", (t) =>
|
||||
fileId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
await ctx.db.insert(user_photos).values({ user_id: args.userId, file_id: args.fileId });
|
||||
return true;
|
||||
},
|
||||
@@ -219,7 +222,7 @@ builder.mutationField("adminRemoveUserPhoto", (t) =>
|
||||
fileId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
await ctx.db
|
||||
.delete(user_photos)
|
||||
.where(and(eq(user_photos.user_id, args.userId), eq(user_photos.file_id, args.fileId)));
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
files,
|
||||
} from "../../db/schema/index";
|
||||
import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
|
||||
import { requireRole } from "../../lib/acl";
|
||||
import { requireAdmin } from "../../lib/acl";
|
||||
|
||||
async function enrichVideo(db: any, video: any) {
|
||||
// Fetch models
|
||||
@@ -432,7 +432,7 @@ builder.queryField("adminListVideos", (t) =>
|
||||
t.field({
|
||||
type: [VideoType],
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
const rows = await ctx.db.select().from(videos).orderBy(desc(videos.upload_date));
|
||||
return Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
|
||||
},
|
||||
@@ -454,7 +454,7 @@ builder.mutationField("createVideo", (t) =>
|
||||
uploadDate: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
const inserted = await ctx.db
|
||||
.insert(videos)
|
||||
.values({
|
||||
@@ -491,7 +491,7 @@ builder.mutationField("updateVideo", (t) =>
|
||||
uploadDate: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (args.title !== undefined && args.title !== null) updates.title = args.title;
|
||||
if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug;
|
||||
@@ -522,7 +522,7 @@ builder.mutationField("deleteVideo", (t) =>
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
await ctx.db.delete(videos).where(eq(videos.id, args.id));
|
||||
return true;
|
||||
},
|
||||
@@ -537,7 +537,7 @@ builder.mutationField("setVideoModels", (t) =>
|
||||
userIds: t.arg.stringList({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
requireAdmin(ctx);
|
||||
await ctx.db.delete(video_models).where(eq(video_models.video_id, args.videoId));
|
||||
if (args.userIds.length > 0) {
|
||||
await ctx.db.insert(video_models).values(
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
Video,
|
||||
ModelPhoto,
|
||||
Model,
|
||||
ArticleAuthor,
|
||||
Article,
|
||||
CommentUser,
|
||||
Comment,
|
||||
@@ -53,6 +52,7 @@ export const UserType = builder.objectRef<User>("User").implement({
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
tags: t.exposeStringList("tags", { nullable: true }),
|
||||
role: t.exposeString("role"),
|
||||
is_admin: t.exposeBoolean("is_admin"),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
banner: t.exposeString("banner", { nullable: true }),
|
||||
email_verified: t.exposeBoolean("email_verified"),
|
||||
@@ -72,6 +72,7 @@ export const CurrentUserType = builder.objectRef<User>("CurrentUser").implement(
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
tags: t.exposeStringList("tags", { nullable: true }),
|
||||
role: t.exposeString("role"),
|
||||
is_admin: t.exposeBoolean("is_admin"),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
banner: t.exposeString("banner", { nullable: true }),
|
||||
email_verified: t.exposeBoolean("email_verified"),
|
||||
@@ -137,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"),
|
||||
@@ -158,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 }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -359,6 +351,7 @@ export const AdminUserDetailType = builder
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
tags: t.exposeStringList("tags", { nullable: true }),
|
||||
role: t.exposeString("role"),
|
||||
is_admin: t.exposeBoolean("is_admin"),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
banner: t.exposeString("banner", { nullable: true }),
|
||||
email_verified: t.exposeBoolean("email_verified"),
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import type { Context } from "../graphql/builder";
|
||||
|
||||
type UserRole = "viewer" | "model" | "admin";
|
||||
|
||||
export function requireAuth(ctx: Context): void {
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
}
|
||||
|
||||
export function requireRole(ctx: Context, ...roles: UserRole[]): void {
|
||||
export function requireAdmin(ctx: Context): void {
|
||||
requireAuth(ctx);
|
||||
if (!roles.includes(ctx.currentUser!.role)) throw new GraphQLError("Forbidden");
|
||||
if (!ctx.currentUser!.is_admin) throw new GraphQLError("Forbidden");
|
||||
}
|
||||
|
||||
export function requireOwnerOrAdmin(ctx: Context, ownerId: string): void {
|
||||
requireAuth(ctx);
|
||||
if (ctx.currentUser!.id !== ownerId && ctx.currentUser!.role !== "admin") {
|
||||
if (ctx.currentUser!.id !== ownerId && !ctx.currentUser!.is_admin) {
|
||||
throw new GraphQLError("Forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import Redis from "ioredis";
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: "model" | "viewer" | "admin";
|
||||
role: "model" | "viewer";
|
||||
is_admin: boolean;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
artist_name: string | null;
|
||||
|
||||
3
packages/backend/src/migrations/0001_is_admin.sql
Normal file
3
packages/backend/src/migrations/0001_is_admin.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "users" ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;--> statement-breakpoint
|
||||
UPDATE "users" SET "is_admin" = true WHERE "role" = 'admin';--> statement-breakpoint
|
||||
UPDATE "users" SET "role" = 'viewer' WHERE "role" = 'admin';
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1772645674513,
|
||||
"tag": "0000_pale_hellion",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1772645674514,
|
||||
"tag": "0001_is_admin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
"@iconify-json/ri": "^1.2.10",
|
||||
"@iconify/tailwind4": "^1.2.1",
|
||||
"@internationalized/date": "^3.11.0",
|
||||
"@lucide/svelte": "^0.577.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.53.4",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
const AGE_VERIFICATION_KEY = "age-verified";
|
||||
|
||||
let isOpen = true;
|
||||
let isOpen = $state(false);
|
||||
|
||||
function handleAgeConfirmation() {
|
||||
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
|
||||
@@ -21,9 +21,8 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
|
||||
if (storedVerification === "true") {
|
||||
isOpen = false;
|
||||
if (localStorage.getItem(AGE_VERIFICATION_KEY) !== "true") {
|
||||
isOpen = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import LogoutButton from "../logout-button/logout-button.svelte";
|
||||
import Separator from "../ui/separator/separator.svelte";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||
import { getUserInitials } from "$lib/utils";
|
||||
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
||||
import Logo from "../logo/logo.svelte";
|
||||
|
||||
@@ -109,7 +107,7 @@
|
||||
<span class="sr-only">{$_("header.play")}</span>
|
||||
</Button>
|
||||
|
||||
{#if authStatus.user?.role === "admin"}
|
||||
{#if authStatus.user?.is_admin}
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
@@ -172,7 +170,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">
|
||||
@@ -180,34 +178,17 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-1 py-6 px-5 space-y-6">
|
||||
<!-- User Profile Card -->
|
||||
<!-- User logout slider -->
|
||||
{#if authStatus.authenticated}
|
||||
<div
|
||||
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"></div>
|
||||
<div class="relative flex items-center gap-3">
|
||||
<Avatar class="h-9 w-9 ring-2 ring-primary/30">
|
||||
<AvatarImage
|
||||
src={getAssetUrl(authStatus.user!.avatar, "mini")}
|
||||
alt={authStatus.user!.artist_name}
|
||||
<LogoutButton
|
||||
user={{
|
||||
name: authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
|
||||
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
|
||||
email: authStatus.user!.email,
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
class="w-full"
|
||||
/>
|
||||
<AvatarFallback
|
||||
class="bg-gradient-to-br from-primary to-accent text-primary-foreground font-semibold"
|
||||
>
|
||||
{getUserInitials(authStatus.user!.artist_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-1 flex-col min-w-0">
|
||||
<p class="text-sm font-semibold text-foreground truncate">
|
||||
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
{authStatus.user!.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation -->
|
||||
@@ -278,7 +259,7 @@
|
||||
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||
</a>
|
||||
|
||||
{#if authStatus.user?.role === "admin"}
|
||||
{#if authStatus.user?.is_admin}
|
||||
<a
|
||||
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/admin" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
||||
href="/admin/users"
|
||||
@@ -340,21 +321,5 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if authStatus.authenticated}
|
||||
<button
|
||||
class="cursor-pointer flex w-full items-center gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 transition-all duration-200 hover:bg-destructive/10 hover:border-destructive/30 group"
|
||||
onclick={handleLogout}
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-destructive/10 group-hover:bg-destructive/20 transition-colors"
|
||||
>
|
||||
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"></span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-0.5 text-left">
|
||||
<span class="text-sm font-medium text-foreground">{$_("header.logout")}</span>
|
||||
<span class="text-xs text-muted-foreground">{$_("header.logout_hint")}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,15 +11,17 @@
|
||||
interface Props {
|
||||
user: User;
|
||||
onLogout: () => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { user, onLogout }: Props = $props();
|
||||
let { user, onLogout, class: className = "" }: Props = $props();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let isDragging = $state(false);
|
||||
let slidePosition = $state(0);
|
||||
let startX = 0;
|
||||
let currentX = 0;
|
||||
let maxSlide = 117; // Maximum slide distance
|
||||
let maxSlide = $derived(container ? container.offsetWidth - 40 : 117);
|
||||
let threshold = 0.75; // 70% threshold to trigger logout
|
||||
|
||||
// Calculate slide progress (0 to 1)
|
||||
@@ -102,9 +104,10 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="relative h-10 w-40 rounded-full bg-muted/30 overflow-hidden select-none transition-all duration-300 bg-muted/40 shadow-lg shadow-accent/10 {isDragging
|
||||
? 'cursor-grabbing'
|
||||
: ''}"
|
||||
: ''} {className}"
|
||||
style="background: linear-gradient(90deg,
|
||||
oklch(var(--primary) / 0.3) 0%,
|
||||
oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%,
|
||||
|
||||
50
packages/frontend/src/lib/components/ui/badge/badge.svelte
Normal file
50
packages/frontend/src/lib/components/ui/badge/badge.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const badgeVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||
destructive:
|
||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
href,
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: BadgeVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? "a" : "span"}
|
||||
bind:this={ref}
|
||||
data-slot="badge"
|
||||
{href}
|
||||
class={cn(badgeVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
2
packages/frontend/src/lib/components/ui/badge/index.ts
Normal file
2
packages/frontend/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from "svelte";
|
||||
import type Calendar from "./calendar.svelte";
|
||||
import CalendarMonthSelect from "./calendar-month-select.svelte";
|
||||
import CalendarYearSelect from "./calendar-year-select.svelte";
|
||||
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
||||
|
||||
let {
|
||||
captionLayout,
|
||||
months,
|
||||
monthFormat,
|
||||
years,
|
||||
yearFormat,
|
||||
month,
|
||||
locale,
|
||||
placeholder = $bindable(),
|
||||
monthIndex = 0,
|
||||
}: {
|
||||
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
|
||||
months: ComponentProps<typeof CalendarMonthSelect>["months"];
|
||||
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
|
||||
years: ComponentProps<typeof CalendarYearSelect>["years"];
|
||||
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
|
||||
month: DateValue;
|
||||
placeholder: DateValue | undefined;
|
||||
locale: string;
|
||||
monthIndex: number;
|
||||
} = $props();
|
||||
|
||||
function formatYear(date: DateValue) {
|
||||
const dateObj = date.toDate(getLocalTimeZone());
|
||||
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
||||
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
||||
}
|
||||
|
||||
function formatMonth(date: DateValue) {
|
||||
const dateObj = date.toDate(getLocalTimeZone());
|
||||
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
||||
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet MonthSelect()}
|
||||
<CalendarMonthSelect
|
||||
{months}
|
||||
{monthFormat}
|
||||
value={month.month}
|
||||
onchange={(e) => {
|
||||
if (!placeholder) return;
|
||||
const v = Number.parseInt(e.currentTarget.value);
|
||||
const newPlaceholder = placeholder.set({ month: v });
|
||||
placeholder = newPlaceholder.subtract({ months: monthIndex });
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet YearSelect()}
|
||||
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
||||
{/snippet}
|
||||
|
||||
{#if captionLayout === "dropdown"}
|
||||
{@render MonthSelect()}
|
||||
{@render YearSelect()}
|
||||
{:else if captionLayout === "dropdown-months"}
|
||||
{@render MonthSelect()}
|
||||
{#if placeholder}
|
||||
{formatYear(placeholder)}
|
||||
{/if}
|
||||
{:else if captionLayout === "dropdown-years"}
|
||||
{#if placeholder}
|
||||
{formatMonth(placeholder)}
|
||||
{/if}
|
||||
{@render YearSelect()}
|
||||
{:else}
|
||||
{formatMonth(month)} {formatYear(month)}
|
||||
{/if}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.CellProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Cell
|
||||
bind:ref
|
||||
class={cn(
|
||||
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.DayProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Day
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
|
||||
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
||||
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
|
||||
// Outside months
|
||||
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
||||
// Disabled
|
||||
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
// Unavailable
|
||||
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
||||
// hover
|
||||
"dark:hover:text-accent-foreground",
|
||||
// focus
|
||||
"focus:border-ring focus:ring-ring/50 focus:relative",
|
||||
// inner spans
|
||||
"[&>span]:text-xs [&>span]:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridBodyProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridHeadProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridRowProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Grid
|
||||
bind:ref
|
||||
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeadCellProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.HeadCell
|
||||
bind:ref
|
||||
class={cn(
|
||||
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeaderProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Header
|
||||
bind:ref
|
||||
class={cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeadingProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Heading
|
||||
bind:ref
|
||||
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
onchange,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarPrimitive.MonthSelect
|
||||
bind:ref
|
||||
class="dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet child({ props, monthItems, selectedMonthItem })}
|
||||
<select {...props} {value} {onchange}>
|
||||
{#each monthItems as monthItem (monthItem.value)}
|
||||
<option
|
||||
value={monthItem.value}
|
||||
selected={value !== undefined
|
||||
? monthItem.value === value
|
||||
: monthItem.value === selectedMonthItem.value}
|
||||
>
|
||||
{monthItem.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span
|
||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.MonthSelect>
|
||||
</span>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { type WithElementRef, cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<nav
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</nav>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = "ghost",
|
||||
...restProps
|
||||
}: CalendarPrimitive.NextButtonProps & {
|
||||
variant?: ButtonVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronRightIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<CalendarPrimitive.NextButton
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant }),
|
||||
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = "ghost",
|
||||
...restProps
|
||||
}: CalendarPrimitive.PrevButtonProps & {
|
||||
variant?: ButtonVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronLeftIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<CalendarPrimitive.PrevButton
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant }),
|
||||
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarPrimitive.YearSelect
|
||||
bind:ref
|
||||
class="dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet child({ props, yearItems, selectedYearItem })}
|
||||
<select {...props} {value}>
|
||||
{#each yearItems as yearItem (yearItem.value)}
|
||||
<option
|
||||
value={yearItem.value}
|
||||
selected={value !== undefined
|
||||
? yearItem.value === value
|
||||
: yearItem.value === selectedYearItem.value}
|
||||
>
|
||||
{yearItem.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span
|
||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.YearSelect>
|
||||
</span>
|
||||
115
packages/frontend/src/lib/components/ui/calendar/calendar.svelte
Normal file
115
packages/frontend/src/lib/components/ui/calendar/calendar.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import * as Calendar from "./index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ButtonVariant } from "../button/button.svelte";
|
||||
import { isEqualMonth, type DateValue } from "@internationalized/date";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
placeholder = $bindable(),
|
||||
class: className,
|
||||
weekdayFormat = "short",
|
||||
buttonVariant = "ghost",
|
||||
captionLayout = "label",
|
||||
locale = "en-US",
|
||||
months: monthsProp,
|
||||
years,
|
||||
monthFormat: monthFormatProp,
|
||||
yearFormat = "numeric",
|
||||
day,
|
||||
disableDaysOutsideMonth = false,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
||||
buttonVariant?: ButtonVariant;
|
||||
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
|
||||
months?: CalendarPrimitive.MonthSelectProps["months"];
|
||||
years?: CalendarPrimitive.YearSelectProps["years"];
|
||||
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
|
||||
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
|
||||
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
||||
} = $props();
|
||||
|
||||
const monthFormat = $derived.by(() => {
|
||||
if (monthFormatProp) return monthFormatProp;
|
||||
if (captionLayout.startsWith("dropdown")) return "short";
|
||||
return "long";
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Discriminated Unions + Destructing (required for bindable) do not
|
||||
get along, so we shut typescript up by casting `value` to `never`.
|
||||
-->
|
||||
<CalendarPrimitive.Root
|
||||
bind:value={value as never}
|
||||
bind:ref
|
||||
bind:placeholder
|
||||
{weekdayFormat}
|
||||
{disableDaysOutsideMonth}
|
||||
class={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{locale}
|
||||
{monthFormat}
|
||||
{yearFormat}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ months, weekdays })}
|
||||
<Calendar.Months>
|
||||
<Calendar.Nav>
|
||||
<Calendar.PrevButton variant={buttonVariant} />
|
||||
<Calendar.NextButton variant={buttonVariant} />
|
||||
</Calendar.Nav>
|
||||
{#each months as month, monthIndex (month)}
|
||||
<Calendar.Month>
|
||||
<Calendar.Header>
|
||||
<Calendar.Caption
|
||||
{captionLayout}
|
||||
months={monthsProp}
|
||||
{monthFormat}
|
||||
{years}
|
||||
{yearFormat}
|
||||
month={month.value}
|
||||
bind:placeholder
|
||||
{locale}
|
||||
{monthIndex}
|
||||
/>
|
||||
</Calendar.Header>
|
||||
<Calendar.Grid>
|
||||
<Calendar.GridHead>
|
||||
<Calendar.GridRow class="select-none">
|
||||
{#each weekdays as weekday (weekday)}
|
||||
<Calendar.HeadCell>
|
||||
{weekday.slice(0, 2)}
|
||||
</Calendar.HeadCell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
</Calendar.GridHead>
|
||||
<Calendar.GridBody>
|
||||
{#each month.weeks as weekDates (weekDates)}
|
||||
<Calendar.GridRow class="mt-2 w-full">
|
||||
{#each weekDates as date (date)}
|
||||
<Calendar.Cell {date} month={month.value}>
|
||||
{#if day}
|
||||
{@render day({
|
||||
day: date,
|
||||
outsideMonth: !isEqualMonth(date, month.value),
|
||||
})}
|
||||
{:else}
|
||||
<Calendar.Day />
|
||||
{/if}
|
||||
</Calendar.Cell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
{/each}
|
||||
</Calendar.GridBody>
|
||||
</Calendar.Grid>
|
||||
</Calendar.Month>
|
||||
{/each}
|
||||
</Calendar.Months>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.Root>
|
||||
40
packages/frontend/src/lib/components/ui/calendar/index.ts
Normal file
40
packages/frontend/src/lib/components/ui/calendar/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import Root from "./calendar.svelte";
|
||||
import Cell from "./calendar-cell.svelte";
|
||||
import Day from "./calendar-day.svelte";
|
||||
import Grid from "./calendar-grid.svelte";
|
||||
import Header from "./calendar-header.svelte";
|
||||
import Months from "./calendar-months.svelte";
|
||||
import GridRow from "./calendar-grid-row.svelte";
|
||||
import Heading from "./calendar-heading.svelte";
|
||||
import GridBody from "./calendar-grid-body.svelte";
|
||||
import GridHead from "./calendar-grid-head.svelte";
|
||||
import HeadCell from "./calendar-head-cell.svelte";
|
||||
import NextButton from "./calendar-next-button.svelte";
|
||||
import PrevButton from "./calendar-prev-button.svelte";
|
||||
import MonthSelect from "./calendar-month-select.svelte";
|
||||
import YearSelect from "./calendar-year-select.svelte";
|
||||
import Month from "./calendar-month.svelte";
|
||||
import Nav from "./calendar-nav.svelte";
|
||||
import Caption from "./calendar-caption.svelte";
|
||||
|
||||
export {
|
||||
Day,
|
||||
Cell,
|
||||
Grid,
|
||||
Header,
|
||||
Months,
|
||||
GridRow,
|
||||
Heading,
|
||||
GridBody,
|
||||
GridHead,
|
||||
HeadCell,
|
||||
NextButton,
|
||||
PrevButton,
|
||||
Nav,
|
||||
Month,
|
||||
YearSelect,
|
||||
MonthSelect,
|
||||
Caption,
|
||||
//
|
||||
Root as Calendar,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { parseDate, type DateValue } from "@internationalized/date";
|
||||
import { Calendar } from "$lib/components/ui/calendar";
|
||||
import * as Popover from "$lib/components/ui/popover";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
|
||||
let {
|
||||
value = $bindable(""),
|
||||
placeholder = "Pick a date",
|
||||
showTime = true,
|
||||
}: {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
showTime?: boolean;
|
||||
} = $props();
|
||||
|
||||
function toCalendarDate(v: string): DateValue | undefined {
|
||||
if (!v) return undefined;
|
||||
try {
|
||||
return parseDate(v.slice(0, 10));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function pad(n: number) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
let calendarDate = $state<DateValue | undefined>(toCalendarDate(value));
|
||||
let timeStr = $state(value.length >= 16 ? value.slice(11, 16) : "00:00");
|
||||
let open = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (calendarDate) {
|
||||
const d = calendarDate;
|
||||
const dateStr = `${d.year}-${pad(d.month)}-${pad(d.day)}`;
|
||||
value = showTime ? `${dateStr}T${timeStr}` : dateStr;
|
||||
} else {
|
||||
value = "";
|
||||
}
|
||||
});
|
||||
|
||||
let displayLabel = $derived.by(() => {
|
||||
if (!calendarDate) return placeholder;
|
||||
const d = calendarDate;
|
||||
const date = new Date(d.year, d.month - 1, d.day);
|
||||
const dateLabel = date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
return showTime ? `${dateLabel} ${timeStr}` : dateLabel;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
variant="outline"
|
||||
{...props}
|
||||
class="w-full justify-start font-normal {!calendarDate ? 'text-muted-foreground' : ''}"
|
||||
>
|
||||
<span class="icon-[ri--calendar-line] h-4 w-4 mr-2 shrink-0"></span>
|
||||
{displayLabel}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-auto p-0" align="start">
|
||||
<Calendar bind:value={calendarDate} />
|
||||
{#if showTime}
|
||||
<div class="border-t border-border/40 p-3">
|
||||
<input
|
||||
type="time"
|
||||
value={timeStr}
|
||||
oninput={(e) => {
|
||||
timeStr = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatePicker } from "./date-picker.svelte";
|
||||
19
packages/frontend/src/lib/components/ui/popover/index.ts
Normal file
19
packages/frontend/src/lib/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Root from "./popover.svelte";
|
||||
import Close from "./popover-close.svelte";
|
||||
import Content from "./popover-content.svelte";
|
||||
import Trigger from "./popover-trigger.svelte";
|
||||
import Portal from "./popover-portal.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
Close,
|
||||
Portal,
|
||||
//
|
||||
Root as Popover,
|
||||
Content as PopoverContent,
|
||||
Trigger as PopoverTrigger,
|
||||
Close as PopoverClose,
|
||||
Portal as PopoverPortal,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import PopoverPortal from "./popover-portal.svelte";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
portalProps,
|
||||
...restProps
|
||||
}: PopoverPrimitive.ContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPortal {...portalProps}>
|
||||
<PopoverPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="popover-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</PopoverPortal>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: PopoverPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="popover-trigger"
|
||||
class={cn("", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Root bind:open {...restProps} />
|
||||
@@ -168,6 +168,7 @@ export default {
|
||||
no_account: "Don't have an account?",
|
||||
sign_up_link: "Sign up now",
|
||||
error: "Heads Up!",
|
||||
error_invalid_credentials: "Invalid email or password.",
|
||||
},
|
||||
signup: {
|
||||
title: "Create Account",
|
||||
@@ -194,6 +195,7 @@ export default {
|
||||
have_account: "Already have an account?",
|
||||
sign_in_link: "Sign in here",
|
||||
error: "Heads Up!",
|
||||
error_email_taken: "This email address is already registered.",
|
||||
agree_error: "You must confirm our terms of service and your age.",
|
||||
password_error: "The password has to match the confirmation password.",
|
||||
toast_register: "A verification email has been sent to {email}!",
|
||||
@@ -221,6 +223,7 @@ export default {
|
||||
resetting: "Resetting...",
|
||||
reset: "Reset",
|
||||
error: "Heads Up!",
|
||||
error_invalid_token: "This reset link is invalid or has expired.",
|
||||
password_error: "The password has to match the confirmation password.",
|
||||
toast_reset: "Your password has been reset!",
|
||||
},
|
||||
@@ -896,6 +899,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",
|
||||
|
||||
@@ -62,6 +62,7 @@ const ME_QUERY = gql`
|
||||
description
|
||||
tags
|
||||
role
|
||||
is_admin
|
||||
avatar
|
||||
banner
|
||||
email_verified
|
||||
@@ -228,10 +229,10 @@ const ARTICLES_QUERY = gql`
|
||||
category
|
||||
featured
|
||||
author {
|
||||
first_name
|
||||
last_name
|
||||
id
|
||||
artist_name
|
||||
slug
|
||||
avatar
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,10 +259,10 @@ const ARTICLE_BY_SLUG_QUERY = gql`
|
||||
category
|
||||
featured
|
||||
author {
|
||||
first_name
|
||||
last_name
|
||||
id
|
||||
artist_name
|
||||
slug
|
||||
avatar
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1022,6 +1023,7 @@ const ADMIN_LIST_USERS_QUERY = gql`
|
||||
artist_name
|
||||
slug
|
||||
role
|
||||
is_admin
|
||||
avatar
|
||||
email_verified
|
||||
date_created
|
||||
@@ -1052,6 +1054,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
|
||||
mutation AdminUpdateUser(
|
||||
$userId: String!
|
||||
$role: String
|
||||
$isAdmin: Boolean
|
||||
$firstName: String
|
||||
$lastName: String
|
||||
$artistName: String
|
||||
@@ -1061,6 +1064,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
|
||||
adminUpdateUser(
|
||||
userId: $userId
|
||||
role: $role
|
||||
isAdmin: $isAdmin
|
||||
firstName: $firstName
|
||||
lastName: $lastName
|
||||
artistName: $artistName
|
||||
@@ -1073,6 +1077,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
|
||||
last_name
|
||||
artist_name
|
||||
role
|
||||
is_admin
|
||||
avatar
|
||||
banner
|
||||
date_created
|
||||
@@ -1083,6 +1088,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
|
||||
export async function adminUpdateUser(input: {
|
||||
userId: string;
|
||||
role?: string;
|
||||
isAdmin?: boolean;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
artistName?: string;
|
||||
@@ -1128,6 +1134,7 @@ const ADMIN_GET_USER_QUERY = gql`
|
||||
artist_name
|
||||
slug
|
||||
role
|
||||
is_admin
|
||||
avatar
|
||||
banner
|
||||
description
|
||||
@@ -1380,8 +1387,9 @@ const ADMIN_LIST_ARTICLES_QUERY = gql`
|
||||
featured
|
||||
content
|
||||
author {
|
||||
first_name
|
||||
last_name
|
||||
id
|
||||
artist_name
|
||||
slug
|
||||
avatar
|
||||
}
|
||||
}
|
||||
@@ -1460,6 +1468,7 @@ const UPDATE_ARTICLE_MUTATION = gql`
|
||||
$excerpt: String
|
||||
$content: String
|
||||
$imageId: String
|
||||
$authorId: String
|
||||
$tags: [String!]
|
||||
$category: String
|
||||
$featured: Boolean
|
||||
@@ -1472,6 +1481,7 @@ const UPDATE_ARTICLE_MUTATION = gql`
|
||||
excerpt: $excerpt
|
||||
content: $content
|
||||
imageId: $imageId
|
||||
authorId: $authorId
|
||||
tags: $tags
|
||||
category: $category
|
||||
featured: $featured
|
||||
@@ -1491,6 +1501,7 @@ export async function updateArticle(input: {
|
||||
excerpt?: string;
|
||||
content?: string;
|
||||
imageId?: string;
|
||||
authorId?: string | null;
|
||||
tags?: string[];
|
||||
category?: string;
|
||||
featured?: boolean;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { _ } from "svelte-i18n";
|
||||
import { page } from "$app/state";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
|
||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
@@ -21,11 +20,7 @@
|
||||
<!-- Content -->
|
||||
<div class="relative z-10 container mx-auto px-4 text-center">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Premium Glassmorphism Card -->
|
||||
<Card
|
||||
class="bg-gradient-to-br from-card/25 via-card/30 to-card/20 backdrop-blur-2xl shadow-2xl shadow-primary/20"
|
||||
>
|
||||
<CardContent class="p-12">
|
||||
<div class="py-12">
|
||||
<!-- 404 Animation -->
|
||||
<div class="mb-8">
|
||||
<div
|
||||
@@ -92,8 +87,7 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export async function load({ locals }) {
|
||||
if (!locals.authStatus.authenticated || locals.authStatus.user?.role !== "admin") {
|
||||
if (!locals.authStatus.authenticated || !locals.authStatus.user?.is_admin) {
|
||||
throw redirect(302, "/");
|
||||
}
|
||||
return { authStatus: locals.authStatus };
|
||||
|
||||
@@ -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,34 +27,34 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-3 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">Articles</h1>
|
||||
<div class="py-3 sm:py-6 sm:pl-6">
|
||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
||||
<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>
|
||||
|
||||
<div class="rounded-lg border border-border/40 overflow-x-auto">
|
||||
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
|
||||
<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,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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -10,6 +11,8 @@
|
||||
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";
|
||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -24,6 +27,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");
|
||||
|
||||
@@ -37,9 +42,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,15 +58,16 @@
|
||||
excerpt: excerpt || undefined,
|
||||
content: content || undefined,
|
||||
imageId: imageId || undefined,
|
||||
authorId: authorId || null,
|
||||
tags,
|
||||
category: category || undefined,
|
||||
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;
|
||||
}
|
||||
@@ -71,43 +77,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">
|
||||
@@ -121,14 +127,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")}
|
||||
@@ -139,32 +145,60 @@
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.article_form.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">{$_("admin.article_form.no_author")}</span>
|
||||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{$_("admin.article_form.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>
|
||||
<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>
|
||||
<Input id="publishDate" type="datetime-local" bind:value={publishDate} />
|
||||
<Label>{$_("admin.common.publish_date")}</Label>
|
||||
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
|
||||
</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";
|
||||
@@ -8,6 +9,7 @@
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||
|
||||
let title = $state("");
|
||||
@@ -39,15 +41,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 +65,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 +78,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 +137,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>
|
||||
<Input id="publishDate" type="datetime-local" bind:value={publishDate} />
|
||||
<Label>{$_("admin.common.publish_date")}</Label>
|
||||
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
|
||||
</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,11 +3,13 @@
|
||||
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";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import type { User } from "$lib/types";
|
||||
|
||||
@@ -22,7 +24,7 @@
|
||||
|
||||
const currentUserId = page.data.authStatus?.user?.id;
|
||||
|
||||
const roles = ["", "viewer", "model", "admin"] as const;
|
||||
const roles = ["", "viewer", "model"] as const;
|
||||
|
||||
function debounceSearch(value: string) {
|
||||
clearTimeout(searchTimeout);
|
||||
@@ -47,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;
|
||||
}
|
||||
@@ -66,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;
|
||||
}
|
||||
@@ -82,16 +84,16 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<div class="py-3 sm:py-6 sm:pl-6">
|
||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
||||
<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">
|
||||
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
|
||||
<Input
|
||||
placeholder="Search email or name…"
|
||||
placeholder={$_("admin.users.search_placeholder")}
|
||||
class="max-w-xs"
|
||||
value={searchValue}
|
||||
oninput={(e) => {
|
||||
@@ -107,22 +109,22 @@
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="rounded-lg border border-border/40 overflow-x-auto">
|
||||
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
|
||||
<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">
|
||||
@@ -144,7 +146,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium block truncate">{user.artist_name || user.first_name || "—"}</span>
|
||||
<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.users.admin_badge")}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground sm:hidden truncate block">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,9 +168,8 @@
|
||||
{user.role}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="viewer">Viewer</SelectItem>
|
||||
<SelectItem value="model">Model</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="viewer">{$_("admin.users.role_viewer")}</SelectItem>
|
||||
<SelectItem value="model">{$_("admin.users.role_model")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
@@ -189,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>
|
||||
@@ -198,9 +204,9 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if data.total > data.limit}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
|
||||
<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
|
||||
@@ -213,7 +219,7 @@
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
{$_("common.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -225,7 +231,7 @@
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
Next
|
||||
{$_("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,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";
|
||||
@@ -20,6 +21,7 @@
|
||||
let artistName = $state(data.user.artist_name ?? "");
|
||||
let avatarId = $state<string | null>(data.user.avatar ?? null);
|
||||
let bannerId = $state<string | null>(data.user.banner ?? null);
|
||||
let isAdmin = $state(data.user.is_admin ?? false);
|
||||
let saving = $state(false);
|
||||
|
||||
async function handleAvatarUpload(files: File[]) {
|
||||
@@ -30,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +88,11 @@
|
||||
artistName: artistName || undefined,
|
||||
avatarId: avatarId || undefined,
|
||||
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;
|
||||
}
|
||||
@@ -99,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}</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>
|
||||
|
||||
@@ -111,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")}
|
||||
@@ -140,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")}
|
||||
@@ -151,15 +154,24 @@
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} />
|
||||
</div>
|
||||
|
||||
<!-- Admin flag -->
|
||||
<label class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors">
|
||||
<input type="checkbox" bind:checked={isAdmin} class="h-4 w-4 rounded accent-primary shrink-0" />
|
||||
<div>
|
||||
<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">
|
||||
@@ -181,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,9 +1,11 @@
|
||||
<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";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import type { Video } from "$lib/types";
|
||||
|
||||
@@ -23,35 +25,35 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-3 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">Videos</h1>
|
||||
<div class="py-3 sm:py-6 sm:pl-6">
|
||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
||||
<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>
|
||||
|
||||
<div class="rounded-lg border border-border/40 overflow-x-auto">
|
||||
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
|
||||
<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">
|
||||
@@ -81,15 +83,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">{$_("admin.common.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">{$_("admin.common.featured")}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
@@ -115,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>
|
||||
@@ -126,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";
|
||||
@@ -9,6 +10,8 @@
|
||||
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";
|
||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -36,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,18 +53,12 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleModel(id: string) {
|
||||
selectedModelIds = selectedModelIds.includes(id)
|
||||
? selectedModelIds.filter((m) => m !== id)
|
||||
: [...selectedModelIds, id];
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving = true;
|
||||
try {
|
||||
@@ -78,10 +75,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;
|
||||
}
|
||||
@@ -91,30 +88,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")}
|
||||
@@ -126,60 +123,69 @@
|
||||
</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>
|
||||
<video
|
||||
src={getAssetUrl(movieId)}
|
||||
poster={imageId ? getAssetUrl(imageId, "preview") ?? undefined : undefined}
|
||||
controls
|
||||
class="w-full rounded-lg bg-black max-h-72 mb-2"
|
||||
></video>
|
||||
{/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>
|
||||
<Input id="uploadDate" type="datetime-local" bind:value={uploadDate} />
|
||||
<Label>{$_("admin.common.publish_date")}</Label>
|
||||
<DatePicker bind:value={uploadDate} placeholder={$_("admin.common.publish_date")} showTime={false} />
|
||||
</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-2">
|
||||
<Label>Models</Label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.video_form.models")}</Label>
|
||||
<Select type="multiple" bind:value={selectedModelIds}>
|
||||
<SelectTrigger class="w-full">
|
||||
{#if selectedModelIds.length}
|
||||
{$_("admin.video_form.models_selected", { values: { count: selectedModelIds.length } })}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">{$_("admin.video_form.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}
|
||||
|
||||
<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,12 +1,14 @@
|
||||
<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";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||
|
||||
const { data } = $props();
|
||||
@@ -38,9 +40,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 +54,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 +68,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 +87,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 +100,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>
|
||||
<Input id="uploadDate" type="datetime-local" bind:value={uploadDate} />
|
||||
<Label>{$_("admin.common.publish_date")}</Label>
|
||||
<DatePicker bind:value={uploadDate} placeholder={$_("admin.common.publish_date")} showTime={false} />
|
||||
</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 +190,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>
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
await login(email, password);
|
||||
goto("/videos", { invalidateAll: true });
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
const raw = err.response?.errors?.[0]?.message ?? err.message;
|
||||
error = raw === "Invalid credentials" ? $_("auth.login.error_invalid_credentials") : raw;
|
||||
isError = true;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { deleteRecording, removeFile, updateProfile, uploadFile } from "$lib/services";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||
@@ -27,6 +28,9 @@
|
||||
const { data } = $props();
|
||||
|
||||
let recordings = $state(data.recordings);
|
||||
let deleteTarget = $state<string | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
let activeTab = $state("settings");
|
||||
|
||||
@@ -83,7 +87,7 @@
|
||||
toast.success($_("me.settings.toast_update"));
|
||||
invalidateAll();
|
||||
} catch (err: any) {
|
||||
profileError = err.message;
|
||||
profileError = err.response?.errors?.[0]?.message ?? err.message;
|
||||
isProfileError = true;
|
||||
} finally {
|
||||
isProfileLoading = false;
|
||||
@@ -107,7 +111,7 @@
|
||||
invalidateAll();
|
||||
password = confirmPassword = "";
|
||||
} catch (err: any) {
|
||||
securityError = err.message;
|
||||
securityError = err.response?.errors?.[0]?.message ?? err.message;
|
||||
isSecurityError = true;
|
||||
} finally {
|
||||
isSecurityLoading = false;
|
||||
@@ -153,17 +157,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRecording(id: string) {
|
||||
if (!confirm($_("me.recordings.delete_confirm"))) {
|
||||
return;
|
||||
function handleDeleteRecording(id: string) {
|
||||
deleteTarget = id;
|
||||
deleteOpen = true;
|
||||
}
|
||||
|
||||
async function confirmDeleteRecording() {
|
||||
if (!deleteTarget) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await deleteRecording(id);
|
||||
recordings = recordings.filter((r) => r.id !== id);
|
||||
await deleteRecording(deleteTarget);
|
||||
recordings = recordings.filter((r) => r.id !== deleteTarget);
|
||||
toast.success($_("me.recordings.delete_success"));
|
||||
deleteOpen = false;
|
||||
deleteTarget = null;
|
||||
} catch {
|
||||
toast.error($_("me.recordings.delete_error"));
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,29 +205,19 @@
|
||||
<PeonyBackground />
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1
|
||||
class="text-4xl md:text-5xl font-bold bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent mb-3"
|
||||
>
|
||||
{$_("me.title")}
|
||||
</h1>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{$_("me.welcome", {
|
||||
values: { name: data.authStatus.user!.artist_name },
|
||||
})}
|
||||
<h1 class="text-2xl font-bold">{$_("me.title")}</h1>
|
||||
<p class="text-sm text-muted-foreground mt-0.5">
|
||||
{$_("me.welcome", { values: { name: data.authStatus.user!.artist_name } })}
|
||||
</p>
|
||||
</div>
|
||||
{#if isModel(data.authStatus.user!)}
|
||||
<Button
|
||||
href={`/models/${data.authStatus.user!.slug}`}
|
||||
variant="outline"
|
||||
class="border-primary/20 hover:bg-primary/10">{$_("me.view_profile")}</Button
|
||||
>
|
||||
<Button href={`/models/${data.authStatus.user!.slug}`} variant="outline">
|
||||
{$_("me.view_profile")}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Tabs -->
|
||||
<Tabs bind:value={activeTab} class="w-full">
|
||||
@@ -641,3 +642,18 @@
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={deleteOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$_("me.recordings.delete_confirm")}</Dialog.Title>
|
||||
<Dialog.Description>This cannot be undone.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
|
||||
<Button variant="destructive" disabled={deleting} onclick={confirmDeleteRecording}>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
toast.success($_("auth.password_request.toast_request", { values: { email } }));
|
||||
goto("/login");
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
error = err.response?.errors?.[0]?.message ?? err.message;
|
||||
isError = true;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
|
||||
@@ -40,7 +40,9 @@
|
||||
toast.success($_("auth.password_reset.toast_reset"));
|
||||
goto("/login");
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
const raw = err.response?.errors?.[0]?.message ?? err.message;
|
||||
const tokenErrors = ["Invalid or expired reset token", "Reset token expired"];
|
||||
error = tokenErrors.includes(raw) ? $_("auth.password_reset.error_invalid_token") : raw;
|
||||
isError = true;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
toast.success($_("auth.signup.toast_register", { values: { email } }));
|
||||
goto("/login");
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
const raw = err.response?.errors?.[0]?.message ?? err.message;
|
||||
error = raw === "Email already registered" ? $_("auth.signup.error_email_taken") : raw;
|
||||
isError = true;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
|
||||
@@ -21,7 +21,8 @@ export interface User {
|
||||
slug: string | null;
|
||||
description: string | null;
|
||||
tags: string[] | null;
|
||||
role: "model" | "viewer" | "admin";
|
||||
role: "model" | "viewer";
|
||||
is_admin: boolean;
|
||||
/** UUID of the avatar file */
|
||||
avatar: string | null;
|
||||
/** UUID of the banner file */
|
||||
@@ -86,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;
|
||||
@@ -105,7 +98,7 @@ export interface Article {
|
||||
publish_date: Date;
|
||||
category: string | null;
|
||||
featured: boolean | null;
|
||||
author?: ArticleAuthor | null;
|
||||
author?: VideoModel | null;
|
||||
}
|
||||
|
||||
// ─── Comment ─────────────────────────────────────────────────────────────────
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -186,8 +186,8 @@ importers:
|
||||
specifier: ^3.11.0
|
||||
version: 3.11.0
|
||||
'@lucide/svelte':
|
||||
specifier: ^0.577.0
|
||||
version: 0.577.0(svelte@5.53.7)
|
||||
specifier: ^0.561.0
|
||||
version: 0.561.0(svelte@5.53.7)
|
||||
'@sveltejs/adapter-node':
|
||||
specifier: ^5.5.4
|
||||
version: 5.5.4(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))
|
||||
@@ -1202,8 +1202,8 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@lucide/svelte@0.577.0':
|
||||
resolution: {integrity: sha512-0P6mkySd2MapIEgq08tADPmcN4DHndC/02PWwaLkOerXlx5Sv9aT4BxyXLIY+eccr0g/nEyCYiJesqS61YdBZQ==}
|
||||
'@lucide/svelte@0.561.0':
|
||||
resolution: {integrity: sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A==}
|
||||
peerDependencies:
|
||||
svelte: ^5
|
||||
|
||||
@@ -4138,7 +4138,7 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@lucide/svelte@0.577.0(svelte@5.53.7)':
|
||||
'@lucide/svelte@0.561.0(svelte@5.53.7)':
|
||||
dependencies:
|
||||
svelte: 5.53.7
|
||||
|
||||
|
||||
Reference in New Issue
Block a user