From 9328f6f27d17dee14a5bfbd3f254dcd1e761287e Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sat, 1 Aug 2026 01:13:16 +0700 Subject: [PATCH] chore: update nextjs and filter render on client side --- app/admin/form/[id]/result/client.tsx | 148 +++++++++++++ app/admin/form/[id]/result/page.tsx | 287 +++----------------------- bun.lock | 18 +- components/results-filters.tsx | 56 ++++- lib/admin-form-results.ts | 11 +- package.json | 2 +- 6 files changed, 235 insertions(+), 287 deletions(-) create mode 100644 app/admin/form/[id]/result/client.tsx diff --git a/app/admin/form/[id]/result/client.tsx b/app/admin/form/[id]/result/client.tsx new file mode 100644 index 0000000..2895176 --- /dev/null +++ b/app/admin/form/[id]/result/client.tsx @@ -0,0 +1,148 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { Kbd } from "@/components/ui/kbd"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination"; +import { DeleteAllSubmissionsButton, DeleteSubmissionButton } from "@/components/delete-buttons"; +import { ResultsFilters } from "@/components/results-filters"; +import type { DiscordMemberProfile } from "@/lib/discord"; + +const RESULTS_PAGE_SIZE = 25; +type ResultFilters = { + q: string; + page: number; + sort: "submittedAt" | "respondent"; + direction: "asc" | "desc"; + edited: "all" | "edited" | "unedited"; + dateFrom: string; + dateTo: string; +}; + +function resultFiltersToSearchParams(filters: ResultFilters) { + const params = new URLSearchParams(); + if (filters.q) params.set("q", filters.q); + if (filters.page > 1) params.set("page", String(filters.page)); + if (filters.sort !== "submittedAt") params.set("sort", filters.sort); + if (filters.direction !== "desc") params.set("direction", filters.direction); + if (filters.edited !== "all") params.set("edited", filters.edited); + if (filters.dateFrom) params.set("dateFrom", filters.dateFrom); + if (filters.dateTo) params.set("dateTo", filters.dateTo); + return params.toString(); +} + +type ResultRow = { + id: string; + userName: string | null; + userDiscordId: string | null; + submittedAt: string; + adminNote: string | null; + editHistory: unknown[]; +}; + +function formatDate(value: string) { + return new Date(value).toLocaleString("en-GB", { + timeZone: "Asia/Bangkok", + hour12: false, + }); +} + +function bangkokDate(value: string) { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Bangkok", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date(value)); + const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${values.year}-${values.month}-${values.day}`; +} + +function RespondentName({ + row, + profile, +}: { + row: ResultRow; + profile: DiscordMemberProfile | null; +}) { + const displayName = profile?.globalName || profile?.username || row.userName || row.userDiscordId || "Anonymous"; + const username = profile?.username || row.userName; + return ( + + {displayName} + {username && username !== displayName && ({username})} + + ); +} + +export default function ResultsClient({ + formId, + initialFilters, + rows, + profiles, +}: { + formId: string; + initialFilters: ResultFilters; + rows: ResultRow[]; + profiles: Record; +}) { + const [filters, setFilters] = useState(initialFilters); + const [page, setPage] = useState(initialFilters.page); + + const filteredRows = useMemo(() => { + const query = filters.q.toLocaleLowerCase(); + return rows + .filter((row) => { + const matchesQuery = !query || `${row.userName ?? ""} ${row.userDiscordId ?? ""}`.toLocaleLowerCase().includes(query); + const edited = row.editHistory.length > 0; + const matchesEdited = filters.edited === "all" || (filters.edited === "edited" ? edited : !edited); + const date = bangkokDate(row.submittedAt); + return matchesQuery && matchesEdited && (!filters.dateFrom || date >= filters.dateFrom) && (!filters.dateTo || date <= filters.dateTo); + }) + .sort((a, b) => { + if (filters.sort === "respondent") { + const result = (a.userName || a.userDiscordId || "").localeCompare(b.userName || b.userDiscordId || ""); + return filters.direction === "asc" ? result : -result; + } + const result = new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime(); + return filters.direction === "asc" ? result : -result; + }); + }, [filters, rows]); + + const pageCount = Math.max(1, Math.ceil(filteredRows.length / RESULTS_PAGE_SIZE)); + const currentPage = Math.min(page, pageCount); + const visibleRows = filteredRows.slice((currentPage - 1) * RESULTS_PAGE_SIZE, currentPage * RESULTS_PAGE_SIZE); + const updateFilters = (next: ResultFilters) => { + const normalized = { ...next, page: 1 }; + setFilters(normalized); + setPage(1); + const query = resultFiltersToSearchParams(normalized); + window.history.replaceState(null, "", `/admin/form/${formId}/result${query ? `?${query}` : ""}`); + }; + + return ( +
+
+
+

Responses

+

{filteredRows.length} matching {filteredRows.length === 1 ? "response" : "responses"}

+
+ {rows.length > 0 && } +
+ + {visibleRows.length === 0 ? ( + No responses foundTry changing or clearing the current filters. + ) : ( + <> + RespondentSubmittedNoteActions + {visibleRows.map((row) => {row.userName && row.userDiscordId &&

{row.userDiscordId}

}
{formatDate(row.submittedAt)}{row.adminNote ?

{row.adminNote}

: No note}
)} +
+
{visibleRows.map((row) => {formatDate(row.submittedAt)}

Note

{row.adminNote ?

{row.adminNote}

:

No note

}
)}
+ + )} + {pageCount > 1 && { event.preventDefault(); setPage(Math.max(1, currentPage - 1)); }} aria-disabled={currentPage === 1} /> event.preventDefault()}>{currentPage} / {pageCount} { event.preventDefault(); setPage(Math.min(pageCount, currentPage + 1)); }} aria-disabled={currentPage === pageCount} />} +
+ ); +} diff --git a/app/admin/form/[id]/result/page.tsx b/app/admin/form/[id]/result/page.tsx index 0e36664..9fcd2f2 100644 --- a/app/admin/form/[id]/result/page.tsx +++ b/app/admin/form/[id]/result/page.tsx @@ -1,79 +1,10 @@ -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, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from "@/components/ui/pagination"; -import { - getFilteredSubmissions, - parseResultFilters, - resultFiltersToSearchParams, - RESULTS_PAGE_SIZE, -} from "@/lib/admin-form-results"; -import { DeleteAllSubmissionsButton, DeleteSubmissionButton } from "@/components/delete-buttons"; -import { ResultsFilters } from "@/components/results-filters"; +import { parseResultFilters, getFilteredSubmissions } from "@/lib/admin-form-results"; +import { getDiscordMemberProfile } from "@/lib/discord"; import { RealtimeRefresh } from "@/components/realtime-refresh"; -import { - getDiscordMemberProfile, - type DiscordMemberProfile, -} from "@/lib/discord"; +import ResultsClient from "./client"; export const dynamic = "force-dynamic"; -function formatDate(date: Date) { - return date.toLocaleString("en-GB", { - timeZone: "Asia/Bangkok", - hour12: false, - }); -} - -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, @@ -82,202 +13,42 @@ export default async function ResultsListPage({ searchParams: Promise>; }) { const [{ id }, rawFilters] = await Promise.all([params, searchParams]); - const filters = parseResultFilters(rawFilters); - const { rows, total } = await getFilteredSubmissions(id, filters); - const pageCount = Math.max(1, Math.ceil(total / RESULTS_PAGE_SIZE)); - if (total > 0 && filters.page > pageCount) { - redirect(`?${resultFiltersToSearchParams(filters, { page: pageCount })}`); - } - const currentPage = Math.min(filters.page, pageCount); - const listQuery = resultFiltersToSearchParams(filters); + const initialFilters = parseResultFilters(rawFilters); + const { rows } = await getFilteredSubmissions(id, parseResultFilters({}), { + paginate: false, + }); const discordIds = [ ...new Set( rows - .map((submission) => submission.userDiscordId) - .filter((discordId): discordId is string => Boolean(discordId)) + .map((row) => row.userDiscordId) + .filter((value): value is string => Boolean(value)) ), ]; - const respondentProfiles = new Map( + const profiles = Object.fromEntries( await Promise.all( - discordIds.map( - async (discordId) => - [discordId, await getDiscordMemberProfile(discordId)] as const - ) + discordIds.map(async (discordId) => [ + discordId, + await getDiscordMemberProfile(discordId), + ]) ) ); return ( -
+ <> -
-
-

Responses

-

- {total} matching {total === 1 ? "response" : "responses"} -

-
- {total > 0 && } -
- - - - {rows.length === 0 ? ( - - - No responses found - - {total === 0 && !listQuery - ? "Responses will appear here after people submit the form." - : "Try changing or clearing the current filters."} - - - - ) : ( - <> - - - - - - Respondent - Submitted - Note - Actions - - - - {rows.map((submission) => { - const detailHref = `/admin/form/${id}/result/${submission.id}${listQuery ? `?return=${encodeURIComponent(listQuery)}` : ""}`; - return ( - - - - - - {submission.userName && submission.userDiscordId && ( -

- {submission.userDiscordId} -

- )} -
- {formatDate(submission.submittedAt)} - - {submission.adminNote ? ( -

- {submission.adminNote} -

- ) : ( - - No note - - )} -
- - - -
- ); - })} -
-
-
-
- -
- {rows.map((submission) => { - const detailHref = `/admin/form/${id}/result/${submission.id}${listQuery ? `?return=${encodeURIComponent(listQuery)}` : ""}`; - return ( - - - - - - - - - {formatDate(submission.submittedAt)} - - - -
-

- Note -

- {submission.adminNote ? ( -

- {submission.adminNote} -

- ) : ( -

- No note -

- )} -
-
- -
-
-
- ); - })} -
- - )} - - {pageCount > 1 && ( - - - - - - - - {currentPage} / {pageCount} - - - - - - - - )} -
+ ({ + id: row.id, + userName: row.userName, + userDiscordId: row.userDiscordId, + submittedAt: row.submittedAt.toISOString(), + adminNote: row.adminNote, + editHistory: Array.isArray(row.editHistory) ? row.editHistory : [], + }))} + profiles={profiles} + /> + ); } diff --git a/bun.lock b/bun.lock index d6ebc9d..18c7dec 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,7 @@ "date-fns": "^4.4.0", "drizzle-orm": "^0.45.2", "lucide-react": "^1.23.0", - "next": "^16.2.10", + "next": "^16.2.12", "next-auth": "^4.24.14", "next-themes": "^0.4.6", "postgres": "^3.4.9", @@ -317,19 +317,19 @@ "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-17ISGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQAAAAAAAA=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -1223,7 +1223,7 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "next": ["next@16.2.10", "", { "dependencies": { "@next/env": "16.2.10", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.10", "@next/swc-darwin-x64": "16.2.10", "@next/swc-linux-arm64-gnu": "16.2.10", "@next/swc-linux-arm64-musl": "16.2.10", "@next/swc-linux-x64-gnu": "16.2.10", "@next/swc-linux-x64-musl": "16.2.10", "@next/swc-win32-arm64-msvc": "16.2.10", "@next/swc-win32-x64-msvc": "16.2.10", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA=="], + "next": ["next@16.2.12", "", { "dependencies": { "@next/env": "16.2.12", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.12", "@next/swc-darwin-x64": "16.2.12", "@next/swc-linux-arm64-gnu": "16.2.12", "@next/swc-linux-arm64-musl": "16.2.12", "@next/swc-linux-x64-gnu": "16.2.12", "@next/swc-linux-x64-musl": "16.2.12", "@next/swc-win32-arm64-msvc": "16.2.12", "@next/swc-win32-x64-msvc": "16.2.12", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw=="], "next-auth": ["next-auth@4.24.14", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", "uuid": "^8.3.2" }, "peerDependencies": { "@auth/core": "0.34.3", "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", "nodemailer": "^7.0.7", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig=="], diff --git a/components/results-filters.tsx b/components/results-filters.tsx index 6fcda7b..98e71c7 100644 --- a/components/results-filters.tsx +++ b/components/results-filters.tsx @@ -72,14 +72,26 @@ function DateFilterPicker({ export function ResultsFilters({ formId, filters, + onFiltersChange, }: { formId: string; filters: ResultFilters; + onFiltersChange?: (filters: ResultFilters) => void; }) { const router = useRouter(); const [values, setValues] = useState(filters); const base = `/admin/form/${formId}/result`; + const updateValues = ( + update: (current: ResultFilters) => ResultFilters + ) => { + setValues((current) => { + const next = update(current); + onFiltersChange?.(next); + return next; + }); + }; + const filterQuery = useMemo(() => { const params = new URLSearchParams(); if (values.q) params.set("q", values.q); @@ -103,6 +115,7 @@ export function ResultsFilters({ }, [filters]); useEffect(() => { + if (onFiltersChange) return; if (filterQuery === appliedQuery) return; const timeout = window.setTimeout(() => { @@ -112,7 +125,7 @@ export function ResultsFilters({ }, 350); return () => window.clearTimeout(timeout); - }, [appliedQuery, base, filterQuery, router]); + }, [appliedQuery, base, filterQuery, onFiltersChange, router]); return (
@@ -120,7 +133,7 @@ export function ResultsFilters({ - setValues((current) => ({ ...current, q: event.target.value })) + updateValues((current) => ({ ...current, q: event.target.value })) } placeholder="Search name or Discord ID" aria-label="Search responses" @@ -128,7 +141,7 @@ export function ResultsFilters({