From 1fb154b4650418234d7533e10933ce3689fd3f5c Mon Sep 17 00:00:00 2001 From: gunshiz Date: Wed, 29 Jul 2026 12:28:13 +0700 Subject: [PATCH] feat: show Discord profiles for form respondents --- .../[id]/result/[submissionId]/client.tsx | 21 +- .../form/[id]/result/[submissionId]/page.tsx | 10 +- app/admin/form/[id]/result/page.tsx | 72 ++++++- components/dropdown-menu-avatar.tsx | 179 +++++++++++------- 4 files changed, 210 insertions(+), 72 deletions(-) diff --git a/app/admin/form/[id]/result/[submissionId]/client.tsx b/app/admin/form/[id]/result/[submissionId]/client.tsx index 5ba999a..4f6af32 100644 --- a/app/admin/form/[id]/result/[submissionId]/client.tsx +++ b/app/admin/form/[id]/result/[submissionId]/client.tsx @@ -27,6 +27,10 @@ import { stripHtml } from "@/lib/utils"; import { HtmlDisplay } from "@/components/html-display"; import { formatStoredAnswer } from "@/lib/form-answer-values"; import { normalizeStoredAnswerSnapshot } from "@/lib/submission-intake"; +import { + DiscordProfileDropdown, + type DiscordMemberProfile, +} from "@/components/dropdown-menu-avatar"; interface Question { id: string; @@ -40,6 +44,7 @@ interface SubmissionDetailClientProps { id: string; userName: string | null; userDiscordId: string | null; + userProfile: DiscordMemberProfile | null; submittedAt: Date; editHistory?: { editedAt: string; oldAnswers: Record }[] | null; }; @@ -78,10 +83,24 @@ export default function SubmissionDetailClient({ Details about this respondent. -
+
Respondent: {userLabel} + {submission.userProfile?.globalName && ( + + + + )}
diff --git a/app/admin/form/[id]/result/[submissionId]/page.tsx b/app/admin/form/[id]/result/[submissionId]/page.tsx index 133d03e..ac8c60d 100644 --- a/app/admin/form/[id]/result/[submissionId]/page.tsx +++ b/app/admin/form/[id]/result/[submissionId]/page.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, ArrowRight } from "lucide-react"; import SubmissionDetailClient from "./client"; import { DeleteSubmissionButton } from "@/components/delete-buttons"; import { RealtimeRefresh } from "@/components/realtime-refresh"; +import { getDiscordMemberProfile } from "@/lib/discord"; interface SubmissionDetailPageProps { params: Promise<{ @@ -38,6 +39,10 @@ export default async function SubmissionDetailPage({ notFound(); } + const respondentProfile = submission.userDiscordId + ? await getDiscordMemberProfile(submission.userDiscordId) + : null; + const questions = await db.query.questions.findMany({ where: (q, { eq }) => eq(q.formId, submission.formId), orderBy: (q, { asc }) => [asc(q.displayOrder)], @@ -77,7 +82,10 @@ export default async function SubmissionDetailPage({
diff --git a/app/admin/form/[id]/result/page.tsx b/app/admin/form/[id]/result/page.tsx index c5f34c6..7041ccd 100644 --- a/app/admin/form/[id]/result/page.tsx +++ b/app/admin/form/[id]/result/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { Badge } from "@/components/ui/badge"; +import { Kbd } from "@/components/ui/kbd"; import { Card, CardContent, @@ -33,6 +34,10 @@ import { import { DeleteAllSubmissionsButton, DeleteSubmissionButton } from "@/components/delete-buttons"; import { ResultsFilters } from "@/components/results-filters"; import { RealtimeRefresh } from "@/components/realtime-refresh"; +import { + getDiscordMemberProfile, + type DiscordMemberProfile, +} from "@/lib/discord"; export const dynamic = "force-dynamic"; @@ -43,6 +48,32 @@ function formatDate(date: Date) { }); } +function RespondentName({ + submission, + profile, +}: { + submission: { + userName: string | null; + userDiscordId: string | null; + }; + profile: DiscordMemberProfile | null; +}) { + const displayName = + profile?.globalName || + profile?.username || + submission.userName || + submission.userDiscordId || + "Anonymous"; + const username = profile?.username || submission.userName; + + return ( + + {displayName} + {username && username !== displayName && ({username})} + + ); +} + export default async function ResultsListPage({ params, searchParams, @@ -59,6 +90,21 @@ export default async function ResultsListPage({ } const currentPage = Math.min(filters.page, pageCount); const listQuery = resultFiltersToSearchParams(filters); + const discordIds = [ + ...new Set( + rows + .map((submission) => submission.userDiscordId) + .filter((discordId): discordId is string => Boolean(discordId)) + ), + ]; + const respondentProfiles = new Map( + await Promise.all( + discordIds.map( + async (discordId) => + [discordId, await getDiscordMemberProfile(discordId)] as const + ) + ) + ); return (
@@ -112,9 +158,16 @@ export default async function ResultsListPage({ href={detailHref} className="font-medium after:absolute after:inset-0 focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring focus-visible:after:ring-inset" > - {submission.userName || - submission.userDiscordId || - "Anonymous"} + {submission.userName && submission.userDiscordId && (

@@ -153,9 +206,16 @@ export default async function ResultsListPage({ href={detailHref} className="after:absolute after:inset-0 focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring focus-visible:after:ring-inset" > - {submission.userName || - submission.userDiscordId || - "Anonymous"} + diff --git a/components/dropdown-menu-avatar.tsx b/components/dropdown-menu-avatar.tsx index 216e949..9ec6053 100644 --- a/components/dropdown-menu-avatar.tsx +++ b/components/dropdown-menu-avatar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { usePathname } from "next/navigation"; import Image from "next/image"; import { LogOut } from "lucide-react"; @@ -42,6 +42,113 @@ function RolePill({ role }: { role: DiscordRole }) { ); } +function DiscordProfileCard({ + userName, + userImage, + profile, + imgError, + onImageError, +}: { + userName: string; + userImage: string; + profile: DiscordMemberProfile | null; + imgError: boolean; + onImageError: () => void; +}) { + const displayAvatar = profile?.avatarUrl || userImage; + const displayName = profile?.globalName || userName; + const username = profile?.username || userName; + const bannerStyle = { + height: 100, + backgroundColor: profile?.accentColor + ? toHex(profile.accentColor) + : "hsl(var(--muted))", + backgroundImage: profile?.bannerUrl ? `url(${profile.bannerUrl})` : "none", + backgroundSize: "cover" as const, + backgroundPosition: "center" as const, + }; + + return ( + <> +

+
+
+
+
+
+ {!imgError && displayAvatar && ( + {displayName} + )} +
+
+
+
+ +
+
+ {displayName} +
+
+ {username} +
+ {profile && profile.roles.length > 0 && ( +
+
+ Roles / บทบาท +
+
+ {profile.roles.map((role) => ( + + ))} +
+
+ )} +
+
+ + ); +} + +export function DiscordProfileDropdown({ + userName, + userImage = "", + profile, + children, +}: { + userName: string; + userImage?: string; + profile: DiscordMemberProfile; + children: ReactNode; +}) { + const [imgError, setImgError] = useState(false); + + return ( + + {children} + + setImgError(true)} + /> + + + ); +} + export function DropdownMenuAvatar({ userName, userImage, @@ -58,17 +165,6 @@ export function DropdownMenuAvatar({ const displayAvatar = profile?.avatarUrl || userImage; const displayName = profile?.globalName || userName; - const username = profile?.username || userName; - - const bannerStyle = { - height: 100, - backgroundColor: profile?.accentColor - ? toHex(profile.accentColor) - : "hsl(var(--muted))", - backgroundImage: profile?.bannerUrl ? `url(${profile.bannerUrl})` : "none", - backgroundSize: "cover" as const, - backgroundPosition: "center" as const, - }; return ( @@ -92,58 +188,13 @@ export function DropdownMenuAvatar({ - {/* Banner Section */} -
- - {/* Body Area */} -
- {/* Avatar - Absolute Positioned to overlap banner and body */} -
-
-
-
- {!imgError && displayAvatar && ( - {displayName} setImgError(true)} - unoptimized - /> - )} -
-
-
-
- - {/* Profile Info Section */} -
-
- {displayName} -
-
- {username} -
- {/* Roles Section */} - {profile && profile.roles.length > 0 && ( -
-
- Roles / บทบาท -
-
- {profile.roles.map((role) => ( - - ))} -
-
- )} -
-
+ setImgError(true)} + /> {/* Footer / Sign Out */}