2025-10-25 22:04:41 +02:00
|
|
|
<script lang="ts">
|
2026-03-04 22:27:54 +01:00
|
|
|
import { _ } from "svelte-i18n";
|
|
|
|
|
import { page } from "$app/state";
|
|
|
|
|
import { Button } from "$lib/components/ui/button";
|
|
|
|
|
import type { AuthStatus } from "$lib/types";
|
|
|
|
|
import { logout } from "$lib/services";
|
|
|
|
|
import { goto } from "$app/navigation";
|
2026-03-05 10:19:05 +01:00
|
|
|
import { getAssetUrl } from "$lib/api";
|
2026-03-07 18:12:18 +01:00
|
|
|
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
|
|
|
|
import { getUserInitials } from "$lib/utils";
|
2026-03-04 22:27:54 +01:00
|
|
|
import Separator from "../ui/separator/separator.svelte";
|
|
|
|
|
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
|
|
|
|
import Logo from "../logo/logo.svelte";
|
2025-10-25 22:04:41 +02:00
|
|
|
|
2026-03-04 22:27:54 +01:00
|
|
|
interface Props {
|
|
|
|
|
authStatus: AuthStatus;
|
|
|
|
|
}
|
2025-10-25 22:04:41 +02:00
|
|
|
|
2026-03-04 22:27:54 +01:00
|
|
|
let { authStatus }: Props = $props();
|
2025-10-25 22:04:41 +02:00
|
|
|
|
2026-03-04 22:27:54 +01:00
|
|
|
let isMobileMenuOpen = $state(false);
|
2025-10-25 22:04:41 +02:00
|
|
|
|
2026-03-04 22:27:54 +01:00
|
|
|
const navLinks = [
|
|
|
|
|
{ name: $_("header.home"), href: "/" },
|
|
|
|
|
{ name: $_("header.models"), href: "/models" },
|
|
|
|
|
{ name: $_("header.videos"), href: "/videos" },
|
|
|
|
|
{ name: $_("header.magazine"), href: "/magazine" },
|
|
|
|
|
{ name: $_("header.about"), href: "/about" },
|
|
|
|
|
];
|
2025-10-25 22:04:41 +02:00
|
|
|
|
2026-03-04 22:27:54 +01:00
|
|
|
async function handleLogout() {
|
|
|
|
|
closeMenu();
|
|
|
|
|
await logout();
|
|
|
|
|
goto("/login", { invalidateAll: true });
|
|
|
|
|
}
|
2025-10-25 22:04:41 +02:00
|
|
|
|
2026-03-04 22:27:54 +01:00
|
|
|
function closeMenu() {
|
|
|
|
|
isMobileMenuOpen = false;
|
|
|
|
|
}
|
2025-10-25 22:04:41 +02:00
|
|
|
|
2026-03-07 19:25:04 +01:00
|
|
|
function isActiveLink(link: { name: string; href: string }) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return (
|
|
|
|
|
(page.url.pathname === "/" && link === navLinks[0]) ||
|
|
|
|
|
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-10-25 22:04:41 +02:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<header
|
2026-03-08 17:39:47 +01:00
|
|
|
class="sticky top-0 z-50 w-full bg-gradient-to-br from-card/60 via-card/65 to-card/55 backdrop-blur-xl border-b border-border/20 shadow-lg shadow-primary/10"
|
2025-10-25 22:04:41 +02:00
|
|
|
>
|
|
|
|
|
<div class="container mx-auto px-4">
|
|
|
|
|
<div class="flex items-center justify-evenly h-16">
|
|
|
|
|
<!-- Logo -->
|
|
|
|
|
<a
|
|
|
|
|
href="/"
|
|
|
|
|
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
|
|
|
|
|
>
|
2026-03-07 18:33:32 +01:00
|
|
|
<Logo />
|
2025-10-25 22:04:41 +02:00
|
|
|
</a>
|
|
|
|
|
|
|
|
|
|
<!-- Desktop Navigation -->
|
|
|
|
|
<nav class="hidden w-full lg:flex items-center justify-center gap-8">
|
2026-03-04 22:24:55 +01:00
|
|
|
{#each navLinks as link (link.href)}
|
2025-10-25 22:04:41 +02:00
|
|
|
<a
|
|
|
|
|
href={link.href}
|
|
|
|
|
class={`text-sm hover:text-foreground transition-colors duration-200 font-medium relative group ${
|
2026-03-04 22:27:54 +01:00
|
|
|
isActiveLink(link) ? "text-foreground" : "text-foreground/85"
|
2025-10-25 22:04:41 +02:00
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{link.name}
|
|
|
|
|
<span
|
2026-03-04 22:27:54 +01:00
|
|
|
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink(link) ? "w-full" : "group-hover:w-full"}`}
|
2025-10-25 22:04:41 +02:00
|
|
|
></span>
|
|
|
|
|
</a>
|
|
|
|
|
{/each}
|
|
|
|
|
</nav>
|
|
|
|
|
|
2026-03-05 11:44:23 +01:00
|
|
|
<!-- Desktop Auth Actions -->
|
2025-10-25 22:04:41 +02:00
|
|
|
{#if authStatus.authenticated}
|
2026-03-05 11:44:23 +01:00
|
|
|
<div class="w-full hidden lg:flex items-center justify-end">
|
2025-10-25 22:04:41 +02:00
|
|
|
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
|
|
|
|
|
<Button
|
|
|
|
|
variant="link"
|
|
|
|
|
size="icon"
|
2026-03-05 11:44:23 +01:00
|
|
|
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "text-foreground" : "hover:text-foreground"}`}
|
2025-10-25 22:04:41 +02:00
|
|
|
href="/me"
|
2026-03-04 22:27:54 +01:00
|
|
|
title={$_("header.dashboard")}
|
2025-10-25 22:04:41 +02:00
|
|
|
>
|
|
|
|
|
<span class="icon-[ri--dashboard-2-line] h-4 w-4"></span>
|
|
|
|
|
<span
|
2026-03-04 22:27:54 +01:00
|
|
|
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/me" }) ? "w-full" : "group-hover:w-full"}`}
|
2025-10-25 22:04:41 +02:00
|
|
|
></span>
|
2026-03-04 22:27:54 +01:00
|
|
|
<span class="sr-only">{$_("header.dashboard")}</span>
|
2025-10-25 22:04:41 +02:00
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="link"
|
|
|
|
|
size="icon"
|
2026-03-05 11:44:23 +01:00
|
|
|
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
|
2025-10-25 22:04:41 +02:00
|
|
|
href="/play"
|
2026-03-04 22:27:54 +01:00
|
|
|
title={$_("header.play")}
|
2025-10-25 22:04:41 +02:00
|
|
|
>
|
|
|
|
|
<span class="icon-[ri--rocket-line] h-4 w-4"></span>
|
|
|
|
|
<span
|
2026-03-04 22:27:54 +01:00
|
|
|
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/play" }) ? "w-full" : "group-hover:w-full"}`}
|
2025-10-25 22:04:41 +02:00
|
|
|
></span>
|
2026-03-04 22:27:54 +01:00
|
|
|
<span class="sr-only">{$_("header.play")}</span>
|
2025-10-25 22:04:41 +02:00
|
|
|
</Button>
|
|
|
|
|
|
2026-03-06 16:14:00 +01:00
|
|
|
{#if authStatus.user?.is_admin}
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
<Button
|
|
|
|
|
variant="link"
|
|
|
|
|
size="icon"
|
|
|
|
|
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/admin" }) ? "text-foreground" : "hover:text-foreground"}`}
|
|
|
|
|
href="/admin/users"
|
|
|
|
|
title="Admin"
|
|
|
|
|
>
|
|
|
|
|
<span class="icon-[ri--settings-3-line] h-4 w-4"></span>
|
|
|
|
|
<span
|
|
|
|
|
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/admin" }) ? "w-full" : "group-hover:w-full"}`}
|
|
|
|
|
></span>
|
|
|
|
|
<span class="sr-only">Admin</span>
|
|
|
|
|
</Button>
|
|
|
|
|
{/if}
|
|
|
|
|
|
2026-03-05 11:44:23 +01:00
|
|
|
<Separator orientation="vertical" class="mx-1 h-6 bg-border/50" />
|
2025-10-25 22:04:41 +02:00
|
|
|
|
2026-03-07 18:12:18 +01:00
|
|
|
<a href="/me" class="flex items-center gap-2 px-1 hover:opacity-80 transition-opacity">
|
|
|
|
|
<Avatar class="h-7 w-7 ring-2 ring-primary/20">
|
|
|
|
|
<AvatarImage
|
|
|
|
|
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
|
|
|
|
|
alt={authStatus.user!.artist_name || authStatus.user!.email}
|
|
|
|
|
/>
|
|
|
|
|
<AvatarFallback
|
|
|
|
|
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold"
|
|
|
|
|
>
|
|
|
|
|
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
|
|
|
|
|
</AvatarFallback>
|
|
|
|
|
</Avatar>
|
|
|
|
|
<span class="text-sm font-medium text-foreground/90 max-w-24 truncate">
|
|
|
|
|
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
|
|
|
|
</span>
|
|
|
|
|
</a>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
class="h-8 w-8 rounded-full text-foreground hover:text-destructive hover:bg-destructive/10"
|
|
|
|
|
onclick={handleLogout}
|
|
|
|
|
title={$_("header.logout")}
|
|
|
|
|
>
|
|
|
|
|
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
|
|
|
|
|
</Button>
|
2025-10-25 22:04:41 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
2026-03-05 11:44:23 +01:00
|
|
|
<div class="hidden lg:flex w-full items-center justify-end gap-4">
|
2026-03-04 22:27:54 +01:00
|
|
|
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button>
|
2025-10-25 22:04:41 +02:00
|
|
|
<Button
|
|
|
|
|
href="/signup"
|
|
|
|
|
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
|
2026-03-04 22:27:54 +01:00
|
|
|
>{$_("header.signup")}</Button
|
2025-10-25 22:04:41 +02:00
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
2026-03-05 11:44:23 +01:00
|
|
|
|
|
|
|
|
<!-- Burger button — mobile/tablet only -->
|
|
|
|
|
<div class="lg:hidden ml-auto">
|
|
|
|
|
<BurgerMenuButton
|
|
|
|
|
label={$_("header.navigation")}
|
|
|
|
|
bind:isMobileMenuOpen
|
|
|
|
|
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-10-25 22:04:41 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-05 11:44:23 +01:00
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<!-- Backdrop -->
|
|
|
|
|
<div
|
|
|
|
|
role="presentation"
|
|
|
|
|
class={`fixed inset-0 z-40 bg-black/60 backdrop-blur-sm transition-opacity duration-300 lg:hidden ${isMobileMenuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
|
|
|
|
onclick={closeMenu}
|
|
|
|
|
></div>
|
|
|
|
|
|
|
|
|
|
<!-- Flyout panel -->
|
|
|
|
|
<div
|
2026-03-05 12:34:57 +01:00
|
|
|
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"}`}
|
2026-03-06 16:31:41 +01:00
|
|
|
inert={!isMobileMenuOpen || undefined}
|
2026-03-05 11:44:23 +01:00
|
|
|
>
|
|
|
|
|
<!-- Panel header -->
|
2026-03-08 10:42:22 +01:00
|
|
|
<div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
|
2026-03-07 18:33:32 +01:00
|
|
|
<Logo />
|
2026-03-05 11:44:23 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="flex-1 py-6 px-5 space-y-6">
|
2026-03-07 18:12:18 +01:00
|
|
|
<!-- User card -->
|
2026-03-05 11:44:23 +01:00
|
|
|
{#if authStatus.authenticated}
|
2026-03-07 18:12:18 +01:00
|
|
|
<div class="flex items-center gap-3 rounded-xl border border-border/40 bg-card/50 px-4 py-3">
|
|
|
|
|
<Avatar class="h-10 w-10 ring-2 ring-primary/20 shrink-0">
|
|
|
|
|
<AvatarImage
|
|
|
|
|
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
|
|
|
|
|
alt={authStatus.user!.artist_name || authStatus.user!.email}
|
|
|
|
|
/>
|
|
|
|
|
<AvatarFallback
|
|
|
|
|
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-sm font-semibold"
|
|
|
|
|
>
|
|
|
|
|
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
|
|
|
|
|
</AvatarFallback>
|
|
|
|
|
</Avatar>
|
|
|
|
|
<div class="flex flex-col min-w-0 flex-1">
|
|
|
|
|
<span class="text-sm font-semibold text-foreground truncate">
|
|
|
|
|
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
|
|
|
|
</span>
|
|
|
|
|
<span class="text-xs text-muted-foreground truncate">{authStatus.user!.email}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
class="h-8 w-8 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 shrink-0"
|
|
|
|
|
onclick={handleLogout}
|
|
|
|
|
title={$_("header.logout")}
|
|
|
|
|
>
|
|
|
|
|
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-03-05 11:44:23 +01:00
|
|
|
{/if}
|
2025-10-25 22:04:41 +02:00
|
|
|
|
2026-03-05 11:44:23 +01:00
|
|
|
<!-- Navigation -->
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
|
|
|
{$_("header.navigation")}
|
|
|
|
|
</h3>
|
|
|
|
|
<div class="grid gap-1.5">
|
|
|
|
|
{#each navLinks as link (link.href)}
|
|
|
|
|
<a
|
|
|
|
|
href={link.href}
|
|
|
|
|
class={`flex items-center justify-between rounded-xl border px-4 py-3 transition-all duration-200 hover:border-primary/30 hover:bg-primary/5 ${
|
|
|
|
|
isActiveLink(link)
|
|
|
|
|
? "border-primary/40 bg-primary/8 text-foreground"
|
|
|
|
|
: "border-border/40 bg-card/50 text-foreground/85"
|
|
|
|
|
}`}
|
|
|
|
|
onclick={closeMenu}
|
|
|
|
|
>
|
|
|
|
|
<span class="font-medium text-sm">{link.name}</span>
|
|
|
|
|
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
|
|
|
|
</a>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-10-25 22:04:41 +02:00
|
|
|
|
2026-03-05 11:44:23 +01:00
|
|
|
<!-- Account -->
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
|
|
|
{$_("header.account")}
|
|
|
|
|
</h3>
|
|
|
|
|
<div class="grid gap-1.5">
|
|
|
|
|
{#if authStatus.authenticated}
|
|
|
|
|
<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: "/me" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
|
|
|
|
href="/me"
|
|
|
|
|
onclick={closeMenu}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
class="icon-[ri--dashboard-2-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
|
|
|
|
|
></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex flex-1 flex-col gap-0.5">
|
|
|
|
|
<span class="text-sm font-medium text-foreground">{$_("header.dashboard")}</span>
|
|
|
|
|
<span class="text-xs text-muted-foreground">{$_("header.dashboard_hint")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
|
|
|
|
</a>
|
2025-10-25 22:04:41 +02:00
|
|
|
|
2026-03-05 11:44:23 +01:00
|
|
|
<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: "/play" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
|
|
|
|
href="/play"
|
|
|
|
|
onclick={closeMenu}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
class="icon-[ri--rocket-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
|
|
|
|
|
></span>
|
2025-10-25 22:04:41 +02:00
|
|
|
</div>
|
2026-03-05 11:44:23 +01:00
|
|
|
<div class="flex flex-1 flex-col gap-0.5">
|
|
|
|
|
<span class="text-sm font-medium text-foreground">{$_("header.play")}</span>
|
|
|
|
|
<span class="text-xs text-muted-foreground">{$_("header.play_hint")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
|
|
|
|
</a>
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
|
2026-03-06 16:14:00 +01:00
|
|
|
{#if authStatus.user?.is_admin}
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
<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"
|
|
|
|
|
onclick={closeMenu}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
class="icon-[ri--settings-3-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
|
|
|
|
|
></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex flex-1 flex-col gap-0.5">
|
|
|
|
|
<span class="text-sm font-medium text-foreground">Admin</span>
|
|
|
|
|
<span class="text-xs text-muted-foreground">Manage content</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
|
|
|
|
</a>
|
|
|
|
|
{/if}
|
2026-03-05 11:44:23 +01:00
|
|
|
{:else}
|
|
|
|
|
<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: "/login" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
|
|
|
|
href="/login"
|
|
|
|
|
onclick={closeMenu}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
class="icon-[ri--login-circle-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
|
|
|
|
|
></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex flex-1 flex-col gap-0.5">
|
|
|
|
|
<span class="text-sm font-medium text-foreground">{$_("header.login")}</span>
|
|
|
|
|
<span class="text-xs text-muted-foreground">{$_("header.login_hint")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
|
|
|
|
</a>
|
2025-10-25 22:04:41 +02:00
|
|
|
|
2026-03-05 11:44:23 +01:00
|
|
|
<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: "/signup" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
|
|
|
|
href="/signup"
|
|
|
|
|
onclick={closeMenu}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-accent/10 transition-colors"
|
2025-10-25 22:04:41 +02:00
|
|
|
>
|
2026-03-05 11:44:23 +01:00
|
|
|
<span
|
|
|
|
|
class="icon-[ri--heart-add-2-line] h-4 w-4 text-muted-foreground group-hover:text-accent transition-colors"
|
|
|
|
|
></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex flex-1 flex-col gap-0.5">
|
|
|
|
|
<span class="text-sm font-medium text-foreground">{$_("header.signup")}</span>
|
|
|
|
|
<span class="text-xs text-muted-foreground">{$_("header.signup_hint")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
|
|
|
|
</a>
|
|
|
|
|
{/if}
|
2025-10-25 22:04:41 +02:00
|
|
|
</div>
|
2026-03-05 11:44:23 +01:00
|
|
|
</div>
|
2025-10-25 22:04:41 +02:00
|
|
|
</div>
|
2026-03-05 11:44:23 +01:00
|
|
|
</div>
|