mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: improve sidebar and deployment setup
This commit is contained in:
@@ -2,16 +2,20 @@ import type { ReactNode } from "react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PublicNotice } from "../public-notice";
|
||||
import { SiteFooter } from "@/components/shared/site-footer";
|
||||
|
||||
export function AuthShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="grid min-h-[100dvh] place-items-center px-4 py-10">
|
||||
<div className="w-full">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<PublicNotice />
|
||||
<main className="flex min-h-[100dvh] flex-col px-4 py-10">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<PublicNotice />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
<SiteFooter className="mt-6" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { SiteFooter } from "@/components/shared/site-footer";
|
||||
|
||||
export function PaymentFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="grid min-h-[100dvh] place-items-center px-4 py-10">
|
||||
<div className="w-full max-w-2xl">{children}</div>
|
||||
<main className="flex min-h-[100dvh] flex-col px-4 py-10">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-2xl">{children}</div>
|
||||
</div>
|
||||
<SiteFooter className="mt-6 max-w-2xl" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export function AdminMobileNav() {
|
||||
title="J-Board"
|
||||
subtitle="管理后台"
|
||||
groups={adminNavGroups}
|
||||
collapsibleGroups
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@ interface MobileHeaderProps {
|
||||
links?: SidebarLink[];
|
||||
groups?: SidebarGroup[];
|
||||
matchMode?: "exact" | "prefix";
|
||||
collapsibleGroups?: boolean;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function MobileHeader({ title, subtitle, links, groups, matchMode, actions }: MobileHeaderProps) {
|
||||
export function MobileHeader({ title, subtitle, links, groups, matchMode, collapsibleGroups, actions }: MobileHeaderProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -42,6 +43,8 @@ export function MobileHeader({ title, subtitle, links, groups, matchMode, action
|
||||
links={links}
|
||||
groups={groups}
|
||||
matchMode={matchMode}
|
||||
collapsibleGroups={collapsibleGroups}
|
||||
railCollapsible={false}
|
||||
onNavigate={() => setOpen(false)}
|
||||
/>
|
||||
</MobileDrawer>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
export interface SidebarLink {
|
||||
@@ -27,6 +27,7 @@ interface SidebarProps {
|
||||
groups?: SidebarGroup[];
|
||||
matchMode?: "exact" | "prefix";
|
||||
collapsibleGroups?: boolean;
|
||||
railCollapsible?: boolean;
|
||||
headerAction?: ReactNode;
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
@@ -38,6 +39,7 @@ export function Sidebar({
|
||||
groups,
|
||||
matchMode = "prefix",
|
||||
collapsibleGroups = false,
|
||||
railCollapsible = true,
|
||||
headerAction,
|
||||
onNavigate,
|
||||
}: SidebarProps) {
|
||||
@@ -45,6 +47,8 @@ export function Sidebar({
|
||||
const router = useRouter();
|
||||
const navGroups = useMemo(() => groups ?? [{ label: "导航", links }], [groups, links]);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [railCollapsed, setRailCollapsed] = useState(false);
|
||||
const shouldCollapseRail = railCollapsible && railCollapsed;
|
||||
|
||||
const isActive = (href: string) =>
|
||||
matchMode === "exact" ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
|
||||
@@ -71,36 +75,58 @@ export function Sidebar({
|
||||
}, {}),
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<aside className="nav-rail flex h-full w-[15rem] flex-col overflow-hidden rounded-xl text-sidebar-foreground">
|
||||
<div className="border-b border-sidebar-border px-4 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-bold text-sidebar-primary-foreground">
|
||||
<aside
|
||||
className={cn(
|
||||
"nav-rail flex h-full flex-col overflow-hidden rounded-xl text-sidebar-foreground transition-[width] duration-200 ease-out",
|
||||
shouldCollapseRail ? "w-[4.75rem]" : "w-[15rem]",
|
||||
)}
|
||||
>
|
||||
<div className={cn("border-b border-sidebar-border py-4", shouldCollapseRail ? "px-3" : "px-4")}>
|
||||
<div className={cn("flex items-center", shouldCollapseRail ? "flex-col gap-2" : "gap-2.5")}>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-bold text-sidebar-primary-foreground">
|
||||
S
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold tracking-[-0.02em]">{title}</p>
|
||||
{subtitle && (
|
||||
<p className="mt-0.5 truncate text-xs text-sidebar-foreground/55">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{headerAction}
|
||||
{!shouldCollapseRail && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold tracking-[-0.02em]">{title}</p>
|
||||
{subtitle && (
|
||||
<p className="mt-0.5 truncate text-xs text-sidebar-foreground/55">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!shouldCollapseRail && headerAction}
|
||||
{railCollapsible && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-base flex size-7 shrink-0 items-center justify-center rounded-md border border-sidebar-border bg-sidebar-accent/35 text-sidebar-foreground/62 hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
aria-label={shouldCollapseRail ? "展开侧边栏" : "收起侧边栏"}
|
||||
title={shouldCollapseRail ? "展开侧边栏" : "收起侧边栏"}
|
||||
onClick={() => setRailCollapsed((value) => !value)}
|
||||
>
|
||||
{shouldCollapseRail ? <PanelLeftOpen className="size-3.5" /> : <PanelLeftClose className="size-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-4 overflow-y-auto px-3 py-3" aria-label={`${title} 导航`}>
|
||||
{navGroups.map((group) => (
|
||||
<nav
|
||||
className={cn("flex-1 space-y-4 overflow-y-auto py-3", shouldCollapseRail ? "px-2" : "px-3")}
|
||||
aria-label={`${title} 导航`}
|
||||
>
|
||||
{navGroups.map((group, groupIndex) => (
|
||||
<div key={group.label} className="space-y-2">
|
||||
{(() => {
|
||||
const hasActive = group.links.some((link) => isActive(link.href));
|
||||
const isCollapsed =
|
||||
collapsibleGroups &&
|
||||
!hasActive &&
|
||||
!shouldCollapseRail &&
|
||||
(collapsedGroups[group.label] ?? Boolean(group.defaultCollapsed));
|
||||
const isOpen = !isCollapsed;
|
||||
const isOpen = shouldCollapseRail || !isCollapsed;
|
||||
|
||||
return (
|
||||
<>
|
||||
{collapsibleGroups ? (
|
||||
{collapsibleGroups && !shouldCollapseRail ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-md px-2.5 py-1 text-left text-[0.68rem] font-medium tracking-wide text-sidebar-foreground/45 transition-colors hover:bg-sidebar-accent/40 hover:text-sidebar-foreground/70"
|
||||
@@ -109,10 +135,7 @@ export function Sidebar({
|
||||
onClick={() =>
|
||||
setCollapsedGroups((prev) => ({
|
||||
...prev,
|
||||
[group.label]: !(
|
||||
!hasActive &&
|
||||
(prev[group.label] ?? Boolean(group.defaultCollapsed))
|
||||
),
|
||||
[group.label]: !(prev[group.label] ?? (!hasActive && Boolean(group.defaultCollapsed))),
|
||||
}))
|
||||
}
|
||||
>
|
||||
@@ -124,11 +147,13 @@ export function Sidebar({
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
) : shouldCollapseRail && groupIndex > 0 ? (
|
||||
<div className="mx-auto h-px w-6 bg-sidebar-border/70" aria-hidden />
|
||||
) : !shouldCollapseRail ? (
|
||||
<p className="px-2.5 text-[0.68rem] font-medium tracking-wide text-sidebar-foreground/38">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
<div
|
||||
id={`sidebar-group-${group.label}`}
|
||||
className={cn("space-y-1", !isOpen && "hidden")}
|
||||
@@ -143,11 +168,14 @@ export function Sidebar({
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"nav-link-premium group flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm",
|
||||
"nav-link-premium group flex items-center rounded-lg py-2 text-sm",
|
||||
shouldCollapseRail ? "justify-center px-0" : "gap-2.5 px-2.5",
|
||||
active
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground font-medium"
|
||||
: "text-sidebar-foreground/68 hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
)}
|
||||
aria-label={shouldCollapseRail ? link.label : undefined}
|
||||
title={shouldCollapseRail ? link.label : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -157,8 +185,8 @@ export function Sidebar({
|
||||
>
|
||||
{link.icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{link.label}</span>
|
||||
{active && <span className="size-1.5 rounded-full bg-sidebar-primary-foreground/80" aria-hidden />}
|
||||
{!shouldCollapseRail && <span className="min-w-0 flex-1 truncate">{link.label}</span>}
|
||||
{active && !shouldCollapseRail && <span className="size-1.5 rounded-full bg-sidebar-primary-foreground/80" aria-hidden />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -169,14 +197,20 @@ export function Sidebar({
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-sidebar-border px-3 py-3">
|
||||
<div className={cn("border-t border-sidebar-border py-3", shouldCollapseRail ? "px-2" : "px-3")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
disabled={signingOut}
|
||||
className="btn-base btn-cream w-full rounded-lg px-2.5 py-2 text-left text-sm font-medium text-sidebar-foreground/75 hover:text-sidebar-foreground"
|
||||
className={cn(
|
||||
"btn-base btn-cream flex w-full items-center rounded-lg py-2 text-sm font-medium text-sidebar-foreground/75 hover:text-sidebar-foreground",
|
||||
shouldCollapseRail ? "justify-center px-0" : "gap-2 px-2.5 text-left",
|
||||
)}
|
||||
aria-label={signingOut ? "退出中" : "退出登录"}
|
||||
title={shouldCollapseRail ? (signingOut ? "退出中..." : "退出登录") : undefined}
|
||||
>
|
||||
{signingOut ? "退出中..." : "退出登录"}
|
||||
<LogOut className="size-4 shrink-0" />
|
||||
{shouldCollapseRail ? <span className="sr-only">{signingOut ? "退出中..." : "退出登录"}</span> : signingOut ? "退出中..." : "退出登录"}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
27
src/components/shared/site-footer.tsx
Normal file
27
src/components/shared/site-footer.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { GitFork } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const GITHUB_URL = "https://github.com/JetSprow/J-Board";
|
||||
|
||||
export function SiteFooter({ className }: { className?: string }) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-md items-center justify-center gap-2 text-xs text-muted-foreground/70",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span>J-Board</span>
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" aria-hidden />
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/15"
|
||||
>
|
||||
<GitFork className="size-3.5" />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export function UserMobileNav({ userName, unreadCount }: { userName: string; unr
|
||||
subtitle={userName}
|
||||
groups={userNavGroups}
|
||||
matchMode="exact"
|
||||
collapsibleGroups
|
||||
actions={<NotificationPopover unreadCount={unreadCount} className="size-10" />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -44,6 +44,7 @@ export function UserSidebar({ userName, unreadCount, onNavigate }: { userName: s
|
||||
subtitle={userName}
|
||||
groups={userNavGroups}
|
||||
matchMode="exact"
|
||||
collapsibleGroups
|
||||
headerAction={<NotificationPopover unreadCount={unreadCount} />}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user