From b2ac63e2b0a56310990ff28eb775f52ad1301020 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 18 Mar 2026 17:39:48 +0800 Subject: [PATCH] feat: extend tactile theme to docs panel and auth --- src/app/docs/DocsSidebar.tsx | 24 +- src/app/docs/DocsSidebarContent.tsx | 478 +++++++++-------- src/app/docs/Feedback.tsx | 6 +- src/app/docs/[collection]/[...slug]/page.tsx | 14 +- src/app/docs/layout.tsx | 4 +- src/app/docs/page.tsx | 24 +- src/app/panel/components/Header.tsx | 291 ++++++----- .../panel/components/PanelSidebarContent.tsx | 479 ++++++++++-------- src/app/panel/components/Sidebar.tsx | 23 +- src/app/panel/layout.tsx | 136 ++--- src/components/auth/AuthLayout.tsx | 34 +- 11 files changed, 863 insertions(+), 650 deletions(-) diff --git a/src/app/docs/DocsSidebar.tsx b/src/app/docs/DocsSidebar.tsx index f1d5840..e460e09 100644 --- a/src/app/docs/DocsSidebar.tsx +++ b/src/app/docs/DocsSidebar.tsx @@ -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 ( - - - - ) + return ( + + + + ); } diff --git a/src/app/docs/DocsSidebarContent.tsx b/src/app/docs/DocsSidebarContent.tsx index 42c271e..4db9dcc 100644 --- a/src/app/docs/DocsSidebarContent.tsx +++ b/src/app/docs/DocsSidebarContent.tsx @@ -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 = { - '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 ( + + + + ); } -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 = {}; + const topLevel: DocVersionOption[] = []; - return ( - - - - ) -} + 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 = {} - const topLevel: DocVersionOption[] = [] + return ( +
+ - 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 ( -
- - - {isOpen && ( -
- {/* Uncategorized / Overview / README */} - {topLevel.length > 0 && ( -
    - {topLevel.map(v => ( - - ))} -
- )} - - {/* Main Categories (Getting Started, Architecture, etc.) */} -
- {Object.entries(grouped) - .filter(([k]) => !ADVANCED_GROUP.includes(k)) - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([category, versions]) => ( - - ))} -
- - {/* Advanced Section Dropdown */} - {hasAdvanced && ( - - )} -
- )} -
- ) -} - -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 ( -
-
- - {humanize(title)} -
-
    - {versions.map(v => ( - - ))} + {isOpen && ( +
    + {/* Uncategorized / Overview / README */} + {topLevel.length > 0 && ( +
      + {topLevel.map((v) => ( + + ))}
    + )} + + {/* Main Categories (Getting Started, Architecture, etc.) */} +
    + {Object.entries(grouped) + .filter(([k]) => !ADVANCED_GROUP.includes(k)) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([category, versions]) => ( + + ))} +
    + + {/* Advanced Section Dropdown */} + {hasAdvanced && ( + + )}
    - ) + )} +
