feat: add sub-sidebar result page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user