Files
erika/app/(ui)/admin/form/[id]/sidebar-client.tsx
T
2026-08-27 14:11:20 +00:00

309 lines
11 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState, useTransition } from "react";
import { usePathname } from "next/navigation";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarInput,
} from "@/components/ui/sidebar";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
ArrowLeft,
ClipboardList,
ChevronRight,
Eye,
FilePenLine,
LayoutDashboard,
Settings,
Search,
} from "lucide-react";
import { ThemeToggle } from "@/components/theme-toggle";
import {
DropdownMenuAvatar,
type DiscordMemberProfile,
} from "@/components/avatar";
import { GuardedLink } from "@/components/guarded-link";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { getResponseNavigation } from "@/app/(ui)/admin/form/actions";
type ResponseNavigationData = Awaited<ReturnType<typeof getResponseNavigation>>;
export function FormAdminSidebar({
formId,
formTitle,
isOpen,
userName,
userImage,
profile,
responseCount,
}: {
formId: string;
formTitle: string;
isOpen: boolean;
userName?: string;
userImage?: string;
profile?: DiscordMemberProfile | null;
responseCount: number;
}) {
const pathname = usePathname();
const [responsesOpen, setResponsesOpen] = useState(
pathname.startsWith(`/admin/form/${formId}/result/`),
);
const [responseQuery, setResponseQuery] = useState("");
const [responseData, setResponseData] =
useState<ResponseNavigationData | null>(null);
const [responseError, setResponseError] = useState<string | null>(null);
const [isLoadingResponses, startResponseTransition] = useTransition();
const loadResponses = useCallback((page: number, query = responseQuery) => {
startResponseTransition(async () => {
try {
setResponseError(null);
setResponseData(await getResponseNavigation(formId, page, query));
} catch {
setResponseError("Could not load responses.");
}
});
}, [formId, responseQuery]);
useEffect(() => {
if (responsesOpen && responseCount > 0 && !responseData) {
loadResponses(1, "");
}
}, [loadResponses, responsesOpen, responseCount, responseData]);
const currentSection = pathname.startsWith(`/admin/form/${formId}/result`)
? "Responses"
: pathname.startsWith(`/admin/form/${formId}/extra`)
? "Settings"
: pathname.startsWith(`/admin/form/${formId}/preview`)
? "Preview"
: "Editor";
const items = [
{
href: `/admin/form/${formId}`,
label: "Editor",
icon: LayoutDashboard,
active: pathname === `/admin/form/${formId}`,
},
{
href: `/admin/form/${formId}/result`,
label: "Responses",
icon: ClipboardList,
active: pathname.startsWith(`/admin/form/${formId}/result`),
},
{
href: `/admin/form/${formId}/extra`,
label: "Settings",
icon: Settings,
active: pathname.startsWith(`/admin/form/${formId}/extra`),
},
{
href: `/admin/form/${formId}/preview`,
label: "Preview",
icon: Eye,
active: pathname.startsWith(`/admin/form/${formId}/preview`),
},
];
return (
<Sidebar variant="sidebar" collapsible="icon">
<SidebarHeader className="border-b p-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
size="lg"
tooltip={`${formTitle} · ${isOpen ? "Open" : "Closed"}`}
className="h-auto min-h-14"
>
<GuardedLink
href={`/admin/form/${formId}`}
aria-label={`${formTitle}, ${isOpen ? "open" : "closed"}. Open editor`}
>
<FilePenLine />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">
{formTitle}
</span>
<Badge
variant={isOpen ? "default" : "secondary"}
className="shrink-0"
>
{isOpen ? "Open" : "Closed"}
</Badge>
</div>
<span className="mt-1 block text-xs text-muted-foreground">
{currentSection}
</span>
</div>
</GuardedLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<GuardedLink href="/admin/form">
<ArrowLeft />
<span>All forms</span>
</GuardedLink>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map(({ href, label, icon: Icon, active }) => (
<SidebarMenuItem key={href}>
<SidebarMenuButton asChild isActive={active}>
<GuardedLink href={href}>
<Icon />
<span>{label}</span>
</GuardedLink>
</SidebarMenuButton>
{label === "Responses" && responseCount > 0 && (
<SidebarMenuBadge>{responseCount}</SidebarMenuBadge>
)}
{label === "Responses" && responseCount > 0 && (
<SidebarMenuAction
className="right-8"
onClick={() => setResponsesOpen((open) => !open)}
aria-label={`${responsesOpen ? "Collapse" : "Expand"} responses`}
aria-expanded={responsesOpen}
>
<ChevronRight
className={cn(
"transition-transform",
responsesOpen && "rotate-90"
)}
/>
</SidebarMenuAction>
)}
{label === "Responses" &&
responsesOpen &&
responseCount > 0 && (
<div className="mt-1 flex flex-col gap-2 group-data-[collapsible=icon]:hidden">
<form
className="flex items-center gap-1 px-2"
onSubmit={(event) => {
event.preventDefault();
loadResponses(1);
}}
>
<SidebarInput
value={responseQuery}
onChange={(event) => setResponseQuery(event.target.value)}
placeholder="Search responses"
aria-label="Search response navigation"
/>
<Button
type="submit"
size="icon-sm"
variant="ghost"
disabled={isLoadingResponses}
aria-label="Search responses"
>
<Search />
</Button>
</form>
<ScrollArea className="max-h-[min(50vh,28rem)]">
<SidebarMenuSub>
{responseData?.items.map((response) => {
const responseHref = `/admin/form/${formId}/result/${response.id}`;
return (
<SidebarMenuSubItem key={response.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === responseHref}
>
<GuardedLink href={responseHref}>
<span className="min-w-0 truncate">
{response.queueNumber}. {response.displayName}
</span>
</GuardedLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
{isLoadingResponses && !responseData && (
<SidebarMenuSubItem className="px-2 py-1 text-xs text-muted-foreground">
Loading responses…
</SidebarMenuSubItem>
)}
{responseError && (
<SidebarMenuSubItem className="px-2 py-1 text-xs text-destructive">
{responseError}
</SidebarMenuSubItem>
)}
</SidebarMenuSub>
</ScrollArea>
{responseData && responseData.pageCount > 1 && (
<div className="flex items-center justify-between gap-1 px-2">
<Button
size="sm"
variant="ghost"
disabled={isLoadingResponses || responseData.page === 1}
onClick={() => loadResponses(responseData.page - 1)}
>
Previous
</Button>
<span className="text-xs text-muted-foreground">
{responseData.page} / {responseData.pageCount}
</span>
<Button
size="sm"
variant="ghost"
disabled={isLoadingResponses || responseData.page === responseData.pageCount}
onClick={() => loadResponses(responseData.page + 1)}
>
Next
</Button>
</div>
)}
</div>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t p-4">
<div className="flex items-center gap-1">
{(userName || profile) && (
<DropdownMenuAvatar
userName={userName || ""}
userImage={userImage || ""}
profile={profile || null}
showName
/>
)}
<div className="ml-auto">
<ThemeToggle />
</div>
</div>
</SidebarFooter>
</Sidebar>
);
}