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"),
avatar: text("avatar").references(() => files.id, { onDelete: "set null" }),
banner: text("banner").references(() => files.id, { onDelete: "set null" }),
is_admin: boolean("is_admin").notNull().default(false),
email_verified: boolean("email_verified").notNull().default(false),
email_verify_token: text("email_verify_token"),
password_reset_token: text("password_reset_token"),

View File

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

View File

@@ -32,7 +32,8 @@ builder.mutationField("login", (t) =>
const sessionUser = {
id: user[0].id,
email: user[0].email,
role: user[0].role,
role: (user[0].role === "admin" ? "viewer" : user[0].role) as "model" | "viewer",
is_admin: user[0].is_admin,
first_name: user[0].first_name,
last_name: user[0].last_name,
artist_name: user[0].artist_name,

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import type {
Video,
ModelPhoto,
Model,
ArticleAuthor,
Article,
CommentUser,
Comment,
@@ -53,6 +52,7 @@ export const UserType = builder.objectRef<User>("User").implement({
description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"),
is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
@@ -72,6 +72,7 @@ export const CurrentUserType = builder.objectRef<User>("CurrentUser").implement(
description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"),
is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
@@ -137,15 +138,6 @@ export const ModelType = builder.objectRef<Model>("Model").implement({
}),
});
export const ArticleAuthorType = builder.objectRef<ArticleAuthor>("ArticleAuthor").implement({
fields: (t) => ({
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
avatar: t.exposeString("avatar", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
}),
});
export const ArticleType = builder.objectRef<Article>("Article").implement({
fields: (t) => ({
id: t.exposeString("id"),
@@ -158,7 +150,7 @@ export const ArticleType = builder.objectRef<Article>("Article").implement({
publish_date: t.expose("publish_date", { type: "DateTime" }),
category: t.exposeString("category", { nullable: true }),
featured: t.exposeBoolean("featured", { nullable: true }),
author: t.expose("author", { type: ArticleAuthorType, nullable: true }),
author: t.expose("author", { type: VideoModelType, nullable: true }),
}),
});
@@ -359,6 +351,7 @@ export const AdminUserDetailType = builder
description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"),
is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),

View File

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

View File

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

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,
"tag": "0000_pale_hellion",
"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/tailwind4": "^1.2.1",
"@internationalized/date": "^3.11.0",
"@lucide/svelte": "^0.577.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.53.4",

View File

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

View File

@@ -8,8 +8,6 @@
import { getAssetUrl } from "$lib/api";
import LogoutButton from "../logout-button/logout-button.svelte";
import Separator from "../ui/separator/separator.svelte";
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
import { getUserInitials } from "$lib/utils";
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
import Logo from "../logo/logo.svelte";
@@ -109,7 +107,7 @@
<span class="sr-only">{$_("header.play")}</span>
</Button>
{#if authStatus.user?.role === "admin"}
{#if authStatus.user?.is_admin}
<Button
variant="link"
size="icon"
@@ -172,7 +170,7 @@
<!-- Flyout panel -->
<div
class={`fixed inset-y-0 left-0 z-50 w-80 max-w-[85vw] bg-card/95 backdrop-blur-xl shadow-2xl shadow-primary/20 border-r border-border/30 transform transition-transform duration-300 ease-in-out lg:hidden overflow-y-auto flex flex-col ${isMobileMenuOpen ? "translate-x-0" : "-translate-x-full"}`}
aria-hidden={!isMobileMenuOpen}
inert={!isMobileMenuOpen || undefined}
>
<!-- Panel header -->
<div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
@@ -180,34 +178,17 @@
</div>
<div class="flex-1 py-6 px-5 space-y-6">
<!-- User Profile Card -->
<!-- User logout slider -->
{#if authStatus.authenticated}
<div
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4"
>
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"></div>
<div class="relative flex items-center gap-3">
<Avatar class="h-9 w-9 ring-2 ring-primary/30">
<AvatarImage
src={getAssetUrl(authStatus.user!.avatar, "mini")}
alt={authStatus.user!.artist_name}
<LogoutButton
user={{
name: authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
email: authStatus.user!.email,
}}
onLogout={handleLogout}
class="w-full"
/>
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground font-semibold"
>
{getUserInitials(authStatus.user!.artist_name)}
</AvatarFallback>
</Avatar>
<div class="flex flex-1 flex-col min-w-0">
<p class="text-sm font-semibold text-foreground truncate">
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
</p>
<p class="text-xs text-muted-foreground truncate">
{authStatus.user!.email}
</p>
</div>
</div>
</div>
{/if}
<!-- Navigation -->
@@ -278,7 +259,7 @@
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
{#if authStatus.user?.role === "admin"}
{#if authStatus.user?.is_admin}
<a
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/admin" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
href="/admin/users"
@@ -340,21 +321,5 @@
</div>
</div>
{#if authStatus.authenticated}
<button
class="cursor-pointer flex w-full items-center gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 transition-all duration-200 hover:bg-destructive/10 hover:border-destructive/30 group"
onclick={handleLogout}
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-destructive/10 group-hover:bg-destructive/20 transition-colors"
>
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"></span>
</div>
<div class="flex flex-1 flex-col gap-0.5 text-left">
<span class="text-sm font-medium text-foreground">{$_("header.logout")}</span>
<span class="text-xs text-muted-foreground">{$_("header.logout_hint")}</span>
</div>
</button>
{/if}
</div>
</div>

View File

@@ -11,15 +11,17 @@
interface Props {
user: User;
onLogout: () => void;
class?: string;
}
let { user, onLogout }: Props = $props();
let { user, onLogout, class: className = "" }: Props = $props();
let container: HTMLDivElement;
let isDragging = $state(false);
let slidePosition = $state(0);
let startX = 0;
let currentX = 0;
let maxSlide = 117; // Maximum slide distance
let maxSlide = $derived(container ? container.offsetWidth - 40 : 117);
let threshold = 0.75; // 70% threshold to trigger logout
// Calculate slide progress (0 to 1)
@@ -102,9 +104,10 @@
</script>
<div
bind:this={container}
class="relative h-10 w-40 rounded-full bg-muted/30 overflow-hidden select-none transition-all duration-300 bg-muted/40 shadow-lg shadow-accent/10 {isDragging
? 'cursor-grabbing'
: ''}"
: ''} {className}"
style="background: linear-gradient(90deg,
oklch(var(--primary) / 0.3) 0%,
oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%,

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?",
sign_up_link: "Sign up now",
error: "Heads Up!",
error_invalid_credentials: "Invalid email or password.",
},
signup: {
title: "Create Account",
@@ -194,6 +195,7 @@ export default {
have_account: "Already have an account?",
sign_in_link: "Sign in here",
error: "Heads Up!",
error_email_taken: "This email address is already registered.",
agree_error: "You must confirm our terms of service and your age.",
password_error: "The password has to match the confirmation password.",
toast_register: "A verification email has been sent to {email}!",
@@ -221,6 +223,7 @@ export default {
resetting: "Resetting...",
reset: "Reset",
error: "Heads Up!",
error_invalid_token: "This reset link is invalid or has expired.",
password_error: "The password has to match the confirmation password.",
toast_reset: "Your password has been reset!",
},
@@ -896,6 +899,142 @@ export default {
head: {
title: "SexyArt | {title}",
},
admin: {
nav: {
back_to_site: "← Back to site",
back_mobile: "← Back",
title: "Admin",
users: "Users",
videos: "Videos",
articles: "Articles",
},
common: {
save_changes: "Save changes",
saving: "Saving…",
creating: "Creating…",
deleting: "Deleting…",
featured: "Featured",
premium: "Premium",
write: "Write",
preview: "Preview",
cover_image: "Cover image",
tags: "Tags",
publish_date: "Publish date",
title_field: "Title *",
slug_field: "Slug *",
title_slug_required: "Title and slug are required",
image_uploaded: "Image uploaded",
image_upload_failed: "Image upload failed",
},
users: {
title: "Users",
total: "{total} total",
search_placeholder: "Search email or name…",
filter_all: "All",
col_user: "User",
col_email: "Email",
col_role: "Role",
col_joined: "Joined",
col_actions: "Actions",
role_viewer: "Viewer",
role_model: "Model",
admin_badge: "Admin",
no_results: "No users found",
showing: "Showing {start}{end} of {total}",
role_updated: "Role updated to {role}",
role_update_failed: "Failed to update role",
delete_title: "Delete user",
delete_description: "Are you sure you want to permanently delete {name}? This cannot be undone.",
delete_success: "User deleted",
delete_error: "Failed to delete user",
},
user_edit: {
first_name: "First name",
last_name: "Last name",
artist_name: "Artist name",
avatar: "Avatar",
banner: "Banner",
is_admin: "Administrator",
is_admin_hint: "Grants full admin access to the dashboard",
photos: "Photo gallery",
no_photos: "No photos yet.",
avatar_uploaded: "Avatar uploaded",
avatar_failed: "Avatar upload failed",
banner_uploaded: "Banner uploaded",
banner_failed: "Banner upload failed",
photos_added: "{count} photos added",
photo_upload_failed: "Failed to upload {name}",
photo_remove_failed: "Failed to remove photo",
save_success: "Saved",
save_error: "Save failed",
},
videos: {
title: "Videos",
new_video: "New video",
col_video: "Video",
col_badges: "Badges",
col_plays: "Plays",
col_likes: "Likes",
no_results: "No videos yet",
delete_title: "Delete video",
delete_description: "Permanently delete {title}? This cannot be undone.",
delete_success: "Video deleted",
delete_error: "Failed to delete video",
},
video_form: {
new_title: "New video",
edit_title: "Edit video",
title_placeholder: "Video title",
slug_placeholder: "video-slug",
description: "Description",
description_placeholder: "Optional description",
video_file: "Video file",
current_file: "Current file: {id}",
models: "Models",
no_models: "No models",
models_selected: "{count} models selected",
cover_uploaded: "Cover image uploaded",
video_uploaded: "Video uploaded",
video_upload_failed: "Video upload failed",
create_success: "Video created",
create_error: "Failed to create video",
update_success: "Video updated",
update_error: "Failed to update video",
create: "Create video",
},
articles: {
title: "Articles",
new_article: "New article",
col_article: "Article",
col_category: "Category",
col_published: "Published",
no_results: "No articles yet",
delete_title: "Delete article",
delete_description: "Permanently delete {title}? This cannot be undone.",
delete_success: "Article deleted",
delete_error: "Failed to delete article",
},
article_form: {
new_title: "New article",
edit_title: "Edit article",
title_placeholder: "Article title",
slug_placeholder: "article-slug",
excerpt: "Excerpt",
excerpt_placeholder: "Short summary…",
content: "Content (Markdown)",
content_placeholder: "Write in Markdown…",
preview_placeholder: "Preview will appear here…",
category: "Category",
category_placeholder: "e.g. news, tutorial…",
author: "Author",
no_author: "No author",
create_success: "Article created",
create_error: "Failed to create article",
update_success: "Article updated",
update_error: "Failed to update article",
create: "Create article",
},
},
gamification: {
leaderboard: "Leaderboard",
leaderboard_description: "Compete with other creators and players for the top spot",

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { redirect } from "@sveltejs/kit";
export async function load({ locals }) {
if (!locals.authStatus.authenticated || locals.authStatus.user?.role !== "admin") {
if (!locals.authStatus.authenticated || !locals.authStatus.user?.is_admin) {
throw redirect(302, "/");
}
return { authStatus: locals.authStatus };

View File

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

View File

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

View File

@@ -1,10 +1,13 @@
import { adminListArticles } from "$lib/services";
import { adminListArticles, adminListUsers } from "$lib/services";
import { error } from "@sveltejs/kit";
export async function load({ params, fetch, cookies }) {
const token = cookies.get("session_token") || "";
const articles = await adminListArticles(fetch, token).catch(() => []);
const [articles, modelsResult] = await Promise.all([
adminListArticles(fetch, token).catch(() => []),
adminListUsers({ role: "model", limit: 200 }, fetch, token).catch(() => ({ items: [], total: 0 })),
]);
const article = articles.find((a) => a.id === params.id);
if (!article) throw error(404, "Article not found");
return { article };
return { article, authors: modelsResult.items };
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,8 @@
await login(email, password);
goto("/videos", { invalidateAll: true });
} catch (err: any) {
error = err.message;
const raw = err.response?.errors?.[0]?.message ?? err.message;
error = raw === "Invalid credentials" ? $_("auth.login.error_invalid_credentials") : raw;
isError = true;
} finally {
isLoading = false;

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,9 @@
toast.success($_("auth.password_reset.toast_reset"));
goto("/login");
} catch (err: any) {
error = err.message;
const raw = err.response?.errors?.[0]?.message ?? err.message;
const tokenErrors = ["Invalid or expired reset token", "Reset token expired"];
error = tokenErrors.includes(raw) ? $_("auth.password_reset.error_invalid_token") : raw;
isError = true;
} finally {
isLoading = false;

View File

@@ -48,7 +48,8 @@
toast.success($_("auth.signup.toast_register", { values: { email } }));
goto("/login");
} catch (err: any) {
error = err.message;
const raw = err.response?.errors?.[0]?.message ?? err.message;
error = raw === "Email already registered" ? $_("auth.signup.error_email_taken") : raw;
isError = true;
} finally {
isLoading = false;

View File

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

10
pnpm-lock.yaml generated
View File

@@ -186,8 +186,8 @@ importers:
specifier: ^3.11.0
version: 3.11.0
'@lucide/svelte':
specifier: ^0.577.0
version: 0.577.0(svelte@5.53.7)
specifier: ^0.561.0
version: 0.561.0(svelte@5.53.7)
'@sveltejs/adapter-node':
specifier: ^5.5.4
version: 5.5.4(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))
@@ -1202,8 +1202,8 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lucide/svelte@0.577.0':
resolution: {integrity: sha512-0P6mkySd2MapIEgq08tADPmcN4DHndC/02PWwaLkOerXlx5Sv9aT4BxyXLIY+eccr0g/nEyCYiJesqS61YdBZQ==}
'@lucide/svelte@0.561.0':
resolution: {integrity: sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A==}
peerDependencies:
svelte: ^5
@@ -4138,7 +4138,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lucide/svelte@0.577.0(svelte@5.53.7)':
'@lucide/svelte@0.561.0(svelte@5.53.7)':
dependencies:
svelte: 5.53.7