feat: extend tactile theme to docs panel and auth

This commit is contained in:
Haitao Pan 2026-03-18 17:39:48 +08:00
parent 2831f6028b
commit b2ac63e2b0
11 changed files with 863 additions and 650 deletions

View File

@ -1,20 +1,20 @@
'use client'
"use client";
import { usePathname } from 'next/navigation'
import type { DocCollection } from './types'
import { SidebarRoot } from '../../components/layout/SidebarRoot'
import { DocsSidebarContent } from './DocsSidebarContent'
import { usePathname } from "next/navigation";
import type { DocCollection } from "./types";
import { SidebarRoot } from "../../components/layout/SidebarRoot";
import { DocsSidebarContent } from "./DocsSidebarContent";
interface DocsSidebarProps {
collections: DocCollection[]
collections: DocCollection[];
}
export default function DocsSidebar({ collections }: DocsSidebarProps) {
const pathname = usePathname()
const pathname = usePathname();
return (
<SidebarRoot className="sticky top-[64px] hidden h-[calc(100vh-64px)] w-72 shrink-0 border-r border-surface-border bg-background/50 backdrop-blur-sm py-8 pl-8 pr-4 lg:block">
<DocsSidebarContent collections={collections} activePath={pathname} />
</SidebarRoot>
)
return (
<SidebarRoot className="sticky top-[calc(var(--app-shell-nav-offset)+0.75rem)] hidden h-[calc(100vh-var(--app-shell-nav-offset)-1rem)] w-72 shrink-0 rounded-[14px] border border-surface-border/80 bg-white/78 px-4 py-5 shadow-[var(--shadow-soft)] backdrop-blur lg:block">
<DocsSidebarContent collections={collections} activePath={pathname} />
</SidebarRoot>
);
}

View File

