feat: add sub-sidebar result page

This commit is contained in:
2026-08-01 00:59:58 +07:00 Unverified
parent 255b895112
commit a41baf1435
7 changed files with 141 additions and 23 deletions
+13
View File
@@ -6,6 +6,8 @@ import { authOptions } from "@/lib/auth-options";
import { getSessionDiscordId } from "@/lib/auth";
import { getDiscordMemberProfile } from "@/lib/discord";
import { db } from "@/db";
import { submissions } from "@/db/schema";
import { desc, eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { isFormAcceptingSubmissions } from "@/lib/form-availability";
@@ -34,6 +36,16 @@ export default async function AdminFormLayout(
notFound();
}
const responseItems = await db
.select({
id: submissions.id,
userName: submissions.userName,
userDiscordId: submissions.userDiscordId,
})
.from(submissions)
.where(eq(submissions.formId, params.id))
.orderBy(desc(submissions.submittedAt), desc(submissions.id));
let profile = null;
const discordId = getSessionDiscordId(session);
if (discordId) {
@@ -46,6 +58,7 @@ export default async function AdminFormLayout(
<FormAdminSidebar
formId={params.id}
formTitle={form.title}
responseItems={responseItems}
isOpen={isFormAcceptingSubmissions(form)}
userName={session?.user?.name || ""}
userImage={session?.user?.image || ""}
@@ -8,7 +8,6 @@ import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import {
ContextMenu,
@@ -25,7 +24,9 @@ import {
Check,
Clock,
Copy,
ExternalLink,
Loader2,
Maximize2,
Users,
} from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -33,6 +34,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -414,34 +416,45 @@ function AnswerImagePreview({ src, label }: { src: string; label: string }) {
<Button
type="button"
variant="outline"
className="h-auto w-fit p-2"
className="group/image relative h-auto w-fit p-2"
aria-label={`Open image answer for ${label}`}
>
<Image
src={src}
alt=""
alt={`${label} image answer`}
width={224}
height={160}
unoptimized
className="max-h-40 max-w-56 rounded object-contain"
onError={() => setUnavailable(true)}
/>
<span className="absolute right-3 bottom-3 rounded-md bg-background/90 p-1.5 opacity-0 shadow-sm transition-opacity group-hover/image:opacity-100 group-focus-visible/image:opacity-100">
<Maximize2 />
</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogContent className="max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] sm:max-w-[calc(100vw-4rem)]">
<DialogHeader>
<DialogTitle>Image answer</DialogTitle>
<DialogDescription>{label}</DialogDescription>
</DialogHeader>
<Image
src={src}
alt=""
width={1200}
height={800}
alt={`${label} image answer`}
width={1920}
height={1080}
unoptimized
className="max-h-[70vh] w-full rounded-md object-contain"
className="max-h-[calc(100vh-10rem)] w-full rounded-md object-contain"
onError={() => setUnavailable(true)}
/>
<DialogFooter>
<Button asChild variant="outline">
<a href={src} target="_blank" rel="noopener noreferrer">
<ExternalLink data-icon="inline-start" />
Open original
</a>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
-9
View File
@@ -141,7 +141,6 @@ export default async function ResultsListPage({
<TableRow>
<TableHead>Respondent</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Status</TableHead>
<TableHead>Note</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
@@ -149,9 +148,6 @@ export default async function ResultsListPage({
<TableBody>
{rows.map((submission) => {
const detailHref = `/admin/form/${id}/result/${submission.id}${listQuery ? `?return=${encodeURIComponent(listQuery)}` : ""}`;
const edited =
Array.isArray(submission.editHistory) &&
submission.editHistory.length > 0;
return (
<TableRow key={submission.id} className="relative cursor-pointer">
<TableCell>
@@ -177,11 +173,6 @@ export default async function ResultsListPage({
)}
</TableCell>
<TableCell>{formatDate(submission.submittedAt)}</TableCell>
<TableCell>
<Badge variant={edited ? "secondary" : "outline"}>
{edited ? "Edited" : "Original"}
</Badge>
</TableCell>
<TableCell className="max-w-80">
{submission.adminNote ? (
<p className="line-clamp-2 whitespace-pre-wrap text-sm">
+24 -6
View File
@@ -1,7 +1,8 @@
"use client";
import React, { useRef, useState, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import {
Card,
CardContent,
@@ -11,6 +12,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ShieldCheck } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
@@ -61,17 +63,33 @@ export function FormLayoutClient({
userName,
userImage,
profile,
isAdmin,
}: {
userName: string;
userImage: string;
profile?: DiscordMemberProfile | null;
isAdmin: boolean;
}) {
const pathname = usePathname();
const formId = pathname.match(/^\/form\/([^/]+)/)?.[1];
const adminHref = formId ? `/admin/form/${formId}` : "/admin";
return (
<DropdownMenuAvatar
userName={userName}
userImage={userImage}
profile={profile || null}
/>
<>
{isAdmin && (
<Button variant="outline" size="sm" asChild>
<Link href={adminHref}>
<ShieldCheck data-icon="inline-start" />
Go to admin
</Link>
</Button>
)}
<DropdownMenuAvatar
userName={userName}
userImage={userImage}
profile={profile || null}
/>
</>
);
}
+3
View File
@@ -6,6 +6,7 @@ import { ThemeToggle } from "@/components/theme-toggle";
import { getSessionDiscordId } from "@/lib/auth";
import { getDiscordMemberProfile } from "@/lib/discord";
import Link from 'next/link';
import { getAdminDiscordIds } from "@/lib/auth";
const siteUrl = process.env.BASE_URL;
@@ -37,6 +38,7 @@ export default async function FormLayout({
}) {
const session = await getServerSession(authOptions);
const discordId = getSessionDiscordId(session);
const isAdmin = Boolean(discordId && getAdminDiscordIds().includes(discordId));
let profile = null;
if (discordId) {
@@ -57,6 +59,7 @@ export default async function FormLayout({
userName={session.user.name ?? ""}
userImage={session.user.image ?? ""}
profile={profile}
isAdmin={isAdmin}
/>
) : (
<LoginButton />
+19
View File
@@ -8,6 +8,12 @@ import { headers } from "next/headers";
import { connection } from "next/server";
import { Users } from "lucide-react";
import { Kbd } from "@/components/ui/kbd";
import { Button } from "@/components/ui/button";
import { ShieldCheck } from "lucide-react";
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { getAdminDiscordIds, getSessionDiscordId } from "@/lib/auth";
import Background from "@/public/background/space.webp";
export default async function Home() {
@@ -40,6 +46,9 @@ export default async function Home() {
// Use configured profile data.
const profileName = config.profile.name;
const profileAvatar = config.profile.avatar;
const session = await getServerSession(authOptions);
const discordId = getSessionDiscordId(session);
const isAdmin = Boolean(discordId && getAdminDiscordIds().includes(discordId));
const links1 = [...config.links];
@@ -99,6 +108,16 @@ export default async function Home() {
</CardHeader>
<LinkCard counts={counts} links1={links1} links2={config_p2.links} />
{isAdmin && (
<div className="px-6 pb-6">
<Button variant="secondary" className="w-full" asChild>
<Link href="/admin">
<ShieldCheck data-icon="inline-start" />
Go to admin
</Link>
</Button>
</div>
)}
</Card>
</main>
);
+61
View File
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { usePathname } from "next/navigation";
import {
Sidebar,
@@ -10,12 +11,18 @@ import {
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
ArrowLeft,
ClipboardList,
ChevronRight,
Eye,
FilePenLine,
LayoutDashboard,
@@ -28,6 +35,7 @@ import {
} from "@/components/dropdown-menu-avatar";
import { GuardedLink } from "@/components/guarded-link";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export function FormAdminSidebar({
formId,
@@ -36,6 +44,7 @@ export function FormAdminSidebar({
userName,
userImage,
profile,
responseItems,
}: {
formId: string;
formTitle: string;
@@ -43,8 +52,15 @@ export function FormAdminSidebar({
userName?: string;
userImage?: string;
profile?: DiscordMemberProfile | null;
responseItems?: {
id: string;
userName: string | null;
userDiscordId: string | null;
}[];
}) {
const pathname = usePathname();
const responseList = responseItems ?? [];
const [responsesOpen, setResponsesOpen] = useState(true);
const currentSection = pathname.startsWith(`/admin/form/${formId}/result`)
? "Responses"
: pathname.startsWith(`/admin/form/${formId}/extra`)
@@ -52,6 +68,7 @@ export function FormAdminSidebar({
: pathname.startsWith(`/admin/form/${formId}/preview`)
? "Preview"
: "Editor";
const items = [
{
href: `/admin/form/${formId}`,
@@ -137,6 +154,50 @@ export function FormAdminSidebar({
<span>{label}</span>
</GuardedLink>
</SidebarMenuButton>
{label === "Responses" && responseList.length > 0 && (
<SidebarMenuAction
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 &&
responseList.length > 0 && (
<ScrollArea className="max-h-80">
<SidebarMenuSub>
{responseList.map((response) => {
const responseHref = `/admin/form/${formId}/result/${response.id}`;
const name =
response.userName ||
response.userDiscordId ||
"Anonymous";
return (
<SidebarMenuSubItem key={response.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === responseHref}
>
<GuardedLink href={responseHref}>
<span className="min-w-0 truncate">
{name}
</span>
</GuardedLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</ScrollArea>
)}
</SidebarMenuItem>
))}
</SidebarMenu>