Compare commits

...

10 Commits

Author SHA1 Message Date
ac63e59906 style: remove card wrapper from error page
Some checks failed
Build and Push Backend Image / build (push) Failing after 27s
Build and Push Frontend Image / build (push) Successful in 5m7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:28:11 +01:00
19d29cbfc6 fix: replace flyout profile card with logout slider, i18n auth errors
- Replace static account card in mobile flyout with swipe-to-logout widget
- Remove redundant logout button from flyout bottom
- Make LogoutButton full-width via class prop and dynamic maxSlide
- Extract clean GraphQL error messages instead of raw JSON in all auth forms
- Add i18n keys for known backend errors (invalid credentials, email taken, invalid token)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:26:14 +01:00
0ec27117ae style: streamline /me page header to match admin dashboard style
Replace large gradient title with simple text-2xl font-bold heading,
matching the header pattern used across admin pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:13:19 +01:00
ed9eb6ef22 style: fix admin table padding — edge-to-edge on mobile, no right pad on desktop
Mobile: remove horizontal padding so tables fill full width with top/bottom
borders only. Desktop: keep left padding, table extends to right edge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:08:16 +01:00
609f116b5d feat: replace native date inputs with shadcn date picker
Add calendar + popover components and a custom DateTimePicker wrapper.
Video forms use date-only; article forms include a time picker.
Also add video player preview to the video edit form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:03:35 +01:00
e943876e70 fix: prevent age verification dialog flicker on page load
Initialize isOpen as false and only open in onMount if not yet verified,
instead of opening immediately and closing after localStorage check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 16:52:48 +01:00
7d373b3aa3 i18n: internationalize all admin pages
Add full i18n coverage for the admin section — locale keys, layout nav,
users, videos, and articles pages (list, new, edit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 16:49:30 +01:00
95fd9f48fc refactor: align article author with VideoModel, streamline selects, fix flyout inert
- Remove ArticleAuthor type; article.author now reuses VideoModel (id, artist_name, slug, avatar)
- updateArticle accepts authorId; author selectable in admin article edit page
- Article edit: single Select with bind:value + $derived selectedAuthor display
- Video edit: replace pill toggles with Select type="multiple" bind:value for models
- Video table: replace inline badge spans with Badge component
- Magazine: display artist_name throughout, author bio links to model profile
- Fix flyout aria-hidden warning: replace with inert attribute

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 16:31:41 +01:00
670c18bcb7 feat: refactor role system to is_admin flag, add Badge component, fix native dialogs
- Separate admin identity from role: viewer|model + is_admin boolean flag
- DB migration 0001_is_admin: adds column, migrates former admin role users
- Update ACL helpers, auth session, GraphQL types and all resolvers
- Admin layout guard and header links check is_admin instead of role
- Admin users table: show Admin badge next to name, remove admin from role select
- Admin user edit page: is_admin checkbox toggle
- Install shadcn Badge component; use in admin users table
- Fix duplicate photo keys in adminGetUser resolver
- Replace confirm() in /me recordings with Dialog component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 16:14:00 +01:00
9ef490c1e5 fix: make deleteRecording a hard delete instead of soft archive
Previously deleteRecording set status to "archived", leaving the row
in the DB and visible in queries without a status filter. Now it hard-
deletes the row. Also excludes archived recordings from the default
recordings query so any pre-existing archived rows no longer appear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:45:59 +01:00
67 changed files with 1429 additions and 420 deletions

View File

@@ -29,6 +29,7 @@ export const users = pgTable(
role: roleEnum("role").notNull().default("viewer"), role: roleEnum("role").notNull().default("viewer"),
avatar: text("avatar").references(() => files.id, { onDelete: "set null" }), avatar: text("avatar").references(() => files.id, { onDelete: "set null" }),
banner: text("banner").references(() => files.id, { onDelete: "set null" }), banner: text("banner").references(() => files.id, { onDelete: "set null" }),
is_admin: boolean("is_admin").notNull().default(false),
email_verified: boolean("email_verified").notNull().default(false), email_verified: boolean("email_verified").notNull().default(false),
email_verify_token: text("email_verify_token"), email_verify_token: text("email_verify_token"),
password_reset_token: text("password_reset_token"), password_reset_token: text("password_reset_token"),

View File

@@ -2,17 +2,17 @@ import { builder } from "../builder";
import { ArticleType } from "../types/index"; import { ArticleType } from "../types/index";
import { articles, users } from "../../db/schema/index"; import { articles, users } from "../../db/schema/index";
import { eq, and, lte, desc } from "drizzle-orm"; 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) { async function enrichArticle(db: any, article: any) {
let author = null; let author = null;
if (article.author) { if (article.author) {
const authorUser = await db const authorUser = await db
.select({ .select({
first_name: users.first_name, id: users.id,
last_name: users.last_name, artist_name: users.artist_name,
slug: users.slug,
avatar: users.avatar, avatar: users.avatar,
description: users.description,
}) })
.from(users) .from(users)
.where(eq(users.id, article.author)) .where(eq(users.id, article.author))
@@ -78,7 +78,7 @@ builder.queryField("adminListArticles", (t) =>
t.field({ t.field({
type: [ArticleType], type: [ArticleType],
resolve: async (_root, _args, ctx) => { resolve: async (_root, _args, ctx) => {
requireRole(ctx, "admin"); requireAdmin(ctx);
const articleList = await ctx.db.select().from(articles).orderBy(desc(articles.publish_date)); const articleList = await ctx.db.select().from(articles).orderBy(desc(articles.publish_date));
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article))); return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
}, },
@@ -100,7 +100,7 @@ builder.mutationField("createArticle", (t) =>
publishDate: t.arg.string(), publishDate: t.arg.string(),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin"); requireAdmin(ctx);
const inserted = await ctx.db const inserted = await ctx.db
.insert(articles) .insert(articles)
.values({ .values({
@@ -132,19 +132,21 @@ builder.mutationField("updateArticle", (t) =>
excerpt: t.arg.string(), excerpt: t.arg.string(),
content: t.arg.string(), content: t.arg.string(),
imageId: t.arg.string(), imageId: t.arg.string(),
authorId: t.arg.string(),
tags: t.arg.stringList(), tags: t.arg.stringList(),
category: t.arg.string(), category: t.arg.string(),
featured: t.arg.boolean(), featured: t.arg.boolean(),
publishDate: t.arg.string(), publishDate: t.arg.string(),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin"); requireAdmin(ctx);
const updates: Record<string, unknown> = { date_updated: new Date() }; const updates: Record<string, unknown> = { date_updated: new Date() };
if (args.title !== undefined && args.title !== null) updates.title = args.title; if (args.title !== undefined && args.title !== null) updates.title = args.title;
if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug; if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug;
if (args.excerpt !== undefined) updates.excerpt = args.excerpt; if (args.excerpt !== undefined) updates.excerpt = args.excerpt;
if (args.content !== undefined) updates.content = args.content; if (args.content !== undefined) updates.content = args.content;
if (args.imageId !== undefined) updates.image = args.imageId; 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.tags !== undefined && args.tags !== null) updates.tags = args.tags;
if (args.category !== undefined) updates.category = args.category; if (args.category !== undefined) updates.category = args.category;
if (args.featured !== undefined && args.featured !== null) updates.featured = args.featured; 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 }), id: t.arg.string({ required: true }),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin"); requireAdmin(ctx);
await ctx.db.delete(articles).where(eq(articles.id, args.id)); await ctx.db.delete(articles).where(eq(articles.id, args.id));
return true; return true;
}, },

View File

@@ -32,7 +32,8 @@ builder.mutationField("login", (t) =>
const sessionUser = { const sessionUser = {
id: user[0].id, id: user[0].id,
email: user[0].email, 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, first_name: user[0].first_name,
last_name: user[0].last_name, last_name: user[0].last_name,
artist_name: user[0].artist_name, artist_name: user[0].artist_name,

View File

@@ -2,7 +2,7 @@ import { GraphQLError } from "graphql";
import { builder } from "../builder"; import { builder } from "../builder";
import { RecordingType } from "../types/index"; import { RecordingType } from "../types/index";
import { recordings, recording_plays } from "../../db/schema/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 { slugify } from "../../lib/slugify";
import { awardPoints, checkAchievements } from "../../lib/gamification"; import { awardPoints, checkAchievements } from "../../lib/gamification";
@@ -21,6 +21,7 @@ builder.queryField("recordings", (t) =>
const conditions = [eq(recordings.user_id, ctx.currentUser.id)]; const conditions = [eq(recordings.user_id, ctx.currentUser.id)];
if (args.status) conditions.push(eq(recordings.status, args.status as any)); 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)); if (args.linkedVideoId) conditions.push(eq(recordings.linked_video, args.linkedVideoId));
const limit = args.limit || 50; 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]) throw new GraphQLError("Recording not found");
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden"); if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
await ctx.db await ctx.db.delete(recordings).where(eq(recordings.id, args.id));
.update(recordings)
.set({ status: "archived", date_updated: new Date() })
.where(eq(recordings.id, args.id));
return true; return true;
}, },

View File

@@ -3,7 +3,7 @@ import { builder } from "../builder";
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index"; import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
import { users, user_photos, files } from "../../db/schema/index"; import { users, user_photos, files } from "../../db/schema/index";
import { eq, ilike, or, count, and } from "drizzle-orm"; import { eq, ilike, or, count, and } from "drizzle-orm";
import { requireRole } from "../../lib/acl"; import { requireAdmin } from "../../lib/acl";
builder.queryField("me", (t) => builder.queryField("me", (t) =>
t.field({ t.field({
@@ -86,7 +86,7 @@ builder.queryField("adminListUsers", (t) =>
offset: t.arg.int(), offset: t.arg.int(),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin"); requireAdmin(ctx);
const limit = args.limit ?? 50; const limit = args.limit ?? 50;
const offset = args.offset ?? 0; const offset = args.offset ?? 0;
@@ -126,6 +126,7 @@ builder.mutationField("adminUpdateUser", (t) =>
args: { args: {
userId: t.arg.string({ required: true }), userId: t.arg.string({ required: true }),
role: t.arg.string(), role: t.arg.string(),
isAdmin: t.arg.boolean(),
firstName: t.arg.string(), firstName: t.arg.string(),
lastName: t.arg.string(), lastName: t.arg.string(),
artistName: t.arg.string(), artistName: t.arg.string(),
@@ -133,10 +134,11 @@ builder.mutationField("adminUpdateUser", (t) =>
bannerId: t.arg.string(), bannerId: t.arg.string(),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin"); requireAdmin(ctx);
const updates: Record<string, unknown> = { date_updated: new Date() }; const updates: Record<string, unknown> = { date_updated: new Date() };
if (args.role !== undefined && args.role !== null) updates.role = args.role as any; 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) if (args.firstName !== undefined && args.firstName !== null)
updates.first_name = args.firstName; updates.first_name = args.firstName;
if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName; 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 }), userId: t.arg.string({ required: true }),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin"); requireAdmin(ctx);
if (args.userId === ctx.currentUser!.id) throw new GraphQLError("Cannot delete yourself"); if (args.userId === ctx.currentUser!.id) throw new GraphQLError("Cannot delete yourself");
await ctx.db.delete(users).where(eq(users.id, args.userId)); await ctx.db.delete(users).where(eq(users.id, args.userId));
return true; return true;
@@ -179,7 +181,7 @@ builder.queryField("adminGetUser", (t) =>
userId: t.arg.string({ required: true }), userId: t.arg.string({ required: true }),
}, },
resolve: async (_root, args, ctx) => { 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); const user = await ctx.db.select().from(users).where(eq(users.id, args.userId)).limit(1);
if (!user[0]) return null; if (!user[0]) return null;
const photoRows = await ctx.db const photoRows = await ctx.db
@@ -188,10 +190,11 @@ builder.queryField("adminGetUser", (t) =>
.leftJoin(files, eq(user_photos.file_id, files.id)) .leftJoin(files, eq(user_photos.file_id, files.id))
.where(eq(user_photos.user_id, args.userId)) .where(eq(user_photos.user_id, args.userId))
.orderBy(user_photos.sort); .orderBy(user_photos.sort);
return { const seen = new Set<string>();
...user[0], const photos = photoRows
photos: photoRows.map((p: any) => ({ id: p.id, filename: p.filename })), .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 }), fileId: t.arg.string({ required: true }),
}, },
resolve: async (_root, args, ctx) => { 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 }); await ctx.db.insert(user_photos).values({ user_id: args.userId, file_id: args.fileId });
return true; return true;
}, },
@@ -219,7 +222,7 @@ builder.mutationField("adminRemoveUserPhoto", (t) =>
fileId: t.arg.string({ required: true }), fileId: t.arg.string({ required: true }),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin"); requireAdmin(ctx);
await ctx.db await ctx.db
.delete(user_photos) .delete(user_photos)
.where(and(eq(user_photos.user_id, args.userId), eq(user_photos.file_id, args.fileId))); .where(and(eq(user_photos.user_id, args.userId), eq(user_photos.file_id, args.fileId)));

View File

@@ -15,7 +15,7 @@ import {
files, files,
} from "../../db/schema/index"; } from "../../db/schema/index";
import { eq, and, lte, desc, inArray, count } from "drizzle-orm"; 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) { async function enrichVideo(db: any, video: any) {
// Fetch models // Fetch models
@@ -432,7 +432,7 @@ builder.queryField("adminListVideos", (t) =>
t.field({ t.field({
type: [VideoType], type: [VideoType],
resolve: async (_root, _args, ctx) => { resolve: async (_root, _args, ctx) => {
requireRole(ctx, "admin"); requireAdmin(ctx);
const rows = await ctx.db.select().from(videos).orderBy(desc(videos.upload_date)); const rows = await ctx.db.select().from(videos).orderBy(desc(videos.upload_date));
return Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v))); return Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
}, },
@@ -454,7 +454,7 @@ builder.mutationField("createVideo", (t) =>
uploadDate: t.arg.string(), uploadDate: t.arg.string(),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin"); requireAdmin(ctx);
const inserted = await ctx.db const inserted = await ctx.db
.insert(videos) .insert(videos)
.values({ .values({
@@ -491,7 +491,7 @@ builder.mutationField("updateVideo", (t) =>
uploadDate: t.arg.string(), uploadDate: t.arg.string(),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin"); requireAdmin(ctx);
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
if (args.title !== undefined && args.title !== null) updates.title = args.title; if (args.title !== undefined && args.title !== null) updates.title = args.title;
if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug; 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 }), id: t.arg.string({ required: true }),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin"); requireAdmin(ctx);
await ctx.db.delete(videos).where(eq(videos.id, args.id)); await ctx.db.delete(videos).where(eq(videos.id, args.id));
return true; return true;
}, },
@@ -537,7 +537,7 @@ builder.mutationField("setVideoModels", (t) =>
userIds: t.arg.stringList({ required: true }), userIds: t.arg.stringList({ required: true }),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
requireRole(ctx, "admin"); requireAdmin(ctx);
await ctx.db.delete(video_models).where(eq(video_models.video_id, args.videoId)); await ctx.db.delete(video_models).where(eq(video_models.video_id, args.videoId));
if (args.userIds.length > 0) { if (args.userIds.length > 0) {
await ctx.db.insert(video_models).values( await ctx.db.insert(video_models).values(

View File

@@ -6,7 +6,6 @@ import type {
Video, Video,
ModelPhoto, ModelPhoto,
Model, Model,
ArticleAuthor,
Article, Article,
CommentUser, CommentUser,
Comment, Comment,
@@ -53,6 +52,7 @@ export const UserType = builder.objectRef<User>("User").implement({
description: t.exposeString("description", { nullable: true }), description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }), tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"), role: t.exposeString("role"),
is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }), banner: t.exposeString("banner", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"), email_verified: t.exposeBoolean("email_verified"),
@@ -72,6 +72,7 @@ export const CurrentUserType = builder.objectRef<User>("CurrentUser").implement(
description: t.exposeString("description", { nullable: true }), description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }), tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"), role: t.exposeString("role"),
is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }), banner: t.exposeString("banner", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"), 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({ export const ArticleType = builder.objectRef<Article>("Article").implement({
fields: (t) => ({ fields: (t) => ({
id: t.exposeString("id"), id: t.exposeString("id"),
@@ -158,7 +150,7 @@ export const ArticleType = builder.objectRef<Article>("Article").implement({
publish_date: t.expose("publish_date", { type: "DateTime" }), publish_date: t.expose("publish_date", { type: "DateTime" }),
category: t.exposeString("category", { nullable: true }), category: t.exposeString("category", { nullable: true }),
featured: t.exposeBoolean("featured", { 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 }), description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }), tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"), role: t.exposeString("role"),
is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }), avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }), banner: t.exposeString("banner", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"), email_verified: t.exposeBoolean("email_verified"),

View File

@@ -1,20 +1,18 @@
import { GraphQLError } from "graphql"; import { GraphQLError } from "graphql";
import type { Context } from "../graphql/builder"; import type { Context } from "../graphql/builder";
type UserRole = "viewer" | "model" | "admin";
export function requireAuth(ctx: Context): void { export function requireAuth(ctx: Context): void {
if (!ctx.currentUser) throw new GraphQLError("Unauthorized"); if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
} }
export function requireRole(ctx: Context, ...roles: UserRole[]): void { export function requireAdmin(ctx: Context): void {
requireAuth(ctx); 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 { export function requireOwnerOrAdmin(ctx: Context, ownerId: string): void {
requireAuth(ctx); requireAuth(ctx);
if (ctx.currentUser!.id !== ownerId && ctx.currentUser!.role !== "admin") { if (ctx.currentUser!.id !== ownerId && !ctx.currentUser!.is_admin) {
throw new GraphQLError("Forbidden"); throw new GraphQLError("Forbidden");
} }
} }

View File

@@ -3,7 +3,8 @@ import Redis from "ioredis";
export type SessionUser = { export type SessionUser = {
id: string; id: string;
email: string; email: string;
role: "model" | "viewer" | "admin"; role: "model" | "viewer";
is_admin: boolean;
first_name: string | null; first_name: string | null;
last_name: string | null; last_name: string | null;
artist_name: string | null; artist_name: string | null;

View 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';

View File

@@ -8,6 +8,13 @@
"when": 1772645674513, "when": 1772645674513,
"tag": "0000_pale_hellion", "tag": "0000_pale_hellion",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1772645674514,
"tag": "0001_is_admin",
"breakpoints": true
} }
] ]
} }

View File

@@ -15,7 +15,7 @@
"@iconify-json/ri": "^1.2.10", "@iconify-json/ri": "^1.2.10",
"@iconify/tailwind4": "^1.2.1", "@iconify/tailwind4": "^1.2.1",
"@internationalized/date": "^3.11.0", "@internationalized/date": "^3.11.0",
"@lucide/svelte": "^0.577.0", "@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.53.4", "@sveltejs/kit": "^2.53.4",

View File

@@ -13,7 +13,7 @@
const AGE_VERIFICATION_KEY = "age-verified"; const AGE_VERIFICATION_KEY = "age-verified";
let isOpen = true; let isOpen = $state(false);
function handleAgeConfirmation() { function handleAgeConfirmation() {
localStorage.setItem(AGE_VERIFICATION_KEY, "true"); localStorage.setItem(AGE_VERIFICATION_KEY, "true");
@@ -21,9 +21,8 @@
} }
onMount(() => { onMount(() => {
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY); if (localStorage.getItem(AGE_VERIFICATION_KEY) !== "true") {
if (storedVerification === "true") { isOpen = true;
isOpen = false;
} }
}); });
</script> </script>

View File

@@ -8,8 +8,6 @@
import { getAssetUrl } from "$lib/api"; import { getAssetUrl } from "$lib/api";
import LogoutButton from "../logout-button/logout-button.svelte"; import LogoutButton from "../logout-button/logout-button.svelte";
import Separator from "../ui/separator/separator.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 BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
import Logo from "../logo/logo.svelte"; import Logo from "../logo/logo.svelte";
@@ -109,7 +107,7 @@
<span class="sr-only">{$_("header.play")}</span> <span class="sr-only">{$_("header.play")}</span>
</Button> </Button>
{#if authStatus.user?.role === "admin"} {#if authStatus.user?.is_admin}
<Button <Button
variant="link" variant="link"
size="icon" size="icon"
@@ -172,7 +170,7 @@
<!-- Flyout panel --> <!-- Flyout panel -->
<div <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"}`} 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 --> <!-- Panel header -->
<div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30"> <div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
@@ -180,34 +178,17 @@
</div> </div>
<div class="flex-1 py-6 px-5 space-y-6"> <div class="flex-1 py-6 px-5 space-y-6">
<!-- User Profile Card --> <!-- User logout slider -->
{#if authStatus.authenticated} {#if authStatus.authenticated}
<div <LogoutButton
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4" user={{
> name: authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"></div> avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
<div class="relative flex items-center gap-3"> email: authStatus.user!.email,
<Avatar class="h-9 w-9 ring-2 ring-primary/30"> }}
<AvatarImage onLogout={handleLogout}
src={getAssetUrl(authStatus.user!.avatar, "mini")} class="w-full"
alt={authStatus.user!.artist_name}
/> />
<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} {/if}
<!-- Navigation --> <!-- Navigation -->
@@ -278,7 +259,7 @@
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span> <span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a> </a>
{#if authStatus.user?.role === "admin"} {#if authStatus.user?.is_admin}
<a <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"}`} 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" href="/admin/users"
@@ -340,21 +321,5 @@
</div> </div>
</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>
</div> </div>

View File

@@ -11,15 +11,17 @@
interface Props { interface Props {
user: User; user: User;
onLogout: () => void; onLogout: () => void;
class?: string;
} }
let { user, onLogout }: Props = $props(); let { user, onLogout, class: className = "" }: Props = $props();
let container: HTMLDivElement;
let isDragging = $state(false); let isDragging = $state(false);
let slidePosition = $state(0); let slidePosition = $state(0);
let startX = 0; let startX = 0;
let currentX = 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 let threshold = 0.75; // 70% threshold to trigger logout
// Calculate slide progress (0 to 1) // Calculate slide progress (0 to 1)
@@ -102,9 +104,10 @@
</script> </script>
<div <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 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' ? 'cursor-grabbing'
: ''}" : ''} {className}"
style="background: linear-gradient(90deg, style="background: linear-gradient(90deg,
oklch(var(--primary) / 0.3) 0%, oklch(var(--primary) / 0.3) 0%,
oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%, oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%,

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

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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,
};

View File

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

View File

@@ -0,0 +1 @@
export { default as DatePicker } from "./date-picker.svelte";

View 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,
};

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />

View File

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

View File

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

View File

@@ -168,6 +168,7 @@ export default {
no_account: "Don't have an account?", no_account: "Don't have an account?",
sign_up_link: "Sign up now", sign_up_link: "Sign up now",
error: "Heads Up!", error: "Heads Up!",
error_invalid_credentials: "Invalid email or password.",
}, },
signup: { signup: {
title: "Create Account", title: "Create Account",
@@ -194,6 +195,7 @@ export default {
have_account: "Already have an account?", have_account: "Already have an account?",
sign_in_link: "Sign in here", sign_in_link: "Sign in here",
error: "Heads Up!", error: "Heads Up!",
error_email_taken: "This email address is already registered.",
agree_error: "You must confirm our terms of service and your age.", agree_error: "You must confirm our terms of service and your age.",
password_error: "The password has to match the confirmation password.", password_error: "The password has to match the confirmation password.",
toast_register: "A verification email has been sent to {email}!", toast_register: "A verification email has been sent to {email}!",
@@ -221,6 +223,7 @@ export default {
resetting: "Resetting...", resetting: "Resetting...",
reset: "Reset", reset: "Reset",
error: "Heads Up!", error: "Heads Up!",
error_invalid_token: "This reset link is invalid or has expired.",
password_error: "The password has to match the confirmation password.", password_error: "The password has to match the confirmation password.",
toast_reset: "Your password has been reset!", toast_reset: "Your password has been reset!",
}, },
@@ -896,6 +899,142 @@ export default {
head: { head: {
title: "SexyArt | {title}", 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: { gamification: {
leaderboard: "Leaderboard", leaderboard: "Leaderboard",
leaderboard_description: "Compete with other creators and players for the top spot", leaderboard_description: "Compete with other creators and players for the top spot",

View File

@@ -62,6 +62,7 @@ const ME_QUERY = gql`
description description
tags tags
role role
is_admin
avatar avatar
banner banner
email_verified email_verified
@@ -228,10 +229,10 @@ const ARTICLES_QUERY = gql`
category category
featured featured
author { author {
first_name id
last_name artist_name
slug
avatar avatar
description
} }
} }
} }
@@ -258,10 +259,10 @@ const ARTICLE_BY_SLUG_QUERY = gql`
category category
featured featured
author { author {
first_name id
last_name artist_name
slug
avatar avatar
description
} }
} }
} }
@@ -1022,6 +1023,7 @@ const ADMIN_LIST_USERS_QUERY = gql`
artist_name artist_name
slug slug
role role
is_admin
avatar avatar
email_verified email_verified
date_created date_created
@@ -1052,6 +1054,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
mutation AdminUpdateUser( mutation AdminUpdateUser(
$userId: String! $userId: String!
$role: String $role: String
$isAdmin: Boolean
$firstName: String $firstName: String
$lastName: String $lastName: String
$artistName: String $artistName: String
@@ -1061,6 +1064,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
adminUpdateUser( adminUpdateUser(
userId: $userId userId: $userId
role: $role role: $role
isAdmin: $isAdmin
firstName: $firstName firstName: $firstName
lastName: $lastName lastName: $lastName
artistName: $artistName artistName: $artistName
@@ -1073,6 +1077,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
last_name last_name
artist_name artist_name
role role
is_admin
avatar avatar
banner banner
date_created date_created
@@ -1083,6 +1088,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
export async function adminUpdateUser(input: { export async function adminUpdateUser(input: {
userId: string; userId: string;
role?: string; role?: string;
isAdmin?: boolean;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
artistName?: string; artistName?: string;
@@ -1128,6 +1134,7 @@ const ADMIN_GET_USER_QUERY = gql`
artist_name artist_name
slug slug
role role
is_admin
avatar avatar
banner banner
description description
@@ -1380,8 +1387,9 @@ const ADMIN_LIST_ARTICLES_QUERY = gql`
featured featured
content content
author { author {
first_name id
last_name artist_name
slug
avatar avatar
} }
} }
@@ -1460,6 +1468,7 @@ const UPDATE_ARTICLE_MUTATION = gql`
$excerpt: String $excerpt: String
$content: String $content: String
$imageId: String $imageId: String
$authorId: String
$tags: [String!] $tags: [String!]
$category: String $category: String
$featured: Boolean $featured: Boolean
@@ -1472,6 +1481,7 @@ const UPDATE_ARTICLE_MUTATION = gql`
excerpt: $excerpt excerpt: $excerpt
content: $content content: $content
imageId: $imageId imageId: $imageId
authorId: $authorId
tags: $tags tags: $tags
category: $category category: $category
featured: $featured featured: $featured
@@ -1491,6 +1501,7 @@ export async function updateArticle(input: {
excerpt?: string; excerpt?: string;
content?: string; content?: string;
imageId?: string; imageId?: string;
authorId?: string | null;
tags?: string[]; tags?: string[];
category?: string; category?: string;
featured?: boolean; featured?: boolean;

View File

@@ -2,7 +2,6 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { page } from "$app/state"; import { page } from "$app/state";
import { Button } from "$lib/components/ui/button"; 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 PeonyIcon from "$lib/components/icon/peony-icon.svelte";
import PeonyBackground from "$lib/components/background/peony-background.svelte"; import PeonyBackground from "$lib/components/background/peony-background.svelte";
import Meta from "$lib/components/meta/meta.svelte"; import Meta from "$lib/components/meta/meta.svelte";
@@ -21,11 +20,7 @@
<!-- Content --> <!-- Content -->
<div class="relative z-10 container mx-auto px-4 text-center"> <div class="relative z-10 container mx-auto px-4 text-center">
<div class="max-w-2xl mx-auto"> <div class="max-w-2xl mx-auto">
<!-- Premium Glassmorphism Card --> <div class="py-12">
<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">
<!-- 404 Animation --> <!-- 404 Animation -->
<div class="mb-8"> <div class="mb-8">
<div <div
@@ -92,8 +87,7 @@
> >
</div> </div>
</div> </div>
</CardContent> </div>
</Card>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
export async function load({ locals }) { 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, "/"); throw redirect(302, "/");
} }
return { authStatus: locals.authStatus }; return { authStatus: locals.authStatus };

View File

@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/state"; import { page } from "$app/state";
import { _ } from "svelte-i18n";
const { children } = $props(); const { children } = $props();
const navLinks = [ const navLinks = $derived([
{ name: "Users", href: "/admin/users", icon: "icon-[ri--team-line]" }, { name: $_("admin.nav.users"), href: "/admin/users", icon: "icon-[ri--team-line]" },
{ name: "Videos", href: "/admin/videos", icon: "icon-[ri--film-line]" }, { name: $_("admin.nav.videos"), href: "/admin/videos", icon: "icon-[ri--film-line]" },
{ name: "Articles", href: "/admin/articles", icon: "icon-[ri--article-line]" }, { name: $_("admin.nav.articles"), href: "/admin/articles", icon: "icon-[ri--article-line]" },
]; ]);
function isActive(href: string) { function isActive(href: string) {
return page.url.pathname.startsWith(href); return page.url.pathname.startsWith(href);
@@ -20,7 +21,7 @@
<!-- Mobile top nav --> <!-- Mobile top nav -->
<div class="lg:hidden flex items-center gap-2 py-3 border-b border-border/40"> <div class="lg:hidden 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"> <a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0 mr-2">
← Back {$_("admin.nav.back_mobile")}
</a> </a>
{#each navLinks as link (link.href)} {#each navLinks as link (link.href)}
<a <a
@@ -43,9 +44,9 @@
<aside class="hidden lg:flex w-56 shrink-0 flex-col border-r border-border/40"> <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"> <div class="px-4 py-5 border-b border-border/40">
<a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors"> <a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors">
← Back to site {$_("admin.nav.back_to_site")}
</a> </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> </div>
<nav class="flex-1 p-3 space-y-1"> <nav class="flex-1 p-3 space-y-1">

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from "$app/navigation"; import { invalidateAll } from "$app/navigation";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { deleteArticle } from "$lib/services"; import { deleteArticle } from "$lib/services";
import { getAssetUrl } from "$lib/api"; import { getAssetUrl } from "$lib/api";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -26,34 +27,34 @@
deleting = true; deleting = true;
try { try {
await deleteArticle(deleteTarget.id); await deleteArticle(deleteTarget.id);
toast.success("Article deleted"); toast.success($_("admin.articles.delete_success"));
deleteOpen = false; deleteOpen = false;
deleteTarget = null; deleteTarget = null;
await invalidateAll(); await invalidateAll();
} catch { } catch {
toast.error("Failed to delete article"); toast.error($_("admin.articles.delete_error"));
} finally { } finally {
deleting = false; deleting = false;
} }
} }
</script> </script>
<div class="p-3 sm:p-6"> <div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<h1 class="text-2xl font-bold">Articles</h1> <h1 class="text-2xl font-bold">{$_("admin.articles.title")}</h1>
<Button href="/admin/articles/new"> <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> </Button>
</div> </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"> <table class="w-full text-sm">
<thead class="bg-muted/30"> <thead class="bg-muted/30">
<tr> <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">{$_("admin.articles.col_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">{$_("admin.articles.col_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-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">Actions</th> <th class="px-4 py-3 text-right font-medium text-muted-foreground">{$_("admin.users.col_actions")}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-border/30"> <tbody class="divide-y divide-border/30">
@@ -79,7 +80,7 @@
{#if article.featured} {#if article.featured}
<span <span
class="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium" class="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium"
>Featured</span >{$_("admin.common.featured")}</span
> >
{/if} {/if}
</div> </div>
@@ -110,7 +111,7 @@
{#if data.articles.length === 0} {#if data.articles.length === 0}
<tr> <tr>
<td colspan="4" class="px-4 py-8 text-center text-muted-foreground"> <td colspan="4" class="px-4 py-8 text-center text-muted-foreground">
No articles yet {$_("admin.articles.no_results")}
</td> </td>
</tr> </tr>
{/if} {/if}
@@ -122,15 +123,15 @@
<Dialog.Root bind:open={deleteOpen}> <Dialog.Root bind:open={deleteOpen}>
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Delete article</Dialog.Title> <Dialog.Title>{$_("admin.articles.delete_title")}</Dialog.Title>
<Dialog.Description> <Dialog.Description>
Permanently delete <strong>{deleteTarget?.title}</strong>? This cannot be undone. {$_("admin.articles.delete_description", { values: { title: deleteTarget?.title } })}
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<Dialog.Footer> <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}> <Button variant="destructive" disabled={deleting} onclick={handleDelete}>
{deleting ? "Deleting" : "Delete"} {deleting ? $_("admin.common.deleting") : $_("common.delete")}
</Button> </Button>
</Dialog.Footer> </Dialog.Footer>
</Dialog.Content> </Dialog.Content>

View File

@@ -1,10 +1,13 @@
import { adminListArticles } from "$lib/services"; import { adminListArticles, adminListUsers } from "$lib/services";
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
export async function load({ params, fetch, cookies }) { export async function load({ params, fetch, cookies }) {
const token = cookies.get("session_token") || ""; 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); const article = articles.find((a) => a.id === params.id);
if (!article) throw error(404, "Article not found"); if (!article) throw error(404, "Article not found");
return { article }; return { article, authors: modelsResult.items };
} }

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { updateArticle, uploadFile } from "$lib/services"; import { updateArticle, uploadFile } from "$lib/services";
import { marked } from "marked"; import { marked } from "marked";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -10,6 +11,8 @@
import { TagsInput } from "$lib/components/ui/tags-input"; import { TagsInput } from "$lib/components/ui/tags-input";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone"; import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { getAssetUrl } from "$lib/api"; 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(); const { data } = $props();
@@ -24,6 +27,8 @@
data.article.publish_date ? new Date(data.article.publish_date).toISOString().slice(0, 16) : "", data.article.publish_date ? new Date(data.article.publish_date).toISOString().slice(0, 16) : "",
); );
let imageId = $state<string | null>(data.article.image ?? null); let imageId = $state<string | null>(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 saving = $state(false);
let editorTab = $state<"write" | "preview">("write"); let editorTab = $state<"write" | "preview">("write");
@@ -37,9 +42,9 @@
try { try {
const res = await uploadFile(fd); const res = await uploadFile(fd);
imageId = res.id; imageId = res.id;
toast.success("Image uploaded"); toast.success($_("admin.common.image_uploaded"));
} catch { } catch {
toast.error("Image upload failed"); toast.error($_("admin.common.image_upload_failed"));
} }
} }
@@ -53,15 +58,16 @@
excerpt: excerpt || undefined, excerpt: excerpt || undefined,
content: content || undefined, content: content || undefined,
imageId: imageId || undefined, imageId: imageId || undefined,
authorId: authorId || null,
tags, tags,
category: category || undefined, category: category || undefined,
featured, featured,
publishDate: publishDate || undefined, publishDate: publishDate || undefined,
}); });
toast.success("Article updated"); toast.success($_("admin.article_form.update_success"));
goto("/admin/articles"); goto("/admin/articles");
} catch (e: any) { } catch (e: any) {
toast.error(e?.message ?? "Failed to update article"); toast.error(e?.message ?? $_("admin.article_form.update_error"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -71,43 +77,43 @@
<div class="p-3 sm:p-6"> <div class="p-3 sm:p-6">
<div class="flex items-center gap-4 mb-6"> <div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/articles" size="sm"> <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> </Button>
<h1 class="text-2xl font-bold">Edit article</h1> <h1 class="text-2xl font-bold">{$_("admin.article_form.edit_title")}</h1>
</div> </div>
<div class="space-y-5 max-w-4xl"> <div class="space-y-5 max-w-4xl">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5"> <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} /> <Input id="title" bind:value={title} />
</div> </div>
<div class="space-y-1.5"> <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} /> <Input id="slug" bind:value={slug} />
</div> </div>
</div> </div>
<div class="space-y-1.5"> <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} /> <Textarea id="excerpt" bind:value={excerpt} rows={2} />
</div> </div>
<!-- Markdown editor with live preview --> <!-- Markdown editor with live preview -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<div class="flex items-center justify-between"> <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"> <div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
<button <button
type="button" type="button"
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`} class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "write")} onclick={() => (editorTab = "write")}
>Write</button> >{$_("admin.common.write")}</button>
<button <button
type="button" type="button"
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`} class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "preview")} onclick={() => (editorTab = "preview")}
>Preview</button> >{$_("admin.common.preview")}</button>
</div> </div>
</div> </div>
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96"> <div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
@@ -121,14 +127,14 @@
{#if preview} {#if preview}
{@html preview} {@html preview}
{:else} {: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} {/if}
</div> </div>
</div> </div>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Cover image</Label> <Label>{$_("admin.common.cover_image")}</Label>
{#if imageId} {#if imageId}
<img <img
src={getAssetUrl(imageId, "thumbnail")} src={getAssetUrl(imageId, "thumbnail")}
@@ -139,32 +145,60 @@
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} /> <FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
</div> </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="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5"> <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} /> <Input id="category" bind:value={category} />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="publishDate">Publish date</Label> <Label>{$_("admin.common.publish_date")}</Label>
<Input id="publishDate" type="datetime-local" bind:value={publishDate} /> <DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
</div> </div>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Tags</Label> <Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} /> <TagsInput bind:value={tags} />
</div> </div>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" /> <input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">Featured</span> <span class="text-sm">{$_("admin.common.featured")}</span>
</label> </label>
<div class="flex gap-3 pt-2"> <div class="flex gap-3 pt-2">
<Button onclick={handleSubmit} disabled={saving}> <Button onclick={handleSubmit} disabled={saving}>
{saving ? "Saving" : "Save changes"} {saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button> </Button>
<Button variant="outline" href="/admin/articles">Cancel</Button> <Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { createArticle, uploadFile } from "$lib/services"; import { createArticle, uploadFile } from "$lib/services";
import { marked } from "marked"; import { marked } from "marked";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -8,6 +9,7 @@
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { Textarea } from "$lib/components/ui/textarea"; import { Textarea } from "$lib/components/ui/textarea";
import { TagsInput } from "$lib/components/ui/tags-input"; 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"; import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
let title = $state(""); let title = $state("");
@@ -39,15 +41,15 @@
try { try {
const res = await uploadFile(fd); const res = await uploadFile(fd);
imageId = res.id; imageId = res.id;
toast.success("Image uploaded"); toast.success($_("admin.common.image_uploaded"));
} catch { } catch {
toast.error("Image upload failed"); toast.error($_("admin.common.image_upload_failed"));
} }
} }
async function handleSubmit() { async function handleSubmit() {
if (!title || !slug) { if (!title || !slug) {
toast.error("Title and slug are required"); toast.error($_("admin.common.title_slug_required"));
return; return;
} }
saving = true; saving = true;
@@ -63,10 +65,10 @@
featured, featured,
publishDate: publishDate || undefined, publishDate: publishDate || undefined,
}); });
toast.success("Article created"); toast.success($_("admin.article_form.create_success"));
goto("/admin/articles"); goto("/admin/articles");
} catch (e: any) { } catch (e: any) {
toast.error(e?.message ?? "Failed to create article"); toast.error(e?.message ?? $_("admin.article_form.create_error"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -76,57 +78,57 @@
<div class="p-3 sm:p-6"> <div class="p-3 sm:p-6">
<div class="flex items-center gap-4 mb-6"> <div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/articles" size="sm"> <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> </Button>
<h1 class="text-2xl font-bold">New article</h1> <h1 class="text-2xl font-bold">{$_("admin.article_form.new_title")}</h1>
</div> </div>
<div class="space-y-5 max-w-4xl"> <div class="space-y-5 max-w-4xl">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="title">Title *</Label> <Label for="title">{$_("admin.common.title_field")}</Label>
<Input <Input
id="title" id="title"
bind:value={title} bind:value={title}
oninput={() => { oninput={() => {
if (!slug) slug = generateSlug(title); if (!slug) slug = generateSlug(title);
}} }}
placeholder="Article title" placeholder={$_("admin.article_form.title_placeholder")}
/> />
</div> </div>
<div class="space-y-1.5"> <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} placeholder="article-slug" /> <Input id="slug" bind:value={slug} placeholder={$_("admin.article_form.slug_placeholder")} />
</div> </div>
</div> </div>
<div class="space-y-1.5"> <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} placeholder="Short summary…" rows={2} /> <Textarea id="excerpt" bind:value={excerpt} placeholder={$_("admin.article_form.excerpt_placeholder")} rows={2} />
</div> </div>
<!-- Markdown editor with live preview --> <!-- Markdown editor with live preview -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<div class="flex items-center justify-between"> <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"> <div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
<button <button
type="button" type="button"
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`} class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "write")} onclick={() => (editorTab = "write")}
>Write</button> >{$_("admin.common.write")}</button>
<button <button
type="button" type="button"
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`} class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
onclick={() => (editorTab = "preview")} onclick={() => (editorTab = "preview")}
>Preview</button> >{$_("admin.common.preview")}</button>
</div> </div>
</div> </div>
<!-- Mobile: single pane toggled; Desktop: side by side --> <!-- Mobile: single pane toggled; Desktop: side by side -->
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96"> <div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
<Textarea <Textarea
bind:value={content} 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" : ""}`} class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
/> />
<div <div
@@ -135,44 +137,44 @@
{#if preview} {#if preview}
{@html preview} {@html preview}
{:else} {: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} {/if}
</div> </div>
</div> </div>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Cover image</Label> <Label>{$_("admin.common.cover_image")}</Label>
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} /> <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>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5"> <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} placeholder="e.g. news, tutorial…" /> <Input id="category" bind:value={category} placeholder={$_("admin.article_form.category_placeholder")} />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="publishDate">Publish date</Label> <Label>{$_("admin.common.publish_date")}</Label>
<Input id="publishDate" type="datetime-local" bind:value={publishDate} /> <DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
</div> </div>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Tags</Label> <Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} /> <TagsInput bind:value={tags} />
</div> </div>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" /> <input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">Featured</span> <span class="text-sm">{$_("admin.common.featured")}</span>
</label> </label>
<div class="flex gap-3 pt-2"> <div class="flex gap-3 pt-2">
<Button onclick={handleSubmit} disabled={saving}> <Button onclick={handleSubmit} disabled={saving}>
{saving ? "Creating" : "Create article"} {saving ? $_("admin.common.creating") : $_("admin.article_form.create")}
</Button> </Button>
<Button variant="outline" href="/admin/articles">Cancel</Button> <Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -3,11 +3,13 @@
import { page } from "$app/state"; import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity"; import { SvelteURLSearchParams } from "svelte/reactivity";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { adminUpdateUser, adminDeleteUser } from "$lib/services"; import { adminUpdateUser, adminDeleteUser } from "$lib/services";
import { getAssetUrl } from "$lib/api"; import { getAssetUrl } from "$lib/api";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select"; 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 * as Dialog from "$lib/components/ui/dialog";
import type { User } from "$lib/types"; import type { User } from "$lib/types";
@@ -22,7 +24,7 @@
const currentUserId = page.data.authStatus?.user?.id; const currentUserId = page.data.authStatus?.user?.id;
const roles = ["", "viewer", "model", "admin"] as const; const roles = ["", "viewer", "model"] as const;
function debounceSearch(value: string) { function debounceSearch(value: string) {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
@@ -47,10 +49,10 @@
updatingId = user.id; updatingId = user.id;
try { try {
await adminUpdateUser({ userId: user.id, role: newRole }); await adminUpdateUser({ userId: user.id, role: newRole });
toast.success(`Role updated to ${newRole}`); toast.success($_("admin.users.role_updated", { values: { role: newRole } }));
await invalidateAll(); await invalidateAll();
} catch { } catch {
toast.error("Failed to update role"); toast.error($_("admin.users.role_update_failed"));
} finally { } finally {
updatingId = null; updatingId = null;
} }
@@ -66,12 +68,12 @@
deleting = true; deleting = true;
try { try {
await adminDeleteUser(deleteTarget.id); await adminDeleteUser(deleteTarget.id);
toast.success("User deleted"); toast.success($_("admin.users.delete_success"));
deleteOpen = false; deleteOpen = false;
deleteTarget = null; deleteTarget = null;
await invalidateAll(); await invalidateAll();
} catch { } catch {
toast.error("Failed to delete user"); toast.error($_("admin.users.delete_error"));
} finally { } finally {
deleting = false; deleting = false;
} }
@@ -82,16 +84,16 @@
} }
</script> </script>
<div class="p-3 sm:p-6"> <div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<h1 class="text-2xl font-bold">Users</h1> <h1 class="text-2xl font-bold">{$_("admin.users.title")}</h1>
<span class="text-sm text-muted-foreground">{data.total} total</span> <span class="text-sm text-muted-foreground">{$_("admin.users.total", { values: { total: data.total } })}</span>
</div> </div>
<!-- Filters --> <!-- 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 <Input
placeholder="Search email or name…" placeholder={$_("admin.users.search_placeholder")}
class="max-w-xs" class="max-w-xs"
value={searchValue} value={searchValue}
oninput={(e) => { oninput={(e) => {
@@ -107,22 +109,22 @@
variant={data.role === role || (!data.role && role === "") ? "default" : "outline"} variant={data.role === role || (!data.role && role === "") ? "default" : "outline"}
onclick={() => setRole(role)} onclick={() => setRole(role)}
> >
{role || "All"} {role ? $_(`admin.users.role_${role}`) : $_("admin.users.filter_all")}
</Button> </Button>
{/each} {/each}
</div> </div>
</div> </div>
<!-- Table --> <!-- 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"> <table class="w-full text-sm">
<thead class="bg-muted/30"> <thead class="bg-muted/30">
<tr> <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">{$_("admin.users.col_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 hidden sm:table-cell">{$_("admin.users.col_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">{$_("admin.users.col_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-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">Actions</th> <th class="px-4 py-3 text-right font-medium text-muted-foreground">{$_("admin.users.col_actions")}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-border/30"> <tbody class="divide-y divide-border/30">
@@ -144,7 +146,12 @@
</div> </div>
{/if} {/if}
<div class="min-w-0"> <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> <span class="text-xs text-muted-foreground sm:hidden truncate block">{user.email}</span>
</div> </div>
</div> </div>
@@ -161,9 +168,8 @@
{user.role} {user.role}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="viewer">Viewer</SelectItem> <SelectItem value="viewer">{$_("admin.users.role_viewer")}</SelectItem>
<SelectItem value="model">Model</SelectItem> <SelectItem value="model">{$_("admin.users.role_model")}</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</td> </td>
@@ -189,7 +195,7 @@
{#if data.items.length === 0} {#if data.items.length === 0}
<tr> <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> </tr>
{/if} {/if}
</tbody> </tbody>
@@ -198,9 +204,9 @@
<!-- Pagination --> <!-- Pagination -->
{#if data.total > data.limit} {#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"> <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> </span>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button
@@ -213,7 +219,7 @@
goto(`?${params.toString()}`); goto(`?${params.toString()}`);
}} }}
> >
Previous {$_("common.previous")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -225,7 +231,7 @@
goto(`?${params.toString()}`); goto(`?${params.toString()}`);
}} }}
> >
Next {$_("common.next")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -236,17 +242,15 @@
<Dialog.Root bind:open={deleteOpen}> <Dialog.Root bind:open={deleteOpen}>
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Delete user</Dialog.Title> <Dialog.Title>{$_("admin.users.delete_title")}</Dialog.Title>
<Dialog.Description> <Dialog.Description>
Are you sure you want to permanently delete <strong {$_("admin.users.delete_description", { values: { name: deleteTarget?.artist_name || deleteTarget?.email } })}
>{deleteTarget?.artist_name || deleteTarget?.email}</strong
>? This cannot be undone.
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<Dialog.Footer> <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}> <Button variant="destructive" disabled={deleting} onclick={handleDelete}>
{deleting ? "Deleting" : "Delete"} {deleting ? $_("admin.common.deleting") : $_("common.delete")}
</Button> </Button>
</Dialog.Footer> </Dialog.Footer>
</Dialog.Content> </Dialog.Content>

View File

@@ -8,6 +8,7 @@
uploadFile, uploadFile,
} from "$lib/services"; } from "$lib/services";
import { getAssetUrl } from "$lib/api"; import { getAssetUrl } from "$lib/api";
import { _ } from "svelte-i18n";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
@@ -20,6 +21,7 @@
let artistName = $state(data.user.artist_name ?? ""); let artistName = $state(data.user.artist_name ?? "");
let avatarId = $state<string | null>(data.user.avatar ?? null); let avatarId = $state<string | null>(data.user.avatar ?? null);
let bannerId = $state<string | null>(data.user.banner ?? null); let bannerId = $state<string | null>(data.user.banner ?? null);
let isAdmin = $state(data.user.is_admin ?? false);
let saving = $state(false); let saving = $state(false);
async function handleAvatarUpload(files: File[]) { async function handleAvatarUpload(files: File[]) {
@@ -30,9 +32,9 @@
try { try {
const res = await uploadFile(fd); const res = await uploadFile(fd);
avatarId = res.id; avatarId = res.id;
toast.success("Avatar uploaded"); toast.success($_("admin.user_edit.avatar_uploaded"));
} catch { } catch {
toast.error("Avatar upload failed"); toast.error($_("admin.user_edit.avatar_failed"));
} }
} }
@@ -44,9 +46,9 @@
try { try {
const res = await uploadFile(fd); const res = await uploadFile(fd);
bannerId = res.id; bannerId = res.id;
toast.success("Banner uploaded"); toast.success($_("admin.user_edit.banner_uploaded"));
} catch { } catch {
toast.error("Banner upload failed"); toast.error($_("admin.user_edit.banner_failed"));
} }
} }
@@ -58,21 +60,21 @@
const res = await uploadFile(fd); const res = await uploadFile(fd);
await adminAddUserPhoto(data.user.id, res.id); await adminAddUserPhoto(data.user.id, res.id);
} catch { } catch {
toast.error(`Failed to upload ${file.name}`); toast.error($_("admin.user_edit.photo_upload_failed", { values: { name: file.name } }));
return; return;
} }
} }
toast.success(`${files.length} photo${files.length > 1 ? "s" : ""} added`); toast.success($_("admin.user_edit.photos_added", { values: { count: files.length } }));
await invalidateAll(); await invalidateAll();
} }
async function removePhoto(fileId: string) { async function removePhoto(fileId: string) {
try { try {
await adminRemoveUserPhoto(data.user.id, fileId); await adminRemoveUserPhoto(data.user.id, fileId);
toast.success("Photo removed"); toast.success($_("admin.user_edit.save_success"));
await invalidateAll(); await invalidateAll();
} catch { } catch {
toast.error("Failed to remove photo"); toast.error($_("admin.user_edit.photo_remove_failed"));
} }
} }
@@ -86,10 +88,11 @@
artistName: artistName || undefined, artistName: artistName || undefined,
avatarId: avatarId || undefined, avatarId: avatarId || undefined,
bannerId: bannerId || undefined, bannerId: bannerId || undefined,
isAdmin,
}); });
toast.success("Saved"); toast.success($_("admin.user_edit.save_success"));
} catch (e: any) { } catch (e: any) {
toast.error(e?.message ?? "Save failed"); toast.error(e?.message ?? $_("admin.user_edit.save_error"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -99,11 +102,11 @@
<div class="p-3 sm:p-6 max-w-2xl"> <div class="p-3 sm:p-6 max-w-2xl">
<div class="flex items-center gap-4 mb-6"> <div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/users" size="sm"> <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> </Button>
<div> <div>
<h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1> <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>
</div> </div>
@@ -111,23 +114,23 @@
<!-- Basic info --> <!-- Basic info -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5"> <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} /> <Input id="firstName" bind:value={firstName} />
</div> </div>
<div class="space-y-1.5"> <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} /> <Input id="lastName" bind:value={lastName} />
</div> </div>
</div> </div>
<div class="space-y-1.5"> <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} /> <Input id="artistName" bind:value={artistName} />
</div> </div>
<!-- Avatar --> <!-- Avatar -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Avatar</Label> <Label>{$_("admin.user_edit.avatar")}</Label>
{#if avatarId} {#if avatarId}
<img <img
src={getAssetUrl(avatarId, "thumbnail")} src={getAssetUrl(avatarId, "thumbnail")}
@@ -140,7 +143,7 @@
<!-- Banner --> <!-- Banner -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Banner</Label> <Label>{$_("admin.user_edit.banner")}</Label>
{#if bannerId} {#if bannerId}
<img <img
src={getAssetUrl(bannerId, "preview")} src={getAssetUrl(bannerId, "preview")}
@@ -151,15 +154,24 @@
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} /> <FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} />
</div> </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"> <div class="flex gap-3">
<Button onclick={handleSave} disabled={saving}> <Button onclick={handleSave} disabled={saving}>
{saving ? "Saving" : "Save changes"} {saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button> </Button>
</div> </div>
<!-- Photo gallery --> <!-- Photo gallery -->
<div class="space-y-3 pt-4 border-t border-border/40"> <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} {#if data.user.photos && data.user.photos.length > 0}
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">
@@ -181,7 +193,7 @@
{/each} {/each}
</div> </div>
{:else} {: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} {/if}
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} /> <FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />

View File

@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from "$app/navigation"; import { invalidateAll } from "$app/navigation";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { deleteVideo } from "$lib/services"; import { deleteVideo } from "$lib/services";
import { getAssetUrl } from "$lib/api"; import { getAssetUrl } from "$lib/api";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import * as Dialog from "$lib/components/ui/dialog"; import * as Dialog from "$lib/components/ui/dialog";
import type { Video } from "$lib/types"; import type { Video } from "$lib/types";
@@ -23,35 +25,35 @@
deleting = true; deleting = true;
try { try {
await deleteVideo(deleteTarget.id); await deleteVideo(deleteTarget.id);
toast.success("Video deleted"); toast.success($_("admin.videos.delete_success"));
deleteOpen = false; deleteOpen = false;
deleteTarget = null; deleteTarget = null;
await invalidateAll(); await invalidateAll();
} catch { } catch {
toast.error("Failed to delete video"); toast.error($_("admin.videos.delete_error"));
} finally { } finally {
deleting = false; deleting = false;
} }
} }
</script> </script>
<div class="p-3 sm:p-6"> <div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<h1 class="text-2xl font-bold">Videos</h1> <h1 class="text-2xl font-bold">{$_("admin.videos.title")}</h1>
<Button href="/admin/videos/new"> <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> </Button>
</div> </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"> <table class="w-full text-sm">
<thead class="bg-muted/30"> <thead class="bg-muted/30">
<tr> <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">{$_("admin.videos.col_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 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">Plays</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">Likes</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">Actions</th> <th class="px-4 py-3 text-right font-medium text-muted-foreground">{$_("admin.users.col_actions")}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-border/30"> <tbody class="divide-y divide-border/30">
@@ -81,15 +83,10 @@
<td class="px-4 py-3 hidden sm:table-cell"> <td class="px-4 py-3 hidden sm:table-cell">
<div class="flex gap-1"> <div class="flex gap-1">
{#if video.premium} {#if video.premium}
<span <Badge variant="outline" class="text-yellow-600 border-yellow-500/40 bg-yellow-500/10">{$_("admin.common.premium")}</Badge>
class="px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-500/10 text-yellow-600"
>Premium</span
>
{/if} {/if}
{#if video.featured} {#if video.featured}
<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary" <Badge variant="default">{$_("admin.common.featured")}</Badge>
>Featured</span
>
{/if} {/if}
</div> </div>
</td> </td>
@@ -115,7 +112,7 @@
{#if data.videos.length === 0} {#if data.videos.length === 0}
<tr> <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> </tr>
{/if} {/if}
</tbody> </tbody>
@@ -126,15 +123,15 @@
<Dialog.Root bind:open={deleteOpen}> <Dialog.Root bind:open={deleteOpen}>
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Delete video</Dialog.Title> <Dialog.Title>{$_("admin.videos.delete_title")}</Dialog.Title>
<Dialog.Description> <Dialog.Description>
Permanently delete <strong>{deleteTarget?.title}</strong>? This cannot be undone. {$_("admin.videos.delete_description", { values: { title: deleteTarget?.title } })}
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<Dialog.Footer> <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}> <Button variant="destructive" disabled={deleting} onclick={handleDelete}>
{deleting ? "Deleting" : "Delete"} {deleting ? $_("admin.common.deleting") : $_("common.delete")}
</Button> </Button>
</Dialog.Footer> </Dialog.Footer>
</Dialog.Content> </Dialog.Content>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { updateVideo, setVideoModels, uploadFile } from "$lib/services"; import { updateVideo, setVideoModels, uploadFile } from "$lib/services";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
@@ -9,6 +10,8 @@
import { TagsInput } from "$lib/components/ui/tags-input"; import { TagsInput } from "$lib/components/ui/tags-input";
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone"; import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
import { getAssetUrl } from "$lib/api"; 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(); const { data } = $props();
@@ -36,9 +39,9 @@
try { try {
const res = await uploadFile(fd); const res = await uploadFile(fd);
imageId = res.id; imageId = res.id;
toast.success("Cover image uploaded"); toast.success($_("admin.video_form.cover_uploaded"));
} catch { } catch {
toast.error("Image upload failed"); toast.error($_("admin.common.image_upload_failed"));
} }
} }
@@ -50,18 +53,12 @@
try { try {
const res = await uploadFile(fd); const res = await uploadFile(fd);
movieId = res.id; movieId = res.id;
toast.success("Video uploaded"); toast.success($_("admin.video_form.video_uploaded"));
} catch { } 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() { async function handleSubmit() {
saving = true; saving = true;
try { try {
@@ -78,10 +75,10 @@
uploadDate: uploadDate || undefined, uploadDate: uploadDate || undefined,
}); });
await setVideoModels(data.video.id, selectedModelIds); await setVideoModels(data.video.id, selectedModelIds);
toast.success("Video updated"); toast.success($_("admin.video_form.update_success"));
goto("/admin/videos"); goto("/admin/videos");
} catch (e: any) { } catch (e: any) {
toast.error(e?.message ?? "Failed to update video"); toast.error(e?.message ?? $_("admin.video_form.update_error"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -91,30 +88,30 @@
<div class="p-3 sm:p-6 max-w-2xl"> <div class="p-3 sm:p-6 max-w-2xl">
<div class="flex items-center gap-4 mb-6"> <div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/videos" size="sm"> <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> </Button>
<h1 class="text-2xl font-bold">Edit video</h1> <h1 class="text-2xl font-bold">{$_("admin.video_form.edit_title")}</h1>
</div> </div>
<div class="space-y-5"> <div class="space-y-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5"> <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} placeholder="Video title" /> <Input id="title" bind:value={title} placeholder={$_("admin.video_form.title_placeholder")} />
</div> </div>
<div class="space-y-1.5"> <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} placeholder="video-slug" /> <Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
</div> </div>
</div> </div>
<div class="space-y-1.5"> <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} rows={3} /> <Textarea id="description" bind:value={description} placeholder={$_("admin.video_form.description_placeholder")} rows={3} />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Cover image</Label> <Label>{$_("admin.common.cover_image")}</Label>
{#if imageId} {#if imageId}
<img <img
src={getAssetUrl(imageId, "thumbnail")} src={getAssetUrl(imageId, "thumbnail")}
@@ -126,60 +123,69 @@
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Video file</Label> <Label>{$_("admin.video_form.video_file")}</Label>
{#if movieId} {#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} {/if}
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} /> <FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Tags</Label> <Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} /> <TagsInput bind:value={tags} />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="uploadDate">Publish date</Label> <Label>{$_("admin.common.publish_date")}</Label>
<Input id="uploadDate" type="datetime-local" bind:value={uploadDate} /> <DatePicker bind:value={uploadDate} placeholder={$_("admin.common.publish_date")} showTime={false} />
</div> </div>
<div class="flex gap-6"> <div class="flex gap-6">
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={premium} class="rounded" /> <input type="checkbox" bind:checked={premium} class="rounded" />
<span class="text-sm">Premium</span> <span class="text-sm">{$_("admin.common.premium")}</span>
</label> </label>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" /> <input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">Featured</span> <span class="text-sm">{$_("admin.common.featured")}</span>
</label> </label>
</div> </div>
{#if data.models.length > 0} {#if data.models.length > 0}
<div class="space-y-2"> <div class="space-y-1.5">
<Label>Models</Label> <Label>{$_("admin.video_form.models")}</Label>
<div class="flex flex-wrap gap-2"> <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)} {#each data.models as model (model.id)}
<button <SelectItem value={model.id}>
type="button" {#if model.avatar}
class={`px-3 py-1.5 rounded-full text-sm border transition-colors ${ <img src={getAssetUrl(model.avatar, "mini")} alt="" class="h-5 w-5 rounded-full object-cover shrink-0" />
selectedModelIds.includes(model.id) {/if}
? "border-primary bg-primary/10 text-primary" {model.artist_name}
: "border-border/40 text-muted-foreground hover:border-primary/40" </SelectItem>
}`}
onclick={() => toggleModel(model.id)}
>
{model.artist_name || model.id}
</button>
{/each} {/each}
</div> </SelectContent>
</Select>
</div> </div>
{/if} {/if}
<div class="flex gap-3 pt-2"> <div class="flex gap-3 pt-2">
<Button onclick={handleSubmit} disabled={saving}> <Button onclick={handleSubmit} disabled={saving}>
{saving ? "Saving" : "Save changes"} {saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
</Button> </Button>
<Button variant="outline" href="/admin/videos">Cancel</Button> <Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { createVideo, setVideoModels, uploadFile } from "$lib/services"; import { createVideo, setVideoModels, uploadFile } from "$lib/services";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { Textarea } from "$lib/components/ui/textarea"; import { Textarea } from "$lib/components/ui/textarea";
import { TagsInput } from "$lib/components/ui/tags-input"; 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"; import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
const { data } = $props(); const { data } = $props();
@@ -38,9 +40,9 @@
try { try {
const res = await uploadFile(fd); const res = await uploadFile(fd);
imageId = res.id; imageId = res.id;
toast.success("Cover image uploaded"); toast.success($_("admin.video_form.cover_uploaded"));
} catch { } catch {
toast.error("Image upload failed"); toast.error($_("admin.common.image_upload_failed"));
} }
} }
@@ -52,9 +54,9 @@
try { try {
const res = await uploadFile(fd); const res = await uploadFile(fd);
movieId = res.id; movieId = res.id;
toast.success("Video uploaded"); toast.success($_("admin.video_form.video_uploaded"));
} catch { } catch {
toast.error("Video upload failed"); toast.error($_("admin.video_form.video_upload_failed"));
} }
} }
@@ -66,7 +68,7 @@
async function handleSubmit() { async function handleSubmit() {
if (!title || !slug) { if (!title || !slug) {
toast.error("Title and slug are required"); toast.error($_("admin.common.title_slug_required"));
return; return;
} }
saving = true; saving = true;
@@ -85,10 +87,10 @@
if (selectedModelIds.length > 0) { if (selectedModelIds.length > 0) {
await setVideoModels(video.id, selectedModelIds); await setVideoModels(video.id, selectedModelIds);
} }
toast.success("Video created"); toast.success($_("admin.video_form.create_success"));
goto("/admin/videos"); goto("/admin/videos");
} catch (e: any) { } catch (e: any) {
toast.error(e?.message ?? "Failed to create video"); toast.error(e?.message ?? $_("admin.video_form.create_error"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -98,70 +100,70 @@
<div class="p-3 sm:p-6 max-w-2xl"> <div class="p-3 sm:p-6 max-w-2xl">
<div class="flex items-center gap-4 mb-6"> <div class="flex items-center gap-4 mb-6">
<Button variant="ghost" href="/admin/videos" size="sm"> <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> </Button>
<h1 class="text-2xl font-bold">New video</h1> <h1 class="text-2xl font-bold">{$_("admin.video_form.new_title")}</h1>
</div> </div>
<div class="space-y-5"> <div class="space-y-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="title">Title *</Label> <Label for="title">{$_("admin.common.title_field")}</Label>
<Input <Input
id="title" id="title"
bind:value={title} bind:value={title}
oninput={() => { oninput={() => {
if (!slug) slug = generateSlug(title); if (!slug) slug = generateSlug(title);
}} }}
placeholder="Video title" placeholder={$_("admin.video_form.title_placeholder")}
/> />
</div> </div>
<div class="space-y-1.5"> <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} placeholder="video-slug" /> <Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
</div> </div>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="description">Description</Label> <Label for="description">{$_("admin.video_form.description")}</Label>
<Textarea <Textarea
id="description" id="description"
bind:value={description} bind:value={description}
placeholder="Optional description" placeholder={$_("admin.video_form.description_placeholder")}
rows={3} rows={3}
/> />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Cover image</Label> <Label>{$_("admin.common.cover_image")}</Label>
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} /> <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>
<div class="space-y-1.5"> <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} /> <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>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>Tags</Label> <Label>{$_("admin.common.tags")}</Label>
<TagsInput bind:value={tags} /> <TagsInput bind:value={tags} />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="uploadDate">Publish date</Label> <Label>{$_("admin.common.publish_date")}</Label>
<Input id="uploadDate" type="datetime-local" bind:value={uploadDate} /> <DatePicker bind:value={uploadDate} placeholder={$_("admin.common.publish_date")} showTime={false} />
</div> </div>
<div class="flex gap-6"> <div class="flex gap-6">
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={premium} class="rounded" /> <input type="checkbox" bind:checked={premium} class="rounded" />
<span class="text-sm">Premium</span> <span class="text-sm">{$_("admin.common.premium")}</span>
</label> </label>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={featured} class="rounded" /> <input type="checkbox" bind:checked={featured} class="rounded" />
<span class="text-sm">Featured</span> <span class="text-sm">{$_("admin.common.featured")}</span>
</label> </label>
</div> </div>
@@ -188,9 +190,9 @@
<div class="flex gap-3 pt-2"> <div class="flex gap-3 pt-2">
<Button onclick={handleSubmit} disabled={saving}> <Button onclick={handleSubmit} disabled={saving}>
{saving ? "Creating" : "Create video"} {saving ? $_("admin.common.creating") : $_("admin.video_form.create")}
</Button> </Button>
<Button variant="outline" href="/admin/videos">Cancel</Button> <Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -32,7 +32,8 @@
await login(email, password); await login(email, password);
goto("/videos", { invalidateAll: true }); goto("/videos", { invalidateAll: true });
} catch (err: any) { } 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; isError = true;
} finally { } finally {
isLoading = false; isLoading = false;

View File

@@ -27,7 +27,7 @@
const matchesSearch = const matchesSearch =
article.title.toLowerCase().includes(searchQuery.toLowerCase()) || article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.excerpt?.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; const matchesCategory = categoryFilter === "all" || article.category === categoryFilter;
return matchesSearch && matchesCategory; return matchesSearch && matchesCategory;
}) })
@@ -190,11 +190,11 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img <img
src={getAssetUrl(featuredArticle.author?.avatar, "mini")} 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" class="w-10 h-10 rounded-full object-cover"
/> />
<div> <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"> <div class="flex items-center gap-3 text-sm text-muted-foreground">
<span>{timeAgo.format(new Date(featuredArticle.publish_date))}</span> <span>{timeAgo.format(new Date(featuredArticle.publish_date))}</span>
<span></span> <span></span>
@@ -288,11 +288,11 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img <img
src={getAssetUrl(article.author?.avatar, "mini")} 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" class="w-8 h-8 rounded-full object-cover"
/> />
<div> <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"> <div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="icon-[ri--calendar-line] w-4 h-4"></span> <span class="icon-[ri--calendar-line] w-4 h-4"></span>
{timeAgo.format(new Date(article.publish_date))} {timeAgo.format(new Date(article.publish_date))}

