grok2api / frontend /src /app /app-shell.tsx
fromozuzhouzzz
Deploy grok2api v3.0.11 to HF Spaces
f1dd159
Raw
History Blame Contribute Delete
15.1 kB
import { zodResolver } from "@hookform/resolvers/zod";
import { Box, ChevronDown, Eye, Image, KeyRound, Languages, LayoutDashboard, LogOut, Menu, MessageSquareText, Monitor, Moon, MoreHorizontal, Settings, Sparkles, Sun, Users, Video } from "lucide-react";
import { useTheme } from "next-themes";
import { useState, type ReactNode } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { useAuth } from "@/shared/auth/use-auth";
import { GitHubMark } from "@/shared/components/github-mark";
import { SiteFooter } from "@/shared/components/site-footer";
import { cn } from "@/shared/lib/cn";
import { CurrentVersionLabel } from "@/features/system/version-update";
const navigation = [
{ href: "/dashboard", label: "nav.dashboard", icon: LayoutDashboard },
{ href: "/accounts", label: "nav.accounts", icon: Users },
{ href: "/client-keys", label: "nav.clientKeys", icon: KeyRound },
{ href: "/models", label: "nav.models", icon: Box },
{ href: "/gallery", label: "nav.gallery", icon: Image },
{ href: "/video-gallery", label: "nav.videoGallery", icon: Video },
{ href: "/request-audits", label: "nav.audits", icon: Eye },
{ href: "/creative-console", label: "nav.creativeConsole", icon: Sparkles },
] as const;
const documentation = [
{
label: "Chat",
icon: MessageSquareText,
items: [
{ href: "/docs/chat/completions", label: "Chat Completions", method: "POST" },
{ href: "/docs/chat/responses", label: "Responses", method: "POST" },
{ href: "/docs/chat/messages", label: "Messages", method: "POST" },
],
},
{
label: "Image",
icon: Image,
items: [
{ href: "/docs/image/generations", label: "Image Generations", method: "POST" },
{ href: "/docs/image/edits", label: "Image Edits", method: "POST" },
],
},
{
label: "Video",
icon: Video,
items: [
{ href: "/docs/video/generations", label: "Video Generations", method: "POST" },
{ href: "/docs/video/get", label: "Get Video", method: "GET" },
],
},
] as const;
export function AppShell() {
const { t, i18n } = useTranslation();
const { admin, logout, changePassword } = useAuth();
const location = useLocation();
const { setTheme } = useTheme();
const [mobileOpen, setMobileOpen] = useState(false);
const [passwordOpen, setPasswordOpen] = useState(false);
const [documentationOpen, setDocumentationOpen] = useState<Record<string, boolean>>({});
const isMediaWorkspace = ["/creative-console", "/gallery", "/video-gallery"].includes(location.pathname);
const passwordSchema = z.object({
currentPassword: z.string().min(1, t("errors.required")),
newPassword: z.string().min(8, t("errors.minPassword")),
});
type PasswordForm = z.infer<typeof passwordSchema>;
const passwordForm = useForm<PasswordForm>({
resolver: zodResolver(passwordSchema),
defaultValues: { currentPassword: "", newPassword: "" },
});
async function submitPassword(values: PasswordForm): Promise<void> {
try {
await changePassword(values.currentPassword, values.newPassword);
toast.success(t("auth.passwordUpdated"));
passwordForm.reset();
setPasswordOpen(false);
await logout();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("errors.generic"));
}
}
function navigationLinks(): ReactNode {
return navigation.map(({ href, label, icon: Icon }) => (
<NavLink
key={href}
to={href}
onClick={() => setMobileOpen(false)}
className={({ isActive }) => cn(
"group flex h-8 items-center gap-2 rounded-md px-2.5 text-xs font-normal text-muted-foreground transition-colors hover:bg-secondary/55 hover:text-foreground",
isActive && "bg-secondary/60 text-foreground",
)}
>
{({ isActive }) => (
<>
<span className="flex size-5 shrink-0 items-center justify-center">
<Icon className={cn("size-4 text-muted-foreground", isActive && "text-foreground")} fill={isActive ? "currentColor" : "none"} fillOpacity={isActive ? 0.14 : 0} strokeWidth={1.8} />
</span>
{t(label)}
</>
)}
</NavLink>
));
}
function documentationLinks(): ReactNode {
return documentation.map(({ label, icon: Icon, items }) => {
const open = documentationOpen[label] ?? false;
return (
<div key={label}>
<button
type="button"
className="flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-xs font-normal text-muted-foreground transition-colors hover:bg-secondary/55 hover:text-foreground"
aria-expanded={open}
onClick={() => setDocumentationOpen((current) => ({ ...current, [label]: !open }))}
>
<span className="flex size-5 shrink-0 items-center justify-center">
<Icon className="size-[15px] text-muted-foreground" strokeWidth={1.7} />
</span>
<span className="flex-1 text-left">{label}</span>
<ChevronDown className={cn("size-3 text-muted-foreground transition-transform", !open && "-rotate-90")} />
</button>
<div className={cn(
"grid transition-[grid-template-rows,opacity] duration-200 ease-out",
open ? "grid-rows-[1fr] opacity-100" : "pointer-events-none grid-rows-[0fr] opacity-0",
)} aria-hidden={!open}>
<div className="overflow-hidden">
<div className="space-y-1 pt-1">
{items.map((item) => (
<NavLink
key={item.href}
to={item.href}
onClick={() => setMobileOpen(false)}
className={({ isActive }) => cn(
"group flex h-7 min-w-0 items-center gap-2 rounded-md pl-[38px] pr-2.5 text-xs text-muted-foreground transition-colors hover:bg-secondary/55 hover:text-foreground",
isActive && "bg-secondary/60 text-foreground",
)}
>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<span className={cn(
"shrink-0 font-mono text-[9px] font-medium text-muted-foreground/70",
item.method === "GET" && "text-emerald-600 dark:text-emerald-400",
item.method === "POST" && "text-sky-600 dark:text-sky-400",
)}>
{item.method}
</span>
</NavLink>
))}
</div>
</div>
</div>
</div>
);
});
}
const navigationContent = (
<nav className="mt-7 min-h-0 flex-1 overflow-y-auto overscroll-contain pr-2 pb-2" aria-label={t("shell.navigation")}>
<div className="space-y-1">{navigationLinks()}</div>
<div className="mt-7">
<div className="px-2.5 pb-2 text-xs font-normal text-foreground">{t("nav.docs")}</div>
<div className="space-y-1">{documentationLinks()}</div>
</div>
</nav>
);
const accountControl = (
<div className="flex h-9 items-center gap-1 px-2.5">
<span className="min-w-0 flex-1 truncate text-xs font-normal capitalize text-muted-foreground">{admin?.username}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-7 shrink-0 text-muted-foreground hover:text-foreground" aria-label={t("common.actions")}><MoreHorizontal /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" sideOffset={8} className="w-56 p-1.5">
<DropdownMenuSub>
<DropdownMenuSubTrigger className="h-8"><Sun />{t("shell.appearance")}</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => setTheme("light")}><Sun />{t("shell.light")}</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}><Moon />{t("shell.dark")}</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}><Monitor />{t("shell.system")}</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="h-8"><Languages />{t("shell.language")}</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => void i18n.changeLanguage("zh-CN")}>简体中文</DropdownMenuItem>
<DropdownMenuItem onClick={() => void i18n.changeLanguage("en")}>English</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem className="h-8" onClick={() => setPasswordOpen(true)}><KeyRound />{t("auth.changePassword")}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="h-8" onClick={() => void logout()}><LogOut />{t("auth.signOut")}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NavLink
to="/settings"
onClick={() => setMobileOpen(false)}
className={({ isActive }) => cn("flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary/55 hover:text-foreground", isActive && "bg-secondary/60 text-foreground")}
aria-label={t("nav.settings")}
>
<Settings className="size-4" strokeWidth={1.8} />
</NavLink>
</div>
);
return (
<div className="min-h-screen bg-background">
<aside className="fixed inset-y-0 left-0 z-30 hidden h-screen w-[288px] flex-col overflow-hidden bg-sidebar px-4 py-6 lg:flex">
<div className="flex h-7 shrink-0 items-center justify-between px-2.5">
<Link to="/dashboard" className="flex h-7 items-baseline gap-2 text-base font-semibold text-foreground">
<span>{t("appName")}</span>
<CurrentVersionLabel />
</Link>
<Button variant="ghost" size="icon" className="size-7 text-muted-foreground [&_svg]:size-[15px]" asChild>
<a href="https://github.com/chenyme/grok2api" target="_blank" rel="noreferrer" aria-label="GitHub">
<GitHubMark />
</a>
</Button>
</div>
{navigationContent}
<div className="relative z-10 mt-4 shrink-0 bg-sidebar pt-4">{accountControl}</div>
</aside>
<div className="flex min-h-screen flex-col lg:pl-[288px]">
<header className="sticky top-0 z-40 flex h-12 items-center justify-between border-b bg-background px-4 lg:hidden">
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild><Button variant="ghost" size="icon" className="size-8" aria-label={t("shell.openNavigation")}><Menu className="size-4" /></Button></SheetTrigger>
<SheetContent side="left" className="flex h-dvh max-h-dvh w-72 flex-col gap-0 overflow-hidden bg-sidebar px-3 py-4 [&>button]:right-2 [&>button]:top-3.5 [&>button]:flex [&>button]:size-7 [&>button]:items-center [&>button]:justify-center">
<SheetHeader className="h-7 shrink-0 px-2.5 text-left">
<SheetTitle className="flex h-7 items-center text-base">{t("appName")}</SheetTitle>
<SheetDescription className="sr-only">{t("shell.navigation")}</SheetDescription>
</SheetHeader>
<nav className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1 pb-2" aria-label={t("shell.navigation")}>
<div className="space-y-1">{navigationLinks()}</div>
<div className="mt-7">
<div className="px-2.5 pb-2 text-xs font-normal text-foreground">{t("nav.docs")}</div>
<div className="space-y-1">{documentationLinks()}</div>
</div>
</nav>
<div className="relative z-10 mt-3 shrink-0 border-t border-sidebar-border/60 bg-sidebar pt-3">{accountControl}</div>
</SheetContent>
</Sheet>
<span className="flex items-baseline gap-2 text-sm font-semibold"><span>{t("appName")}</span><CurrentVersionLabel /></span>
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground hover:text-foreground" asChild>
<a href="https://github.com/chenyme/grok2api" target="_blank" rel="noreferrer" aria-label="GitHub">
<GitHubMark />
</a>
</Button>
</header>
<main className={cn("mx-auto w-full max-w-[1280px] flex-1 px-5 sm:px-8", isMediaWorkspace ? "pt-8 pb-0 lg:pt-20" : "py-8 lg:py-20")}>
<Outlet />
</main>
{!isMediaWorkspace ? <SiteFooter /> : null}
</div>
<Dialog open={passwordOpen} onOpenChange={setPasswordOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{t("auth.changePassword")}</DialogTitle><DialogDescription>{admin?.username}</DialogDescription></DialogHeader>
<form className="space-y-4" onSubmit={passwordForm.handleSubmit(submitPassword)}>
<div className="space-y-2"><Label htmlFor="current-password">{t("auth.currentPassword")}</Label><Input id="current-password" type="password" autoComplete="current-password" {...passwordForm.register("currentPassword")} />{passwordForm.formState.errors.currentPassword ? <p className="text-xs text-destructive">{passwordForm.formState.errors.currentPassword.message}</p> : null}</div>
<div className="space-y-2"><Label htmlFor="new-password">{t("auth.newPassword")}</Label><Input id="new-password" type="password" autoComplete="new-password" {...passwordForm.register("newPassword")} />{passwordForm.formState.errors.newPassword ? <p className="text-xs text-destructive">{passwordForm.formState.errors.newPassword.message}</p> : null}</div>
<DialogFooter><Button type="button" variant="secondary" size="sm" onClick={() => setPasswordOpen(false)}>{t("common.cancel")}</Button><Button type="submit" size="sm" disabled={passwordForm.formState.isSubmitting}>{t("common.save")}</Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}