@ -1,233 +1,311 @@
'use client'
"use client";
import Link from 'next/link'
import { useState, useEffect } from 'react'
import Link from "next/link";
import { useState, useEffect } from "react";
import {
ChevronRight,
ChevronDown,
Book,
Settings,
Zap,
Shield,
HelpCircle,
Code,
Terminal,
Activity,
GraduationCap,
Layout,
Layers,
Puzzle
} from 'lucide-react'
import type { DocCollection, DocVersionOption } from './types'
import { SidebarContent } from '../../components/layout/SidebarRoot'
ChevronRight,
ChevronDown,
Book,
Settings,
Zap,
Shield,
HelpCircle,
Code,
Terminal,
Activity,
GraduationCap,
Layout,
Layers,
Puzzle,
} from "lucide-react";
import type { DocCollection, DocVersionOption } from "./types";
import { SidebarContent } from "../../components/layout/SidebarRoot";
interface DocsSidebarContentProps {
collections: DocCollection[]
activePath: string
collections: DocCollection[];
activePath: string;
}
// Helper to humanize category names
const humanize = (s: string) => {
if (!s) return ''
return s.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
if (!s) return "";
return s.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
};
// Icon mapping for categories
const ICON_MAP: Record<string, any> = {
'getting-started': Book,
'architecture': Zap,
'usage': Settings,
'advanced': GraduationCap,
'api': Code,
'development': Terminal,
'operations': Activity,
'governance': Shield,
'appendix': HelpCircle,
'integrations': Puzzle,
'overview': Layout,
'core-concepts': Layers,
"getting-started": Book,
architecture: Zap,
usage: Settings,
advanced: GraduationCap,
api: Code,
development: Terminal,
operations: Activity,
governance: Shield,
appendix: HelpCircle,
integrations: Puzzle,
overview: Layout,
"core-concepts": Layers,
};
const ADVANCED_GROUP = [
"api",
"development",
"operations",
"governance",
"advanced",
];
export function DocsSidebarContent({
collections,
activePath,
}: DocsSidebarContentProps) {
// Sort collections: Console first, then others alphabetically
const sortedCollections = [...collections].sort((a, b) => {
if (a.slug.includes("console")) return -1;
if (b.slug.includes("console")) return 1;
return a.title.localeCompare(b.title);
});
return (
<SidebarContent>
<nav className="space-y-8">
{sortedCollections.map((collection) => (
<CollectionGroup
key={collection.slug}
collection={collection}
activePath={activePath}
/>
))}
</nav>
</SidebarContent>
);
}
const ADVANCED_GROUP = ['api', 'development', 'operations', 'governance', 'advanced']
function CollectionGroup({
collection,
activePath,
}: {
collection: DocCollection;
activePath: string;
}) {
const [isOpen, setIsOpen] = useState(true);
export function DocsSidebarContent({ collections, activePath }: DocsSidebarContentProps) {
// Sort collections: Console first, then others alphabetically
const sortedCollections = [...collections].sort((a, b) => {
if (a.slug.includes('console')) return -1
if (b.slug.includes('console')) return 1
return a.title.localeCompare(b.title)
})
// Group versions by category
const grouped: Record<string, DocVersionOption[]> = {};
const topLevel: DocVersionOption[] = [];
return (
<SidebarContent>
<nav className="space-y-10">
{sortedCollections.map((collection) => (
<CollectionGroup
key={collection.slug}
collection={collection}
activePath={activePath}
/>
))}
</nav>
</SidebarContent>
)
}
collection.versions.forEach((v) => {
const category = v.category;
if (!category || category === "overview" || category === "index") {
topLevel.push(v);
} else {
if (!grouped[category]) grouped[category] = [];
grouped[category].push(v);
}
});
function CollectionGroup({ collection, activePath }: { collection: DocCollection; activePath: string }) {
const [isOpen, setIsOpen] = useState(true)
const hasAdvanced = ADVANCED_GROUP.some((k) => grouped[k]);
// Group versions by category
const grouped: Record<string, DocVersionOption[]> = {}
const topLevel: DocVersionOption[] = []
return (
<div className="space-y-3">
<button
onClick={() => setIsOpen(!isOpen)}
className="group flex w-full items-center justify-between text-xs font-bold uppercase tracking-[0.22em] text-text-subtle transition-colors hover:text-primary"
>
<span className="flex items-center gap-2">
<span className="h-1 w-1 rounded-full bg-primary/40 group-hover:bg-primary transition-colors"></span>
{collection.title}
</span>
{isOpen ? (
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
) : (
<ChevronRight className="h-3.5 w-3.5 opacity-50" />
)}
</button>
collection.versions.forEach(v => {
const category = v.category
if (!category || category === 'overview' || category === 'index') {
topLevel.push(v)
} else {
if (!grouped[category]) grouped[category] = []
grouped[category].push(v)
}
})
const hasAdvanced = ADVANCED_GROUP.some(k => grouped[k])
return (
<div className="space-y-4">
<button
onClick={() => setIsOpen(!isOpen)}
className="group flex w-full items-center justify-between text-xs font-bold uppercase tracking-widest text-text-subtle transition-colors hover:text-primary"
>
<span className="flex items-center gap-2">
<span className="h-1 w-1 rounded-full bg-primary/40 group-hover:bg-primary transition-colors"></span>
{collection.title}
</span>
{isOpen ? <ChevronDown className="h-3.5 w-3.5 opacity-50" /> : <ChevronRight className="h-3.5 w-3.5 opacity-50" />}
</button>
{isOpen && (
<div className="space-y-6">
{/* Uncategorized / Overview / README */}
{topLevel.length > 0 && (
<ul className="space-y-1">
{topLevel.map(v => (
<SidebarLink key={v.slug} version={v} collectionSlug={collection.slug} activePath={activePath} />
))}
</ul>
)}
{/* Main Categories (Getting Started, Architecture, etc.) */}
<div className="space-y-4">
{Object.entries(grouped)
.filter(([k]) => !ADVANCED_GROUP.includes(k))
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([category, versions]) => (
<CategorySection
key={category}
title={category}
versions={versions}
collectionSlug={collection.slug}
activePath={activePath}
/>
))}
</div>
{/* Advanced Section Dropdown */}
{hasAdvanced && (
<AdvancedSection
grouped={grouped}
collectionSlug={collection.slug}
activePath={activePath}
/>
)}
</div>
)}
</div>
)
}
function CategorySection({ title, versions, collectionSlug, activePath }: { title: string; versions: DocVersionOption[]; collectionSlug: string; activePath: string }) {
const Icon = ICON_MAP[title] || Book
// Auto-expand if active
const isActive = versions.some(v => activePath === `/docs/${collectionSlug}/${v.slug}`)
return (
<div className="space-y-1">
<div
className={`flex w-full items-center gap-2.5 px-2 py-1 text-[11px] font-bold uppercase tracking-tight ${isActive ? 'text-primary' : 'text-text-muted/80'}`}
>
<Icon className={`h-3.5 w-3.5 ${isActive ? 'text-primary' : 'text-text-subtle'}`} />
<span>{humanize(title)}</span>
</div>
<ul className="ml-3.5 space-y-1 border-l border-surface-border pl-4">
{versions.map(v => (
<SidebarLink key={v.slug} version={v} collectionSlug={collectionSlug} activePath={activePath} />
))}
{isOpen && (
<div className="space-y-5">
{/* Uncategorized / Overview / README */}
{topLevel.length > 0 && (
<ul className="space-y-1">
{topLevel.map((v) => (
<SidebarLink
key={v.slug}
version={v}
collectionSlug={collection.slug}
activePath={activePath}
/>
))}
</ul>
)}
{/* Main Categories (Getting Started, Architecture, etc.) */}
<div className="space-y-4">
{Object.entries(grouped)
.filter(([k]) => !ADVANCED_GROUP.includes(k))
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([category, versions]) => (
<CategorySection
key={category}
title={category}
versions={versions}
collectionSlug={collection.slug}
activePath={activePath}
/>
))}
</div>
{/* Advanced Section Dropdown */}
{hasAdvanced && (
<AdvancedSection
grouped={grouped}
collectionSlug={collection.slug}
activePath={activePath}
/>
)}
</div>
)
)}
</div>
);
}
function AdvancedSection({ grouped, collectionSlug, activePath }: { grouped: Record<string, DocVersionOption[]>; collectionSlug: string; activePath: string }) {
// Check if anything inside is active to auto-expand
const isInsideActive = ADVANCED_GROUP.some(k => grouped[k]?.some(v => activePath === `/docs/${collectionSlug}/${v.slug}`))
function CategorySection({
title,
versions,
collectionSlug,
activePath,
}: {
title: string;
versions: DocVersionOption[];
collectionSlug: string;
activePath: string;
}) {
const Icon = ICON_MAP[title] || Book;
const [isExpanded, setIsExpanded] = useState(isInsideActive)
// Auto-expand if active
const isActive = versions.some(
(v) => activePath === `/docs/${collectionSlug}/${v.slug}`,
);
useEffect(() => {
if (isInsideActive) setIsExpanded(true)
}, [isInsideActive])
return (
<div className="space-y-1">
<div
className={`flex w-full items-center gap-2.5 px-2 py-1 text-[11px] font-bold uppercase tracking-tight ${isActive ? "text-primary" : "text-text-muted/80"}`}
>
<Icon
className={`h-3.5 w-3.5 ${isActive ? "text-primary" : "text-text-subtle"}`}
/>
<span>{humanize(title)}</span>
</div>
<ul className="ml-3.5 space-y-1 border-l border-surface-border pl-4">
{versions.map((v) => (
<SidebarLink
key={v.slug}
version={v}
collectionSlug={collectionSlug}
activePath={activePath}
/>
))}
</ul>
</div>
);
}
return (
<div className="space-y-2 rounded-lg bg-surface-muted/30 p-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className={`flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-xs font-bold transition-all ${isExpanded ? 'text-primary bg-primary/5' : 'text-text-muted hover:text-primary hover:bg-primary/5'}`}
>
<GraduationCap className={`h-4 w-4 ${isExpanded ? 'text-primary' : 'text-text-subtle'}`} />
<span className="uppercase tracking-wide">Advanced</span>
<ChevronDown className={`ml-auto h-3.5 w-3.5 transition-transform duration-300 ${isExpanded ? '' : '-rotate-90'}`} />
</button>
function AdvancedSection({
grouped,
collectionSlug,
activePath,
}: {
grouped: Record<string, DocVersionOption[]>;
collectionSlug: string;
activePath: string;
}) {
// Check if anything inside is active to auto-expand
const isInsideActive = ADVANCED_GROUP.some((k) =>
grouped[k]?.some((v) => activePath === `/docs/${collectionSlug}/${v.slug}`),
);
{isExpanded && (
<div className="space-y-5 animate-in fade-in slide-in-from-top-1 duration-300">
{ADVANCED_GROUP.map(k => grouped[k] && (
<div key={k} className="space-y-2 py-1">
<div className="flex items-center gap-2 px-3 text-[10px] font-bold uppercase tracking-widest text-text-subtle/50">
<span className="h-[1px] w-2 bg-surface-border"></span>
{humanize(k)}
</div>
<ul className="ml-4 space-y-1 border-l border-surface-border/50 pl-4">
{grouped[k].map(v => (
<SidebarLink key={v.slug} version={v} collectionSlug={collectionSlug} activePath={activePath} />
))}
</ul>
</div>
const [isExpanded, setIsExpanded] = useState(isInsideActive);
useEffect(() => {
if (isInsideActive) setIsExpanded(true);
}, [isInsideActive]);
return (
<div className="space-y-2 rounded-[12px] border border-surface-border/60 bg-surface-muted/45 p-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className={`flex w-full items-center gap-2.5 rounded-[10px] px-3 py-2 text-xs font-bold transition-all ${isExpanded ? "bg-primary/10 text-primary" : "text-text-muted hover:bg-primary/5 hover:text-primary"}`}
>
<GraduationCap
className={`h-4 w-4 ${isExpanded ? "text-primary" : "text-text-subtle"}`}
/>
<span className="uppercase tracking-wide">Advanced</span>
<ChevronDown
className={`ml-auto h-3.5 w-3.5 transition-transform duration-300 ${isExpanded ? "" : "-rotate-90"}`}
/>
</button>
{isExpanded && (
<div className="space-y-5 animate-in fade-in slide-in-from-top-1 duration-300">
{ADVANCED_GROUP.map(
(k) =>
grouped[k] && (
<div key={k} className="space-y-2 py-1">
<div className="flex items-center gap-2 px-3 text-[10px] font-bold uppercase tracking-widest text-text-subtle/50">
<span className="h-[1px] w-2 bg-surface-border"></span>
{humanize(k)}
</div>
<ul className="ml-4 space-y-1 border-l border-surface-border/50 pl-4">
{grouped[k].map((v) => (
<SidebarLink
key={v.slug}
version={v}
collectionSlug={collectionSlug}
activePath={activePath}
/>
))}
</ul>
</div>
)}
),
)}
</div>
)
)}
</div>
);
}
function SidebarLink({ version, collectionSlug, activePath }: { version: DocVersionOption; collectionSlug: string; activePath: string }) {
const href = `/docs/${collectionSlug}/${version.slug}`
const isPageActive = activePath === href
function SidebarLink({
version,
collectionSlug,
activePath,
}: {
version: DocVersionOption;
collectionSlug: string;
activePath: string;
}) {
const href = `/docs/${collectionSlug}/${version.slug}`;
const isPageActive = activePath === href;
return (
<li>
<Link
href={href}
className={`group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-all duration-200 ${isPageActive
? 'bg-primary/10 text-primary font-medium shadow-sm'
: 'text-text-muted hover:text-heading hover:bg-surface-muted'
}`}
>
{isPageActive && <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
<span className="truncate">{version.title}</span>
{!isPageActive && <ChevronRight className="ml-auto h-3 w-3 opacity-0 transition-all -translate-x-2 group-hover:opacity-30 group-hover:translate-x-0" />}
</Link>
</li>
)
return (
<li>
<Link
href={href}
className={`group flex items-center gap-2 rounded-[10px] px-3 py-2 text-sm transition-all duration-200 ${
isPageActive
? "bg-primary/10 text-primary font-medium shadow-[var(--shadow-sm)]"
: "text-text-muted hover:bg-surface-muted hover:text-text"
}`}
>
{isPageActive && (
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
)}
<span className="truncate">{version.title}</span>
{!isPageActive && (
<ChevronRight className="ml-auto h-3 w-3 opacity-0 transition-all -translate-x-2 group-hover:opacity-30 group-hover:translate-x-0" />
)}
</Link>
</li>
);
}

View File

@ -7,7 +7,7 @@ export default function Feedback() {
const [voted, setVoted] = useState<"yes" | "no" | null>(null);
return (
<section className="rounded-[1.6rem] border border-slate-900/10 bg-[#fcfbf8] p-5 shadow-[0_14px_30px_rgba(15,23,42,0.04)]">
<section className="rounded-[0.95rem] border border-slate-900/8 bg-white/82 p-5 shadow-[var(--shadow-soft)]">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-text-subtle">
@ -22,14 +22,14 @@ export default function Feedback() {
<div className="flex gap-3">
<button
onClick={() => setVoted("yes")}
className="inline-flex items-center gap-2 rounded-full border border-slate-900/10 bg-white px-4 py-2 text-sm font-semibold text-slate-800 transition hover:border-primary/20 hover:text-primary"
className="tactile-button tactile-button-soft px-4 text-sm text-slate-800"
>
<ThumbsUp className="h-4 w-4" />
Yes
</button>
<button
onClick={() => setVoted("no")}
className="inline-flex items-center gap-2 rounded-full border border-slate-900/10 bg-white px-4 py-2 text-sm font-semibold text-slate-800 transition hover:border-danger/20 hover:text-danger"
className="tactile-button tactile-button-soft px-4 text-sm text-slate-800 hover:text-danger"
>
<ThumbsDown className="h-4 w-4" />
No

View File

@ -28,10 +28,10 @@ function DocsBreadcrumbs({
) : null}
<Link
href={item.href}
className={`rounded-full border px-3 py-1.5 transition ${
className={`rounded-[12px] border px-3 py-1.5 transition ${
index === items.length - 1
? "border-slate-900/10 bg-[#f8f4ec] font-medium text-slate-900"
: "border-slate-900/10 bg-white text-slate-600 hover:text-primary"
? "border-slate-900/8 bg-white/84 font-medium text-slate-900"
: "border-slate-900/8 bg-white text-slate-600 hover:text-primary"
}`}
>
{item.label}
@ -96,9 +96,9 @@ export default async function DocVersionPage({
];
return (
<div className="flex gap-8 xl:gap-10">
<div className="flex gap-6 xl:gap-8">
<article className="min-w-0 flex-1 space-y-6">
<section className="rounded-[2rem] border border-slate-900/10 bg-[linear-gradient(180deg,#ffffff,#faf7f2)] p-6 shadow-[0_20px_48px_rgba(15,23,42,0.05)] lg:p-7">
<section className="rounded-[1rem] border border-slate-900/8 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(247,248,250,0.98))] p-5 shadow-[var(--shadow-soft)] lg:p-6">
<DocsBreadcrumbs items={breadcrumbs} />
<PublicPageIntro
eyebrow="Documentation"
@ -108,7 +108,7 @@ export default async function DocVersionPage({
/>
</section>
<section className="rounded-[2rem] border border-slate-900/10 bg-white/92 p-6 shadow-[0_18px_40px_rgba(15,23,42,0.05)] lg:p-8">
<section className="rounded-[1rem] border border-slate-900/8 bg-white/90 p-5 shadow-[var(--shadow-soft)] lg:p-6">
<DocArticle content={version.content} />
</section>
@ -117,7 +117,7 @@ export default async function DocVersionPage({
<aside className="hidden w-64 shrink-0 lg:block xl:w-72">
<div className="sticky top-[100px]">
<div className="rounded-[1.6rem] border border-slate-900/10 bg-white/90 p-5 shadow-[0_18px_40px_rgba(15,23,42,0.05)]">
<div className="rounded-[0.95rem] border border-slate-900/8 bg-white/88 p-5 shadow-[var(--shadow-soft)]">
<p className="mb-4 text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-text-subtle">
Metadata
</p>

View File

@ -13,9 +13,9 @@ export default async function DocsLayout({
return (
<div className="flex min-h-screen flex-col bg-background text-text">
<UnifiedNavigation />
<div className="mx-auto flex w-full max-w-[1536px] items-start">
<div className="flex w-full flex-1 items-start px-2 pb-8 pt-3 sm:px-3 lg:px-4">
<DocsSidebar collections={collections} />
<main className="min-h-[calc(100vh-64px)] flex-1 overflow-x-hidden py-8 px-4 sm:px-8 lg:px-10">
<main className="min-h-[calc(100vh-64px)] flex-1 overflow-x-hidden px-3 py-3 sm:px-5 lg:px-6">
{children}
</main>
</div>

View File

@ -30,8 +30,8 @@ export default async function DocsHome() {
);
return (
<div className="space-y-8">
<section className="rounded-[2.2rem] border border-slate-900/10 bg-[linear-gradient(180deg,#ffffff,#faf7f2)] p-6 shadow-[0_22px_50px_rgba(15,23,42,0.05)] lg:p-8">
<div className="space-y-4">
<section className="rounded-[1rem] border border-slate-900/8 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(247,248,250,0.98))] p-5 shadow-[var(--shadow-soft)] lg:p-6">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-end">
<PublicPageIntro
eyebrow="Documentation"
@ -43,12 +43,12 @@ export default async function DocsHome() {
titleClassName="editorial-display text-[2.8rem] tracking-[-0.06em] sm:text-[3.4rem]"
/>
<div className="grid gap-3 rounded-[1.75rem] border border-slate-900/10 bg-white/85 p-5">
<div className="grid gap-3 rounded-[0.95rem] border border-slate-900/8 bg-white/84 p-4 shadow-[var(--shadow-soft)]">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-text-subtle">
Library snapshot
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
<div className="rounded-[1.25rem] border border-slate-900/10 bg-[#fcfbf8] p-4">
<div className="rounded-[0.9rem] border border-slate-900/8 bg-white/80 p-4">
<div className="flex items-center gap-2 text-slate-900">
<BookCopy className="h-4 w-4 text-primary" aria-hidden />
<span className="text-sm font-semibold">Collections</span>
@ -57,7 +57,7 @@ export default async function DocsHome() {
{collections.length}
</p>
</div>
<div className="rounded-[1.25rem] border border-slate-900/10 bg-[#fcfbf8] p-4">
<div className="rounded-[0.9rem] border border-slate-900/8 bg-white/80 p-4">
<div className="flex items-center gap-2 text-slate-900">
<Files className="h-4 w-4 text-primary" aria-hidden />
<span className="text-sm font-semibold">Articles</span>
@ -72,7 +72,7 @@ export default async function DocsHome() {
</section>
{collections.length > 0 ? (
<section className="rounded-[2rem] border border-slate-900/10 bg-white/90 p-5 shadow-[0_18px_40px_rgba(15,23,42,0.05)] lg:p-7">
<section className="rounded-[1rem] border border-slate-900/8 bg-white/88 p-4 shadow-[var(--shadow-soft)] lg:p-5">
<div className="mb-5 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-text-subtle">
@ -83,7 +83,7 @@ export default async function DocsHome() {
rest of the public site.
</p>
</div>
<span className="inline-flex w-fit rounded-full border border-slate-900/10 bg-[#f8f4ec] px-3 py-1 text-xs font-semibold text-slate-700">
<span className="inline-flex w-fit rounded-[12px] border border-slate-900/8 bg-white/82 px-3 py-1.5 text-xs font-semibold text-slate-700">
{collections.length} collections
</span>
</div>
@ -93,7 +93,7 @@ export default async function DocsHome() {
<Link
key={collection.slug}
href={`/docs/${collection.slug}/${collection.defaultVersionSlug}`}
className="group rounded-[1.5rem] border border-slate-900/10 bg-[#fcfbf8] p-4 transition duration-200 hover:-translate-y-[1px] hover:bg-white"
className="group rounded-[0.9rem] border border-slate-900/8 bg-white/82 p-4 transition duration-200 hover:-translate-y-[1px] hover:bg-white"
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
@ -108,13 +108,13 @@ export default async function DocsHome() {
</div>
<div className="mt-4 flex flex-wrap gap-2">
<span className="rounded-full border border-slate-900/10 bg-white px-3 py-1 text-xs font-semibold text-slate-600">
<span className="rounded-[12px] border border-slate-900/8 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600">
{collection.versions.length} articles
</span>
{collection.tags.slice(0, 2).map((tag) => (
<span
key={tag}
className="rounded-full border border-slate-900/10 bg-white px-3 py-1 text-xs font-medium text-slate-500"
className="rounded-[12px] border border-slate-900/8 bg-white px-3 py-1.5 text-xs font-medium text-slate-500"
>
{tag}
</span>
@ -126,7 +126,7 @@ export default async function DocsHome() {
</section>
) : null}
<section className="rounded-[2rem] border border-slate-900/10 bg-white/92 p-6 shadow-[0_18px_40px_rgba(15,23,42,0.05)] lg:p-8">
<section className="rounded-[1rem] border border-slate-900/8 bg-white/90 p-5 shadow-[var(--shadow-soft)] lg:p-6">
<div className="mb-5 border-b border-slate-900/10 pb-4">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-text-subtle">
Overview
@ -140,7 +140,7 @@ export default async function DocsHome() {
console.error("Failed to load docs index:", error);
return (
<div className="rounded-[2rem] border border-dashed border-slate-900/12 bg-white/80 p-8 text-center shadow-[0_18px_40px_rgba(15,23,42,0.04)]">
<div className="rounded-[1rem] border border-dashed border-slate-900/12 bg-white/82 p-8 text-center shadow-[var(--shadow-soft)]">
<h3 className="text-xl font-semibold tracking-[-0.03em] text-heading">
No Documentation Found
</h3>

View File

@ -1,147 +1,181 @@
'use client'
"use client";
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { ChevronLeft, ChevronRight, Menu } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useUserStore } from '@lib/userStore'
import type { UserRole } from '@lib/userStore'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { useUserStore } from "@lib/userStore";
import type { UserRole } from "@lib/userStore";
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
const ROLE_BADGES: Record<UserRole, { label: string; className: string }> = {
guest: {
label: 'Guest',
className: 'bg-[var(--color-badge-muted)] text-[var(--color-text-subtle)]',
label: "Guest",
className: "bg-[var(--color-badge-muted)] text-[var(--color-text-subtle)]",
},
user: {
label: 'User',
className: 'bg-[var(--color-accent-muted)] text-[var(--color-accent-foreground)]',
label: "User",
className:
"bg-[var(--color-accent-muted)] text-[var(--color-accent-foreground)]",
},
operator: {
label: 'Operator',
className: 'bg-[var(--color-success-muted)] text-[var(--color-success-foreground)]',
label: "Operator",
className:
"bg-[var(--color-success-muted)] text-[var(--color-success-foreground)]",
},
admin: {
label: 'Admin',
className: 'bg-[var(--color-primary-muted)] text-[var(--color-primary)]',
label: "Admin",
className: "bg-[var(--color-primary-muted)] text-[var(--color-primary)]",
},
}
};
interface HeaderProps {
onMenu: () => void
onCollapse?: () => void
isCollapsed?: boolean
onMenu: () => void;
onCollapse?: () => void;
isCollapsed?: boolean;
}
function resolveAccountInitial(input?: string | null) {
if (!input) {
return '?'
return "?";
}
const normalized = input.trim()
const normalized = input.trim();
if (!normalized) {
return '?'
return "?";
}
return normalized.charAt(0).toUpperCase()
return normalized.charAt(0).toUpperCase();
}
export default function Header({ onMenu, onCollapse, isCollapsed }: HeaderProps) {
const { language } = useLanguage()
const router = useRouter()
const user = useUserStore((state) => state.user)
const isLoading = useUserStore((state) => state.isLoading)
const role: UserRole = user?.role ?? 'guest'
const badge = ROLE_BADGES[role]
const accountLabel = user?.name ?? user?.username ?? user?.email ?? 'Guest user'
const accountInitial = resolveAccountInitial(accountLabel)
const statusBadge = isLoading ? 'Syncing' : badge.label
export default function Header({
onMenu,
onCollapse,
isCollapsed,
}: HeaderProps) {
const { language } = useLanguage();
const router = useRouter();
const user = useUserStore((state) => state.user);
const isLoading = useUserStore((state) => state.isLoading);
const role: UserRole = user?.role ?? "guest";
const badge = ROLE_BADGES[role];
const accountLabel =
user?.name ?? user?.username ?? user?.email ?? "Guest user";
const accountInitial = resolveAccountInitial(accountLabel);
const statusBadge = isLoading ? "Syncing" : badge.label;
const badgeClasses = isLoading
? 'bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] opacity-70'
: badge.className
? "bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] opacity-70"
: badge.className;
const isRoot = useMemo(() => {
const email = user?.email?.trim().toLowerCase() ?? ''
return email === 'admin@svc.plus' && role === 'admin'
}, [role, user?.email])
const email = user?.email?.trim().toLowerCase() ?? "";
return email === "admin@svc.plus" && role === "admin";
}, [role, user?.email]);
const [assumeStatus, setAssumeStatus] = useState<{ isAssuming: boolean; target?: string }>({
const [assumeStatus, setAssumeStatus] = useState<{
isAssuming: boolean;
target?: string;
}>({
isAssuming: false,
})
const [assumeBusy, setAssumeBusy] = useState(false)
});
const [assumeBusy, setAssumeBusy] = useState(false);
useEffect(() => {
let cancelled = false
let cancelled = false;
void (async () => {
try {
const res = await fetch('/api/sandbox/assume/status', { method: 'GET', cache: 'no-store' })
const payload = (await res.json().catch(() => null)) as any
if (cancelled) return
const res = await fetch("/api/sandbox/assume/status", {
method: "GET",
cache: "no-store",
});
const payload = (await res.json().catch(() => null)) as any;
if (cancelled) return;
setAssumeStatus({
isAssuming: Boolean(payload?.isAssuming),
target: typeof payload?.target === 'string' ? payload.target : undefined,
})
target:
typeof payload?.target === "string" ? payload.target : undefined,
});
} catch {
if (cancelled) return
setAssumeStatus({ isAssuming: false })
if (cancelled) return;
setAssumeStatus({ isAssuming: false });
}
})()
})();
return () => {
cancelled = true
}
}, [])
cancelled = true;
};
}, []);
const handleAssumeSandbox = async () => {
if (!isRoot || assumeBusy) return
if (!isRoot || assumeBusy) return;
try {
setAssumeBusy(true)
const res = await fetch('/api/sandbox/assume', { method: 'POST', cache: 'no-store', credentials: 'include' })
setAssumeBusy(true);
const res = await fetch("/api/sandbox/assume", {
method: "POST",
cache: "no-store",
credentials: "include",
});
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as any
throw new Error((payload && (payload.message || payload.error)) || `Assume failed (${res.status})`)
const payload = (await res.json().catch(() => null)) as any;
throw new Error(
(payload && (payload.message || payload.error)) ||
`Assume failed (${res.status})`,
);
}
router.refresh()
router.refresh();
// Ensure server-rendered parts reflect the new cookie immediately.
window.location.reload()
window.location.reload();
} finally {
setAssumeBusy(false)
setAssumeBusy(false);
}
}
};
const handleRevertAssume = async () => {
if (assumeBusy) return
if (assumeBusy) return;
try {
setAssumeBusy(true)
const res = await fetch('/api/sandbox/assume/revert', { method: 'POST', cache: 'no-store', credentials: 'include' })
setAssumeBusy(true);
const res = await fetch("/api/sandbox/assume/revert", {
method: "POST",
cache: "no-store",
credentials: "include",
});
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as any
throw new Error((payload && (payload.message || payload.error)) || `Revert failed (${res.status})`)
const payload = (await res.json().catch(() => null)) as any;
throw new Error(
(payload && (payload.message || payload.error)) ||
`Revert failed (${res.status})`,
);
}
router.refresh()
window.location.reload()
router.refresh();
window.location.reload();
} finally {
setAssumeBusy(false)
setAssumeBusy(false);
}
}
};
return (
<header className="sticky top-0 z-30 border-b border-[color:var(--color-surface-border)] bg-[var(--color-surface-translucent)] text-[var(--color-text)] shadow-[var(--shadow-sm)] backdrop-blur transition-colors">
<header className="sticky top-0 z-30 overflow-hidden border-b border-[color:var(--color-surface-border)] bg-white/80 text-[var(--color-text)] shadow-[var(--shadow-soft)] backdrop-blur-xl transition-colors">
{assumeStatus.isAssuming ? (
<div className="flex items-center justify-between gap-3 px-4 py-2 text-xs md:px-6">
<div className="rounded-full border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] px-3 py-1 text-[var(--color-warning-foreground)]">
{language === 'zh'
? `当前处于 Assume: ${assumeStatus.target || 'sandbox@svc.plus'}(只读视角)`
: `Assuming: ${assumeStatus.target || 'sandbox@svc.plus'} (read-only view)`}
<div className="rounded-[12px] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] px-3 py-1.5 text-[var(--color-warning-foreground)]">
{language === "zh"
? `当前处于 Assume: ${assumeStatus.target || "sandbox@svc.plus"}(只读视角)`
: `Assuming: ${assumeStatus.target || "sandbox@svc.plus"} (read-only view)`}
</div>
<button
type="button"
onClick={() => void handleRevertAssume()}
disabled={assumeBusy}
className="rounded-full border border-[color:var(--color-warning-muted)] px-3 py-1 text-[var(--color-warning-foreground)] transition-colors hover:bg-[var(--color-warning-muted)] disabled:opacity-60"
className="tactile-button tactile-button-subtle min-h-9 px-3 text-[var(--color-warning-foreground)] disabled:opacity-60"
>
{assumeBusy ? (language === 'zh' ? '处理中…' : 'Working…') : language === 'zh' ? '退出 Sandbox' : 'Exit Sandbox'}
{assumeBusy
? language === "zh"
? "处理中…"
: "Working…"
: language === "zh"
? "退出 Sandbox"
: "Exit Sandbox"}
</button>
</div>
) : (
@ -151,57 +185,80 @@ export default function Header({ onMenu, onCollapse, isCollapsed }: HeaderProps)
type="button"
onClick={() => void handleAssumeSandbox()}
disabled={assumeBusy || isLoading}
className="rounded-full border border-[color:var(--color-primary-border)] px-3 py-1 text-[var(--color-primary)] transition-colors hover:bg-[var(--color-primary-muted)] disabled:opacity-60"
className="tactile-button tactile-button-soft min-h-9 border border-[color:var(--color-primary-border)] px-3 text-[var(--color-primary)] disabled:opacity-60"
>
{assumeBusy ? (language === 'zh' ? '处理中…' : 'Working…') : language === 'zh' ? '切换到 Sandbox' : 'Assume Sandbox'}
{assumeBusy
? language === "zh"
? "处理中…"
: "Working…"
: language === "zh"
? "切换到 Sandbox"
: "Assume Sandbox"}
</button>
) : null}
</div>
)}
<div className="flex items-center justify-between px-4 py-3 md:px-6">
<div className="flex items-center gap-4">
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] px-3 py-2 text-sm font-medium text-[var(--color-text-subtle)] transition-colors hover:border-[color:var(--color-primary-border)] hover:text-[var(--color-primary)] md:hidden"
onClick={onMenu}
aria-label="Toggle navigation menu"
>
<Menu className="h-4 w-4" />
Menu
</button>
{onCollapse && (
<div className="flex items-center gap-4">
<button
type="button"
className="hidden items-center justify-center rounded-lg border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] p-2 text-[var(--color-text-subtle)] transition-colors hover:border-[color:var(--color-primary-border)] hover:text-[var(--color-primary)] md:flex"
onClick={onCollapse}
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className="tactile-button tactile-button-soft gap-2 px-3 text-sm font-medium text-[var(--color-text-subtle)] md:hidden"
onClick={onMenu}
aria-label="Toggle navigation menu"
>
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
<Menu className="h-4 w-4" />
Menu
</button>
)}
</div>
<div className="flex flex-1 items-center justify-end gap-4 md:justify-end">
<div className="flex items-center gap-3">
<Link
href="/"
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] px-3 py-2 text-sm font-medium text-[var(--color-text-subtle)] transition-colors hover:border-[color:var(--color-primary-border)] hover:text-[var(--color-primary)]"
>
</Link>
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${badgeClasses}`}>{statusBadge}</span>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-[var(--gradient-primary-from)] to-[var(--gradient-primary-to)] text-sm font-semibold text-[var(--color-primary-foreground)] shadow-[var(--shadow-sm)] transition-colors">
{isLoading ? <span className="animate-pulse"></span> : accountInitial}
</div>
<div className="hidden flex-col text-right text-xs text-[var(--color-text-subtle)] transition-colors sm:flex">
<span className="text-sm font-semibold text-[var(--color-text)]">{accountLabel}</span>
<span>{user?.email ?? (isLoading ? 'Checking session…' : 'Not signed in')}</span>
{onCollapse && (
<button
type="button"
className="tactile-button tactile-button-soft hidden h-10 w-10 items-center justify-center p-0 text-[var(--color-text-subtle)] md:flex"
onClick={onCollapse}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</button>
)}
</div>
<div className="flex flex-1 items-center justify-end gap-4 md:justify-end">
<div className="flex items-center gap-3">
<Link
href="/"
className="tactile-button tactile-button-soft gap-2 px-3 text-sm font-medium text-[var(--color-text-subtle)]"
>
</Link>
<span
className={`rounded-[12px] px-3 py-1.5 text-xs font-semibold ${badgeClasses}`}
>
{statusBadge}
</span>
<div className="flex h-10 w-10 items-center justify-center rounded-[12px] bg-gradient-to-br from-[var(--gradient-primary-from)] to-[var(--gradient-primary-to)] text-sm font-semibold text-[var(--color-primary-foreground)] shadow-[var(--shadow-soft)] transition-colors">
{isLoading ? (
<span className="animate-pulse"></span>
) : (
accountInitial
)}
</div>
<div className="hidden flex-col text-right text-xs text-[var(--color-text-subtle)] transition-colors sm:flex">
<span className="text-sm font-semibold text-[var(--color-text)]">
{accountLabel}
</span>
<span>
{user?.email ??
(isLoading ? "Checking session…" : "Not signed in")}
</span>
</div>
</div>
</div>
</div>
</div>
</header>
)
);
}

View File

@ -1,241 +1,302 @@
'use client'
"use client";
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useMemo, type ComponentType } from 'react'
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, type ComponentType } from "react";
import { Plus, type LucideIcon } from 'lucide-react'
import { Plus, type LucideIcon } from "lucide-react";
import { getExtensionRegistry } from '@extensions/loader'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { resolveAccess } from '@lib/accessControl'
import { useUserStore } from '@lib/userStore'
import { SidebarHeader, SidebarContent, SidebarFooter } from '../../../components/layout/SidebarRoot'
import { getExtensionRegistry } from "@extensions/loader";
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
import { resolveAccess } from "@lib/accessControl";
import { useUserStore } from "@lib/userStore";
import {
SidebarHeader,
SidebarContent,
SidebarFooter,
} from "../../../components/layout/SidebarRoot";
const registry = getExtensionRegistry()
const PlaceholderIcon: ComponentType<{ className?: string }> = () => null
const registry = getExtensionRegistry();
const PlaceholderIcon: ComponentType<{ className?: string }> = () => null;
interface NavItem {
id?: string
href: string
label: string
description: string
Icon: ComponentType<{ className?: string }> | LucideIcon
disabled: boolean
id?: string;
href: string;
label: string;
description: string;
Icon: ComponentType<{ className?: string }> | LucideIcon;
disabled: boolean;
}
interface NavSection {
id: string
title: string
items: NavItem[]
id: string;
title: string;
items: NavItem[];
}
function isActive(pathname: string, href: string) {
if (href === '/panel') {
return pathname === '/panel'
}
return pathname.startsWith(href)
if (href === "/panel") {
return pathname === "/panel";
}
return pathname.startsWith(href);
}
export interface PanelSidebarContentProps {
onNavigate?: () => void
collapsed?: boolean
onNavigate?: () => void;
collapsed?: boolean;
}
export function PanelSidebarContent({ onNavigate, collapsed = false }: PanelSidebarContentProps) {
const pathname = usePathname()
const { language } = useLanguage()
const copy = translations[language].userCenter.mfa
const user = useUserStore((state) => state.user)
const requiresSetup = Boolean(user && !user.isReadOnly && (!user.mfaEnabled || user.mfaPending))
export function PanelSidebarContent({
onNavigate,
collapsed = false,
}: PanelSidebarContentProps) {
const pathname = usePathname();
const { language } = useLanguage();
const copy = translations[language].userCenter.mfa;
const user = useUserStore((state) => state.user);
const requiresSetup = Boolean(
user && !user.isReadOnly && (!user.mfaEnabled || user.mfaPending),
);
const navSections = useMemo<NavSection[]>(() => {
return registry.sidebar
.map((section) => {
const items = section.items
.map((item) => {
const { route } = item
const guardResult = route.guard ? resolveAccess(user, route.guard) : { allowed: true }
const requiresRole = Boolean(route.guard?.roles?.length)
if (requiresRole && !guardResult.allowed) {
return null
}
const navSections = useMemo<NavSection[]>(() => {
return registry.sidebar
.map((section) => {
const items = section.items
.map((item) => {
const { route } = item;
const guardResult = route.guard
? resolveAccess(user, route.guard)
: { allowed: true };
const requiresRole = Boolean(route.guard?.roles?.length);
if (requiresRole && !guardResult.allowed) {
return null;
}
const disabledByGuard = !requiresRole && !guardResult.allowed
const disabled =
item.disabled ||
disabledByGuard ||
(requiresSetup && route.path !== '/panel/account')
const disabledByGuard = !requiresRole && !guardResult.allowed;
const disabled =
item.disabled ||
disabledByGuard ||
(requiresSetup && route.path !== "/panel/account");
const Icon = route.icon ?? PlaceholderIcon
const Icon = route.icon ?? PlaceholderIcon;
return {
id: route.id,
href: route.path,
label: route.label,
description: route.description ?? '',
Icon,
disabled,
}
})
.filter((value) => Boolean(value)) as NavItem[]
return {
id: route.id,
href: route.path,
label: route.label,
description: route.description ?? "",
Icon,
disabled,
};
})
.filter((value) => Boolean(value)) as NavItem[];
if (items.length === 0) {
return null
}
if (items.length === 0) {
return null;
}
return {
id: section.id,
title: section.title,
items,
}
})
.filter((value) => Boolean(value)) as NavSection[]
}, [requiresSetup, user])
return {
id: section.id,
title: section.title,
items,
};
})
.filter((value) => Boolean(value)) as NavSection[];
}, [requiresSetup, user]);
return (
<>
<SidebarHeader className={`space-y-1 text-[var(--color-text)] transition-all duration-300 mb-6 ${collapsed ? 'text-center' : 'text-left'}`}>
<h2 className={`text-lg font-bold text-[var(--color-heading)] truncate transition-opacity duration-300 ${collapsed ? 'opacity-0 h-0 invisible' : 'opacity-100'}`}>
{translations[language].userCenter.overview.heading}
</h2>
<p className={`text-sm text-[var(--color-text-subtle)] truncate transition-opacity duration-300 ${collapsed ? 'opacity-0 h-0 invisible' : 'opacity-100'}`}>
{language === 'zh' ? '在同一处掌控权限与功能特性。' : 'Manage permissions and features in one place.'}
</p>
return (
<>
<SidebarHeader
className={`space-y-1 text-[var(--color-text)] transition-all duration-300 mb-6 ${collapsed ? "text-center" : "text-left"}`}
>
<h2
className={`text-lg font-bold text-[var(--color-heading)] truncate transition-opacity duration-300 ${collapsed ? "opacity-0 h-0 invisible" : "opacity-100"}`}
>
{translations[language].userCenter.overview.heading}
</h2>
<p
className={`text-sm text-[var(--color-text-subtle)] truncate transition-opacity duration-300 ${collapsed ? "opacity-0 h-0 invisible" : "opacity-100"}`}
>
{language === "zh"
? "在同一处掌控权限与功能特性。"
: "Manage permissions and features in one place."}
</p>
{requiresSetup ? (
<div className="mt-4 rounded-[var(--radius-lg)] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] p-3 text-xs text-[var(--color-warning-foreground)] transition-colors">
<p className="font-semibold">{copy.pendingHint}</p>
<p className="mt-1">{copy.lockedMessage}</p>
<div className="mt-2 flex flex-wrap gap-2">
<Link
href="/panel/account?setupMfa=1"
onClick={onNavigate}
className="inline-flex items-center justify-center rounded-md bg-[var(--color-primary)] px-3 py-1.5 text-xs font-medium text-[var(--color-primary-foreground)] shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--color-primary-hover)]"
>
{copy.actions.setup}
</Link>
<a
href={copy.actions.docsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md border border-[color:var(--color-primary-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-primary)] transition-colors hover:border-[color:var(--color-primary)] hover:bg-[var(--color-primary-muted)]"
>
{copy.actions.docs}
</a>
</div>
{requiresSetup ? (
<div className="mt-4 rounded-[14px] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)]/92 p-3 text-xs text-[var(--color-warning-foreground)] shadow-[var(--shadow-soft)] transition-colors">
<p className="font-semibold">{copy.pendingHint}</p>
<p className="mt-1">{copy.lockedMessage}</p>
<div className="mt-2 flex flex-wrap gap-2">
<Link
href="/panel/account?setupMfa=1"
onClick={onNavigate}
className="tactile-button tactile-button-primary min-h-9 px-3 text-xs"
>
{copy.actions.setup}
</Link>
<a
href={copy.actions.docsUrl}
target="_blank"
rel="noreferrer"
className="tactile-button tactile-button-soft min-h-9 border border-[color:var(--color-primary-border)] px-3 text-xs text-[var(--color-primary)]"
>
{copy.actions.docs}
</a>
</div>
</div>
) : null}
</SidebarHeader>
<SidebarContent className="flex flex-col gap-6">
{navSections.map((section) => {
const sectionDisabled = section.items.every((item) => item.disabled);
return (
<div key={section.id} className="space-y-3">
<p
className={`text-xs font-semibold uppercase tracking-wide transition-all duration-300 ${
sectionDisabled
? "text-[var(--color-text-subtle)] opacity-60"
: "text-[var(--color-text-subtle)]"
} ${collapsed ? "text-center scale-0 h-0 opacity-0 invisible" : "text-left"}`}
>
{translations[language].userCenter.sections[
section.id as keyof typeof translations.en.userCenter.sections
] || section.title}
</p>
<div
className={`space-y-2 ${sectionDisabled ? "opacity-60" : ""}`}
>
{section.items.map((item) => {
const active = isActive(pathname, item.href);
const isDashboard = item.href === "/panel";
const { Icon } = item;
const baseClasses = [
"group flex items-center gap-3 rounded-[14px] border px-3 py-3 text-sm transition-all duration-300",
];
if (item.disabled) {
baseClasses.push(
"cursor-not-allowed border-dashed border-[color:var(--color-surface-border)] text-[var(--color-text-subtle)] opacity-60",
);
} else {
baseClasses.push(
"border-transparent text-[var(--color-text-subtle)] hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-primary)]",
);
}
if (active) {
baseClasses.push(
"border-[color:var(--color-primary)] bg-[var(--color-primary-muted)] text-[var(--color-primary)] shadow-[var(--shadow-sm)]",
);
} else if (isDashboard) {
// Dashboard visual priority when not active
baseClasses.push(
"bg-[var(--color-surface-muted)]/45 shadow-[var(--shadow-soft)]",
);
}
const iconClasses = [
"flex h-8 w-8 items-center justify-center rounded-xl transition-colors",
];
if (active) {
iconClasses.push(
"bg-[var(--color-primary)] text-[var(--color-primary-foreground)]",
);
} else if (item.disabled) {
iconClasses.push(
"bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] opacity-60",
);
} else if (isDashboard) {
iconClasses.push(
"bg-[var(--color-primary-muted)] text-[var(--color-primary)]",
);
} else {
iconClasses.push(
"bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] group-hover:bg-[var(--color-primary-muted)] group-hover:text-[var(--color-primary)]",
);
}
const descriptionClasses = [
"text-xs transition-colors",
item.disabled
? "text-[var(--color-text-subtle)] opacity-60"
: "text-[var(--color-text-subtle)] group-hover:text-[var(--color-primary)]",
];
const content = (
<div
className={baseClasses.join(" ")}
title={collapsed ? item.label : undefined}
>
<span className={`${iconClasses.join(" ")} shrink-0`}>
<Icon className="h-4 w-4" />
</span>
<span
className={`flex flex-1 flex-col truncate transition-all duration-300 ${collapsed ? "w-0 opacity-0 invisible overflow-hidden" : "w-auto opacity-100 visible"}`}
>
<span className="font-semibold text-left">
{(item.id &&
translations[language].userCenter.items[
item.id as keyof typeof translations.en.userCenter.items
]) ||
item.label}
</span>
<span
className={`${descriptionClasses.join(" ")} text-left`}
>
{item.description}
</span>
</span>
</div>
) : null}
</SidebarHeader>
<SidebarContent className="flex flex-col gap-6">
{navSections.map((section) => {
const sectionDisabled = section.items.every((item) => item.disabled)
);
if (item.disabled) {
return (
<div key={section.id} className="space-y-3">
<p
className={`text-xs font-semibold uppercase tracking-wide transition-all duration-300 ${sectionDisabled
? 'text-[var(--color-text-subtle)] opacity-60'
: 'text-[var(--color-text-subtle)]'
} ${collapsed ? 'text-center scale-0 h-0 opacity-0 invisible' : 'text-left'}`}
>
{translations[language].userCenter.sections[section.id as keyof typeof translations.en.userCenter.sections] || section.title}
</p>
<div className={`space-y-2 ${sectionDisabled ? 'opacity-60' : ''}`}>
{section.items.map((item) => {
const active = isActive(pathname, item.href)
const isDashboard = item.href === '/panel'
const { Icon } = item
<div
key={item.href}
aria-disabled={true}
className="select-none"
>
{content}
</div>
);
}
const baseClasses = [
'group flex items-center gap-3 rounded-[var(--radius-xl)] border px-3 py-3 text-sm transition-all duration-300',
]
if (item.disabled) {
baseClasses.push(
'cursor-not-allowed border-dashed border-[color:var(--color-surface-border)] text-[var(--color-text-subtle)] opacity-60',
)
} else {
baseClasses.push(
'border-transparent text-[var(--color-text-subtle)] hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-primary)]',
)
}
if (active) {
baseClasses.push(
'border-[color:var(--color-primary)] bg-[var(--color-primary-muted)] text-[var(--color-primary)] shadow-[var(--shadow-sm)]',
)
} else if (isDashboard) {
// Dashboard visual priority when not active
baseClasses.push('shadow-[0_2px_8px_-2px_rgba(0,0,0,0.05)] bg-[var(--color-surface-muted)]/30')
}
const iconClasses = ['flex h-8 w-8 items-center justify-center rounded-xl transition-colors']
if (active) {
iconClasses.push('bg-[var(--color-primary)] text-[var(--color-primary-foreground)]')
} else if (item.disabled) {
iconClasses.push('bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] opacity-60')
} else if (isDashboard) {
iconClasses.push('bg-[var(--color-primary-muted)] text-[var(--color-primary)]')
} else {
iconClasses.push(
'bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] group-hover:bg-[var(--color-primary-muted)] group-hover:text-[var(--color-primary)]',
)
}
const descriptionClasses = [
'text-xs transition-colors',
item.disabled
? 'text-[var(--color-text-subtle)] opacity-60'
: 'text-[var(--color-text-subtle)] group-hover:text-[var(--color-primary)]',
]
const content = (
<div className={baseClasses.join(' ')} title={collapsed ? item.label : undefined}>
<span className={`${iconClasses.join(' ')} shrink-0`}>
<Icon className="h-4 w-4" />
</span>
<span className={`flex flex-1 flex-col truncate transition-all duration-300 ${collapsed ? 'w-0 opacity-0 invisible overflow-hidden' : 'w-auto opacity-100 visible'}`}>
<span className="font-semibold text-left">
{(item.id && translations[language].userCenter.items[item.id as keyof typeof translations.en.userCenter.items]) || item.label}
</span>
<span className={`${descriptionClasses.join(' ')} text-left`}>{item.description}</span>
</span>
</div>
)
if (item.disabled) {
return (
<div key={item.href} aria-disabled={true} className="select-none">
{content}
</div>
)
}
return (
<Link key={item.href} href={item.href} onClick={onNavigate}>
{content}
</Link>
)
})}
</div>
</div>
)
return (
<Link key={item.href} href={item.href} onClick={onNavigate}>
{content}
</Link>
);
})}
</SidebarContent>
</div>
</div>
);
})}
</SidebarContent>
<SidebarFooter className="p-4 border-t border-[color:var(--color-surface-border)]">
<button
className={`group w-full flex items-center justify-center gap-2 py-3 px-4 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-xl font-bold text-sm shadow-[var(--shadow-sm)] hover:opacity-90 transition-all duration-300 ${collapsed ? 'px-0' : ''}`}
title={collapsed ? (language === 'zh' ? '创建项目' : 'New Project') : undefined}
>
<Plus className={`size-5 transition-transform group-hover:rotate-90`} />
<span className={`transition-all duration-300 ${collapsed ? 'w-0 opacity-0 overflow-hidden' : 'w-auto opacity-100'}`}>
{language === 'zh' ? '创建项目' : 'New Project'}
</span>
</button>
</SidebarFooter>
</>
)
<SidebarFooter className="border-t border-[color:var(--color-surface-border)] p-4">
<button
className={`tactile-button tactile-button-primary group w-full gap-2 px-4 text-sm font-bold ${collapsed ? "px-0" : ""}`}
title={
collapsed
? language === "zh"
? "创建项目"
: "New Project"
: undefined
}
>
<Plus
className={`size-5 transition-transform group-hover:rotate-90`}
/>
<span
className={`transition-all duration-300 ${collapsed ? "w-0 opacity-0 overflow-hidden" : "w-auto opacity-100"}`}
>
{language === "zh" ? "创建项目" : "New Project"}
</span>
</button>
</SidebarFooter>
</>
);
}

View File

@ -1,19 +1,26 @@
'use client'
"use client";
import React from 'react'
import { SidebarRoot } from '../../../components/layout/SidebarRoot'
import { PanelSidebarContent, PanelSidebarContentProps } from './PanelSidebarContent'
import React from "react";
import { SidebarRoot } from "../../../components/layout/SidebarRoot";
import {
PanelSidebarContent,
PanelSidebarContentProps,
} from "./PanelSidebarContent";
export interface SidebarProps extends PanelSidebarContentProps {
className?: string
className?: string;
}
export default function Sidebar({ className = '', onNavigate, collapsed = false }: SidebarProps) {
export default function Sidebar({
className = "",
onNavigate,
collapsed = false,
}: SidebarProps) {
return (
<SidebarRoot
className={`transition-all duration-300 ${collapsed ? 'w-20' : 'w-64'} border-r border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] p-4 text-[var(--color-text)] shadow-[var(--shadow-md)] backdrop-blur ${className}`}
className={`transition-all duration-300 ${collapsed ? "w-20" : "w-64"} border-r border-[color:var(--color-surface-border)] bg-white/82 p-4 text-[var(--color-text)] shadow-[var(--shadow-soft)] backdrop-blur ${className}`}
>
<PanelSidebarContent onNavigate={onNavigate} collapsed={collapsed} />
</SidebarRoot>
)
);
}

View File

@ -1,96 +1,104 @@
"use client";
'use client'
import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import Header from "./components/Header";
import Sidebar from "./components/Sidebar";
import { getExtensionRegistry } from "@extensions/loader";
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
import { resolveAccess, type AccessRule } from "@lib/accessControl";
import { useUserStore } from "@lib/userStore";
import Header from './components/Header'
import Sidebar from './components/Sidebar'
import { getExtensionRegistry } from '@extensions/loader'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { resolveAccess, type AccessRule } from '@lib/accessControl'
import { useUserStore } from '@lib/userStore'
const registry = getExtensionRegistry()
const registry = getExtensionRegistry();
type RouteGuard = {
path: string
match: 'exact' | 'startsWith'
path: string;
match: "exact" | "startsWith";
redirect?: {
unauthenticated?: string
forbidden?: string
}
rule: AccessRule
}
unauthenticated?: string;
forbidden?: string;
};
rule: AccessRule;
};
export default function PanelLayout({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(false)
const router = useRouter()
const pathname = usePathname()
const { language } = useLanguage()
const copy = translations[language].userCenter.mfa
const user = useUserStore((state) => state.user)
const isLoading = useUserStore((state) => state.isLoading)
const logout = useUserStore((state) => state.logout)
const requiresSetup = Boolean(user && !user.isReadOnly && (!user.mfaEnabled || user.mfaPending))
export default function PanelLayout({
children,
}: {
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false);
const router = useRouter();
const pathname = usePathname();
const { language } = useLanguage();
const copy = translations[language].userCenter.mfa;
const user = useUserStore((state) => state.user);
const isLoading = useUserStore((state) => state.isLoading);
const logout = useUserStore((state) => state.logout);
const requiresSetup = Boolean(
user && !user.isReadOnly && (!user.mfaEnabled || user.mfaPending),
);
const routeGuards = useMemo<RouteGuard[]>(() => {
return registry.routes
.filter((route) => route.guard)
.map((route) => ({
path: route.path,
match: route.match ?? 'exact',
match: route.match ?? "exact",
redirect: route.redirect,
rule: route.guard!,
}))
.sort((a, b) => b.path.length - a.path.length)
}, [])
.sort((a, b) => b.path.length - a.path.length);
}, []);
useEffect(() => {
if (isLoading) {
return
return;
}
const guard = routeGuards.find((entry) =>
entry.match === 'startsWith' ? pathname.startsWith(entry.path) : pathname === entry.path,
)
entry.match === "startsWith"
? pathname.startsWith(entry.path)
: pathname === entry.path,
);
if (!guard) {
return
return;
}
const decision = resolveAccess(user, guard.rule)
const decision = resolveAccess(user, guard.rule);
if (!decision.allowed) {
const redirect = guard.redirect ?? {}
const redirect = guard.redirect ?? {};
const destination =
decision.reason === 'unauthenticated'
? redirect.unauthenticated ?? '/login'
: redirect.forbidden ?? redirect.unauthenticated ?? '/login'
decision.reason === "unauthenticated"
? (redirect.unauthenticated ?? "/login")
: (redirect.forbidden ?? redirect.unauthenticated ?? "/login");
if (destination && destination !== pathname) {
router.replace(destination)
router.replace(destination);
}
}
}, [isLoading, pathname, routeGuards, router, user])
}, [isLoading, pathname, routeGuards, router, user]);
useEffect(() => {
if (!requiresSetup || pathname.startsWith('/panel/account')) {
return
if (!requiresSetup || pathname.startsWith("/panel/account")) {
return;
}
router.replace('/panel/account?setupMfa=1')
}, [pathname, requiresSetup, router])
router.replace("/panel/account?setupMfa=1");
}, [pathname, requiresSetup, router]);
const handleLogout = async () => {
await logout()
router.replace('/login')
router.refresh()
}
await logout();
router.replace("/login");
router.refresh();
};
return (
<div className="relative flex min-h-screen bg-gradient-to-br from-[var(--gradient-app-from)] via-[var(--gradient-app-via)] to-[var(--gradient-app-to)] text-[var(--color-text)]">
<Sidebar
className={`fixed inset-y-0 left-0 z-40 transform transition-transform duration-200 ease-in-out md:static md:translate-x-0 ${open ? 'translate-x-0' : '-translate-x-full md:translate-x-0'
}`}
className={`fixed inset-y-0 left-0 z-40 transform transition-transform duration-200 ease-in-out md:static md:translate-x-0 ${
open ? "translate-x-0" : "-translate-x-full md:translate-x-0"
}`}
onNavigate={() => setOpen(false)}
collapsed={isCollapsed}
/>
@ -108,15 +116,15 @@ export default function PanelLayout({ children }: { children: React.ReactNode })
onCollapse={() => setIsCollapsed((prev) => !prev)}
isCollapsed={isCollapsed}
/>
<main className="flex flex-1 flex-col space-y-6 bg-[var(--color-surface-translucent)] px-3 py-5 text-[var(--color-text)] transition-colors sm:px-4 md:px-6 lg:px-8">
<main className="flex flex-1 flex-col space-y-4 bg-transparent px-3 py-4 text-[var(--color-text)] transition-colors sm:px-4 md:px-5 lg:px-6">
{requiresSetup ? (
<div className="rounded-[var(--radius-lg)] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] p-4 text-sm text-[var(--color-warning-foreground)] transition-colors">
<div className="rounded-[14px] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)]/90 p-4 text-sm text-[var(--color-warning-foreground)] shadow-[var(--shadow-soft)] transition-colors">
<p className="text-sm">{copy.lockedMessage}</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
<button
type="button"
onClick={() => router.replace('/panel/account?setupMfa=1')}
className="inline-flex items-center justify-center rounded-md bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-[var(--color-primary-foreground)] shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--color-primary-hover)]"
onClick={() => router.replace("/panel/account?setupMfa=1")}
className="tactile-button tactile-button-primary px-3 text-sm"
>
{copy.actions.setup}
</button>
@ -124,28 +132,30 @@ export default function PanelLayout({ children }: { children: React.ReactNode })
href={copy.actions.docsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md border border-[color:var(--color-primary-border)] px-3 py-1.5 text-sm font-medium text-[var(--color-primary)] transition-colors hover:border-[color:var(--color-primary)] hover:bg-[var(--color-primary-muted)]"
className="tactile-button tactile-button-soft border border-[color:var(--color-primary-border)] px-3 text-sm text-[var(--color-primary)]"
>
{copy.actions.docs}
</a>
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center justify-center rounded-md border border-transparent px-3 py-1.5 text-sm font-medium text-[var(--color-warning-foreground)] transition-colors hover:bg-[var(--color-warning-muted)]"
className="tactile-button tactile-button-subtle px-3 text-sm text-[var(--color-warning-foreground)]"
>
{copy.actions.logout}
</button>
{isLoading ? (
<span className="inline-flex items-center rounded-md border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] px-3 py-1.5 text-xs text-[var(--color-warning-foreground)]">
<span className="inline-flex min-h-10 items-center rounded-[12px] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] px-3 py-1.5 text-xs text-[var(--color-warning-foreground)]">
</span>
) : null}
</div>
</div>
) : null}
<div className="flex w-full flex-1 flex-col gap-5 text-[var(--color-text)] transition-colors md:gap-6">{children}</div>
<div className="flex w-full flex-1 flex-col gap-4 rounded-[16px] border border-[color:var(--color-surface-border)] bg-white/72 p-3 text-[var(--color-text)] shadow-[var(--shadow-soft)] backdrop-blur md:gap-5 md:p-4">
{children}
</div>
</main>
</div>
</div>
)
);
}

View File

@ -36,16 +36,16 @@ type AuthLayoutProps = {
};
export const AUTH_INPUT_CLASS =
"w-full rounded-[1.25rem] border border-slate-900/10 bg-[#fcfbf8] px-4 py-3 text-slate-900 shadow-[0_1px_2px_rgba(15,23,42,0.04)] transition focus:border-slate-900/15 focus:outline-none focus:ring-2 focus:ring-primary/15 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400";
"tactile-control w-full rounded-[12px] px-4 py-3 text-slate-900 transition focus:border-slate-900/15 focus:outline-none focus:ring-2 focus:ring-primary/15 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400";
export const AUTH_HINT_PANEL_CLASS =
"rounded-[1.25rem] border border-slate-900/10 bg-[#fcfbf8] px-4 py-3 text-sm leading-6 text-slate-600";
"rounded-[14px] border border-slate-900/8 bg-white/82 px-4 py-3 text-sm leading-6 text-slate-600 shadow-[var(--shadow-soft)]";
export const AUTH_PRIMARY_BUTTON_CLASS =
"inline-flex items-center justify-center rounded-[1.25rem] bg-slate-950 px-4 py-3 text-sm font-semibold text-white transition hover:bg-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-70";
"tactile-button tactile-button-primary inline-flex px-4 py-3 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-70";
export const AUTH_SECONDARY_BUTTON_CLASS =
"inline-flex items-center justify-center rounded-[1.25rem] border border-slate-900/10 bg-white px-4 py-3 text-sm font-semibold text-slate-800 transition hover:border-slate-900/15 hover:bg-[#fcfbf8] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-300 disabled:cursor-not-allowed disabled:opacity-60";
"tactile-button tactile-button-soft inline-flex px-4 py-3 text-sm font-semibold text-slate-800 disabled:cursor-not-allowed disabled:opacity-60";
export const AUTH_TEXT_LINK_CLASS =
"font-semibold text-primary transition hover:text-primary-hover";
@ -54,7 +54,7 @@ export const AUTH_CHECKBOX_CLASS =
"h-4 w-4 rounded border-slate-300 text-primary focus:ring-primary/30";
export const AUTH_CODE_INPUT_CLASS =
"h-12 w-full rounded-[1rem] border border-slate-900/10 bg-[#fcfbf8] text-center text-lg font-semibold text-slate-900 shadow-[0_1px_2px_rgba(15,23,42,0.04)] transition focus:border-slate-900/15 focus:outline-none focus:ring-2 focus:ring-primary/15";
"tactile-control h-12 w-full rounded-[12px] text-center text-lg font-semibold text-slate-900 transition focus:border-slate-900/15 focus:outline-none focus:ring-2 focus:ring-primary/15";
function AuthLayoutTab({
href,
@ -69,10 +69,10 @@ function AuthLayoutTab({
<Link
href={href}
className={clsx(
"flex items-center justify-center rounded-full px-4 py-2 text-sm font-semibold transition",
"flex min-h-10 items-center justify-center rounded-[12px] px-4 py-2 text-sm font-semibold transition",
active
? "bg-white text-slate-900 shadow-sm shadow-slate-900/5"
: "text-slate-500 hover:text-slate-800",
? "bg-white text-slate-900 shadow-[var(--shadow-soft)]"
: "text-slate-500 hover:bg-white/70 hover:text-slate-800",
)}
aria-current={active ? "page" : undefined}
>
@ -101,10 +101,10 @@ function AuthSocialButton({
href={href}
onClick={handleClick}
className={clsx(
"inline-flex items-center justify-center gap-3 rounded-[1.25rem] px-4 py-3 text-sm font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2",
"inline-flex min-h-10 items-center justify-center gap-3 rounded-[12px] px-4 py-3 text-sm font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2",
disabled
? "cursor-not-allowed border border-slate-200 bg-slate-100 text-slate-400 focus-visible:outline-slate-200"
: "border border-slate-900/10 bg-white text-slate-800 hover:border-slate-900/15 hover:bg-[#fcfbf8] focus-visible:outline-slate-300",
: "border border-slate-900/8 bg-white text-slate-800 shadow-[var(--shadow-soft)] hover:bg-[#fcfbf8] focus-visible:outline-slate-300",
)}
aria-disabled={disabled}
tabIndex={disabled ? -1 : undefined}
@ -135,7 +135,7 @@ export function AuthLayout({
<div className="relative flex min-h-screen flex-col overflow-hidden bg-background text-text transition-colors duration-150">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.56),rgba(255,255,255,0))]"
className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.5),rgba(255,255,255,0))]"
/>
<div
aria-hidden
@ -143,7 +143,7 @@ export function AuthLayout({
/>
<main
className="relative flex flex-1 items-center justify-center px-4 py-12 sm:px-6 lg:px-8"
className="relative flex flex-1 items-center justify-center px-4 py-8 sm:px-6 lg:px-8"
data-testid="auth-layout"
>
<div className="w-full max-w-[32rem]">
@ -156,13 +156,13 @@ export function AuthLayout({
Svc.Plus
</p>
</Link>
<span className="rounded-full border border-slate-900/10 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-600">
<span className="rounded-[12px] border border-slate-900/8 bg-white/88 px-3 py-1.5 text-xs font-semibold text-slate-600">
{modeLabel}
</span>
</div>
<div className="overflow-hidden rounded-[2.25rem] border border-slate-900/10 bg-white/94 p-6 shadow-[0_22px_50px_rgba(15,23,42,0.05)] backdrop-blur sm:p-8">
<div className="grid grid-cols-2 gap-2 rounded-full bg-[#f3efe8] p-1">
<div className="overflow-hidden rounded-[1rem] border border-slate-900/8 bg-white/92 p-5 shadow-[var(--shadow-soft)] backdrop-blur sm:p-6">
<div className="grid grid-cols-2 gap-2 rounded-[14px] bg-surface-muted/70 p-1">
<AuthLayoutTab href="/login" active={mode === "login"}>
Sign In
</AuthLayoutTab>
@ -173,7 +173,7 @@ export function AuthLayout({
<div className="mt-6 space-y-6">
{badge ? (
<span className="inline-flex items-center rounded-full border border-slate-900/10 bg-[#f8f4ec] px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-600">
<span className="inline-flex items-center rounded-[12px] border border-slate-900/8 bg-white/84 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-slate-600">
{badge}
</span>
) : null}
@ -192,7 +192,7 @@ export function AuthLayout({
{alert ? (
<div
className={clsx(
"rounded-[1.25rem] border px-4 py-3 text-sm leading-6",
"rounded-[14px] border px-4 py-3 text-sm leading-6 shadow-[var(--shadow-soft)]",
alert.type === "error"
? "border-danger/20 bg-danger-muted text-danger-foreground"
: alert.type === "success"