View File

@@ -141,32 +141,21 @@
<!-- Author Bio --> <!-- Author Bio -->
{#if data.article.author} {#if data.article.author}
{@const author = data.article.author}
<Card class="p-0 bg-gradient-to-r from-card/50 to-card"> <Card class="p-0 bg-gradient-to-r from-card/50 to-card">
<CardContent class="p-6"> <CardContent class="p-6">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<img <img
src={getAssetUrl(data.article.author.avatar, "mini")} src={getAssetUrl(author.avatar, "mini")}
alt={data.article.author.first_name} alt={author.artist_name}
class="w-16 h-16 rounded-full object-cover ring-2 ring-primary/20" class="w-16 h-16 rounded-full object-cover ring-2 ring-primary/20"
/> />
<div class="flex-1"> <div class="flex-1">
<h3 class="font-semibold text-lg mb-2"> <h3 class="font-semibold text-lg mb-2">About {author.artist_name}</h3>
About {data.article.author.first_name} {#if author.slug}
</h3> <a href="/models/{author.slug}" class="text-sm text-primary hover:underline">
{#if data.article.author.description} View profile
<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}
</a> </a>
</div>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -18,6 +18,7 @@
import * as Alert from "$lib/components/ui/alert"; import * as Alert from "$lib/components/ui/alert";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { deleteRecording, removeFile, updateProfile, uploadFile } from "$lib/services"; import { deleteRecording, removeFile, updateProfile, uploadFile } from "$lib/services";
import * as Dialog from "$lib/components/ui/dialog";
import { Textarea } from "$lib/components/ui/textarea"; import { Textarea } from "$lib/components/ui/textarea";
import Meta from "$lib/components/meta/meta.svelte"; import Meta from "$lib/components/meta/meta.svelte";
import { TagsInput } from "$lib/components/ui/tags-input"; import { TagsInput } from "$lib/components/ui/tags-input";
@@ -27,6 +28,9 @@
const { data } = $props(); const { data } = $props();
let recordings = $state(data.recordings); let recordings = $state(data.recordings);
let deleteTarget = $state<string | null>(null);
let deleteOpen = $state(false);
let deleting = $state(false);
let activeTab = $state("settings"); let activeTab = $state("settings");
@@ -83,7 +87,7 @@
toast.success($_("me.settings.toast_update")); toast.success($_("me.settings.toast_update"));
invalidateAll(); invalidateAll();
} catch (err: any) { } catch (err: any) {
profileError = err.message; profileError = err.response?.errors?.[0]?.message ?? err.message;
isProfileError = true; isProfileError = true;
} finally { } finally {
isProfileLoading = false; isProfileLoading = false;
@@ -107,7 +111,7 @@
invalidateAll(); invalidateAll();
password = confirmPassword = ""; password = confirmPassword = "";
} catch (err: any) { } catch (err: any) {
securityError = err.message; securityError = err.response?.errors?.[0]?.message ?? err.message;
isSecurityError = true; isSecurityError = true;
} finally { } finally {
isSecurityLoading = false; isSecurityLoading = false;
@@ -153,17 +157,24 @@
} }
} }
async function handleDeleteRecording(id: string) { function handleDeleteRecording(id: string) {
if (!confirm($_("me.recordings.delete_confirm"))) { deleteTarget = id;
return; deleteOpen = true;
} }
async function confirmDeleteRecording() {
if (!deleteTarget) return;
deleting = true;
try { try {
await deleteRecording(id); await deleteRecording(deleteTarget);
recordings = recordings.filter((r) => r.id !== id); recordings = recordings.filter((r) => r.id !== deleteTarget);
toast.success($_("me.recordings.delete_success")); toast.success($_("me.recordings.delete_success"));
deleteOpen = false;
deleteTarget = null;
} catch { } catch {
toast.error($_("me.recordings.delete_error")); toast.error($_("me.recordings.delete_error"));
} finally {
deleting = false;
} }
} }
@@ -194,29 +205,19 @@
<PeonyBackground /> <PeonyBackground />
<div class="container mx-auto px-4 py-8"> <div class="container mx-auto px-4 py-8">
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="flex items-center justify-between mb-6">
<div class="flex items-center justify-between mb-8">
<div> <div>
<h1 <h1 class="text-2xl font-bold">{$_("me.title")}</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" <p class="text-sm text-muted-foreground mt-0.5">
> {$_("me.welcome", { values: { name: data.authStatus.user!.artist_name } })}
{$_("me.title")}
</h1>
<p class="text-lg text-muted-foreground">
{$_("me.welcome", {
values: { name: data.authStatus.user!.artist_name },
})}
</p> </p>
</div> </div>
{#if isModel(data.authStatus.user!)} {#if isModel(data.authStatus.user!)}
<Button <Button href={`/models/${data.authStatus.user!.slug}`} variant="outline">
href={`/models/${data.authStatus.user!.slug}`} {$_("me.view_profile")}
variant="outline" </Button>
class="border-primary/20 hover:bg-primary/10">{$_("me.view_profile")}</Button
>
{/if} {/if}
</div> </div>
</div>
<!-- Dashboard Tabs --> <!-- Dashboard Tabs -->
<Tabs bind:value={activeTab} class="w-full"> <Tabs bind:value={activeTab} class="w-full">
@@ -641,3 +642,18 @@
</Tabs> </Tabs>
</div> </div>
</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>

View File

@@ -31,7 +31,7 @@
toast.success($_("auth.password_request.toast_request", { values: { email } })); toast.success($_("auth.password_request.toast_request", { values: { email } }));
goto("/login"); goto("/login");
} catch (err: any) { } catch (err: any) {
error = err.message; error = err.response?.errors?.[0]?.message ?? err.message;
isError = true; isError = true;
} finally { } finally {
isLoading = false; isLoading = false;

View File

@@ -40,7 +40,9 @@
toast.success($_("auth.password_reset.toast_reset")); toast.success($_("auth.password_reset.toast_reset"));
goto("/login"); goto("/login");
} catch (err: any) { } 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; isError = true;
} finally { } finally {
isLoading = false; isLoading = false;

View File

@@ -48,7 +48,8 @@
toast.success($_("auth.signup.toast_register", { values: { email } })); toast.success($_("auth.signup.toast_register", { values: { email } }));
goto("/login"); goto("/login");
} catch (err: any) { } 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; isError = true;
} finally { } finally {
isLoading = false; isLoading = false;

View File

@@ -21,7 +21,8 @@ export interface User {
slug: string | null; slug: string | null;
description: string | null; description: string | null;
tags: string[] | null; tags: string[] | null;
role: "model" | "viewer" | "admin"; role: "model" | "viewer";
is_admin: boolean;
/** UUID of the avatar file */ /** UUID of the avatar file */
avatar: string | null; avatar: string | null;
/** UUID of the banner file */ /** UUID of the banner file */
@@ -86,14 +87,6 @@ export interface Model {
// ─── Article ───────────────────────────────────────────────────────────────── // ─── Article ─────────────────────────────────────────────────────────────────
export interface ArticleAuthor {
first_name: string | null;
last_name: string | null;
avatar: string | null;
description: string | null;
website?: string | null;
}
export interface Article { export interface Article {
id: string; id: string;
slug: string; slug: string;
@@ -105,7 +98,7 @@ export interface Article {
publish_date: Date; publish_date: Date;
category: string | null; category: string | null;
featured: boolean | null; featured: boolean | null;
author?: ArticleAuthor | null; author?: VideoModel | null;
} }
// ─── Comment ───────────────────────────────────────────────────────────────── // ─── Comment ─────────────────────────────────────────────────────────────────

10
pnpm-lock.yaml generated
View File

@@ -186,8 +186,8 @@ importers:
specifier: ^3.11.0 specifier: ^3.11.0
version: 3.11.0 version: 3.11.0
'@lucide/svelte': '@lucide/svelte':
specifier: ^0.577.0 specifier: ^0.561.0
version: 0.577.0(svelte@5.53.7) version: 0.561.0(svelte@5.53.7)
'@sveltejs/adapter-node': '@sveltejs/adapter-node':
specifier: ^5.5.4 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))) 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': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lucide/svelte@0.577.0': '@lucide/svelte@0.561.0':
resolution: {integrity: sha512-0P6mkySd2MapIEgq08tADPmcN4DHndC/02PWwaLkOerXlx5Sv9aT4BxyXLIY+eccr0g/nEyCYiJesqS61YdBZQ==} resolution: {integrity: sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A==}
peerDependencies: peerDependencies:
svelte: ^5 svelte: ^5
@@ -4138,7 +4138,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@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: dependencies:
svelte: 5.53.7 svelte: 5.53.7