+ ); } -function AdvancedSection({ grouped, collectionSlug, activePath }: { grouped: Record; 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 ( +
+
+ + {humanize(title)} +
+
    + {versions.map((v) => ( + + ))} +
+
+ ); +} - return ( -
- +function AdvancedSection({ + grouped, + collectionSlug, + activePath, +}: { + grouped: Record; + 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 && ( -
- {ADVANCED_GROUP.map(k => grouped[k] && ( -
-
- - {humanize(k)} -
-
    - {grouped[k].map(v => ( - - ))} -
-
+ const [isExpanded, setIsExpanded] = useState(isInsideActive); + + useEffect(() => { + if (isInsideActive) setIsExpanded(true); + }, [isInsideActive]); + + return ( +
+ + + {isExpanded && ( +
+ {ADVANCED_GROUP.map( + (k) => + grouped[k] && ( +
+
+ + {humanize(k)} +
+
    + {grouped[k].map((v) => ( + ))} +
- )} + ), + )}
- ) + )} +
+ ); } -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 ( -
  • - - {isPageActive && } - {version.title} - {!isPageActive && } - -
  • - ) + return ( +
  • + + {isPageActive && ( + + )} + {version.title} + {!isPageActive && ( + + )} + +
  • + ); } diff --git a/src/app/docs/Feedback.tsx b/src/app/docs/Feedback.tsx index bab4f0d..ded0d6a 100644 --- a/src/app/docs/Feedback.tsx +++ b/src/app/docs/Feedback.tsx @@ -7,7 +7,7 @@ export default function Feedback() { const [voted, setVoted] = useState<"yes" | "no" | null>(null); return ( -
    +

    @@ -22,14 +22,14 @@ export default function Feedback() {

    ) : ( @@ -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"} ) : null}
    )}
    -
    - - - {onCollapse && ( +
    - )} -
    -
    -
    - - 返回主页 - - {statusBadge} -
    - {isLoading ? : accountInitial} -
    -
    - {accountLabel} - {user?.email ?? (isLoading ? 'Checking session…' : 'Not signed in')} + {onCollapse && ( + + )} +
    + +
    +
    + + 返回主页 + + + {statusBadge} + +
    + {isLoading ? ( + + ) : ( + accountInitial + )} +
    +
    + + {accountLabel} + + + {user?.email ?? + (isLoading ? "Checking session…" : "Not signed in")} + +
    -
    - ) + ); } diff --git a/src/app/panel/components/PanelSidebarContent.tsx b/src/app/panel/components/PanelSidebarContent.tsx index 9d6de63..518d841 100644 --- a/src/app/panel/components/PanelSidebarContent.tsx +++ b/src/app/panel/components/PanelSidebarContent.tsx @@ -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(() => { - 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(() => { + 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 ( - <> - -

    - {translations[language].userCenter.overview.heading} -

    -

    - {language === 'zh' ? '在同一处掌控权限与功能特性。' : 'Manage permissions and features in one place.'} -

    + return ( + <> + +

    + {translations[language].userCenter.overview.heading} +

    +

    + {language === "zh" + ? "在同一处掌控权限与功能特性。" + : "Manage permissions and features in one place."} +

    - {requiresSetup ? ( -
    -

    {copy.pendingHint}

    -

    {copy.lockedMessage}

    -
    - - {copy.actions.setup} - - - {copy.actions.docs} - -
    + {requiresSetup ? ( +
    +

    {copy.pendingHint}

    +

    {copy.lockedMessage}

    +
    + + {copy.actions.setup} + + + {copy.actions.docs} + +
    +
    + ) : null} + + + + {navSections.map((section) => { + const sectionDisabled = section.items.every((item) => item.disabled); + + return ( +
    +

    + {translations[language].userCenter.sections[ + section.id as keyof typeof translations.en.userCenter.sections + ] || section.title} +

    +
    + {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 = ( +
    + + + + + + {(item.id && + translations[language].userCenter.items[ + item.id as keyof typeof translations.en.userCenter.items + ]) || + item.label} + + + {item.description} + +
    - ) : null} - - - - {navSections.map((section) => { - const sectionDisabled = section.items.every((item) => item.disabled) + ); + if (item.disabled) { return ( -
    -

    - {translations[language].userCenter.sections[section.id as keyof typeof translations.en.userCenter.sections] || section.title} -

    -
    - {section.items.map((item) => { - const active = isActive(pathname, item.href) - const isDashboard = item.href === '/panel' - const { Icon } = item +
    + {content} +
    + ); + } - 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 = ( -
    - - - - - - {(item.id && translations[language].userCenter.items[item.id as keyof typeof translations.en.userCenter.items]) || item.label} - - {item.description} - -
    - ) - - if (item.disabled) { - return ( -
    - {content} -
    - ) - } - - return ( - - {content} - - ) - })} -
    -
    - ) + return ( + + {content} + + ); })} -
    +
    +
    + ); + })} +
    - - - - - ) + + + + + ); } diff --git a/src/app/panel/components/Sidebar.tsx b/src/app/panel/components/Sidebar.tsx index 116754e..b2868f2 100644 --- a/src/app/panel/components/Sidebar.tsx +++ b/src/app/panel/components/Sidebar.tsx @@ -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 ( - ) + ); } diff --git a/src/app/panel/layout.tsx b/src/app/panel/layout.tsx index 383efdf..8d609c9 100644 --- a/src/app/panel/layout.tsx +++ b/src/app/panel/layout.tsx @@ -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(() => { 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 (
    setOpen(false)} collapsed={isCollapsed} /> @@ -108,15 +116,15 @@ export default function PanelLayout({ children }: { children: React.ReactNode }) onCollapse={() => setIsCollapsed((prev) => !prev)} isCollapsed={isCollapsed} /> -
    +
    {requiresSetup ? ( -
    +

    {copy.lockedMessage}

    @@ -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} {isLoading ? ( - + ) : null}
    ) : null} -
    {children}
    +
    + {children} +
    - ) + ); } diff --git a/src/components/auth/AuthLayout.tsx b/src/components/auth/AuthLayout.tsx index e48b457..c5c43dd 100644 --- a/src/components/auth/AuthLayout.tsx +++ b/src/components/auth/AuthLayout.tsx @@ -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({ @@ -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({
    @@ -156,13 +156,13 @@ export function AuthLayout({ Svc.Plus

    - + {modeLabel}
    -
    -
    +
    +
    Sign In @@ -173,7 +173,7 @@ export function AuthLayout({
    {badge ? ( - + {badge} ) : null} @@ -192,7 +192,7 @@ export function AuthLayout({ {alert ? (