feat: extend tactile theme to docs panel and auth
This commit is contained in:
parent
2831f6028b
commit
b2ac63e2b0
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user