diff --git a/app/actions/submissions.ts b/app/actions/submissions.ts index b3403cd..859e4c7 100644 --- a/app/actions/submissions.ts +++ b/app/actions/submissions.ts @@ -8,13 +8,12 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth-options"; import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth"; import { sse } from "@/lib/sse"; +import type { FormAnswerValue } from "@/lib/form-answer-values"; import { - decodeCheckboxAnswer, - encodeAnswerValue, - formatStoredAnswer, - type FormAnswerValue, -} from "@/lib/form-answer-values"; -import { stripHtml } from "@/lib/utils"; + answersByTemplateIndex, + normalizeSubmissionAnswers, + submissionUpdateText, +} from "@/lib/submission-intake"; export async function submitForm( formId: string, @@ -36,87 +35,7 @@ export async function submitForm( throw new Error("แบบฟอร์มนี้ยังไม่มีคำถาม"); } - const questionsById = new Map(questionsList.map((question) => [question.id, question])); - const submittedQuestionIds = new Set(); - for (const answer of answersList) { - if (!questionsById.has(answer.questionId) || submittedQuestionIds.has(answer.questionId)) { - throw new Error("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม"); - } - submittedQuestionIds.add(answer.questionId); - } - - const submittedAnswers = new Map( - answersList.map((answer) => [answer.questionId, answer.value]) - ); - const normalizedAnswers = questionsList.map((question) => { - const rawValue = submittedAnswers.get(question.id); - - if (question.type === "checkbox") { - if ( - rawValue !== undefined && - typeof rawValue !== "string" && - (!Array.isArray(rawValue) || - rawValue.some((value) => typeof value !== "string")) - ) { - throw new Error(`รูปแบบคำตอบของ “${stripHtml(question.label)}” ไม่ถูกต้อง`); - } - - const selectedValues = [...new Set(decodeCheckboxAnswer(rawValue))]; - if ( - selectedValues.some( - (value) => !value.trim() || value === "__OTHER__" - ) - ) { - throw new Error(`โปรดระบุคำตอบสำหรับ “${stripHtml(question.label)}”`); - } - if (question.required && selectedValues.length === 0) { - throw new Error(`จำเป็นต้องตอบ “${stripHtml(question.label)}”`); - } - - const standardOptions = question.options ?? []; - const customValues = selectedValues.filter( - (value) => !standardOptions.includes(value) - ); - if ( - (!question.allowOther && customValues.length > 0) || - customValues.length > 1 - ) { - throw new Error(`ตัวเลือกของ “${stripHtml(question.label)}” ไม่ถูกต้อง`); - } - - return { - questionId: question.id, - value: encodeAnswerValue(selectedValues), - displayValue: selectedValues.join(", "), - }; - } - - if (rawValue !== undefined && typeof rawValue !== "string") { - throw new Error(`รูปแบบคำตอบของ “${stripHtml(question.label)}” ไม่ถูกต้อง`); - } - - const value = rawValue ?? ""; - if (value === "__OTHER__") { - throw new Error(`โปรดระบุคำตอบสำหรับ “${stripHtml(question.label)}”`); - } - if (question.required && !value.trim()) { - throw new Error(`จำเป็นต้องตอบ “${stripHtml(question.label)}”`); - } - if ( - question.type === "radio" && - value && - !(question.options ?? []).includes(value) && - !question.allowOther - ) { - throw new Error(`ตัวเลือกของ “${stripHtml(question.label)}” ไม่ถูกต้อง`); - } - - return { - questionId: question.id, - value, - displayValue: value, - }; - }); + const normalizedAnswers = normalizeSubmissionAnswers(questionsList, answersList); const result = await db.transaction(async (tx) => { const existingSubmission = await tx.query.submissions.findFirst({ @@ -186,37 +105,10 @@ export async function submitForm( if (form.discordWebhookUrl && templateToUse) { try { - const questionOrderMap = questionsList.reduce((acc, q, index) => { - acc[q.id] = String(index + 1); - return acc; - }, {} as Record); - - const answersDict = normalizedAnswers.reduce((acc, answer) => { - const idx = questionOrderMap[answer.questionId]; - if (idx) { - acc[idx] = answer.displayValue; - } - return acc; - }, {} as Record); - - let updatesText = ""; - if (result.isUpdate) { - const changed: string[] = []; - for (const answer of normalizedAnswers) { - const question = questionsById.get(answer.questionId); - const oldValue = result.oldAnswersDict[answer.questionId]; - const oldDisplayValue = question - ? formatStoredAnswer(oldValue, question.type) - : oldValue; - if (oldDisplayValue !== answer.displayValue) { - const questionTitle = stripHtml(question?.label || "Question"); - changed.push( - `**${questionTitle}**: \`${oldDisplayValue || "Empty"}\` ➡️ \`${answer.displayValue || "Empty"}\`` - ); - } - } - updatesText = changed.length > 0 ? changed.join("\n") : "No changes made."; - } + const answersDict = answersByTemplateIndex(questionsList, normalizedAnswers); + const updatesText = result.isUpdate + ? submissionUpdateText(questionsList, normalizedAnswers, result.oldAnswersDict) + : ""; let message = templateToUse; // Replace {1}, {2}, etc. with value, and new variables diff --git a/app/form/layout.tsx b/app/form/layout.tsx index 2aeaaf3..c279c68 100644 --- a/app/form/layout.tsx +++ b/app/form/layout.tsx @@ -2,6 +2,7 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth-options"; import { FormLayoutClient, LoginButton } from "./client"; import { Metadata } from "next"; +import { SpaceBackground } from "@/components/space-background"; import { ThemeToggle } from "@/components/theme-toggle"; import { getSessionDiscordId } from "@/lib/auth"; import { getDiscordMemberProfile } from "@/lib/discord"; @@ -44,8 +45,9 @@ export default async function FormLayout({ } return ( -
-
+
+ +
Form @@ -64,7 +66,7 @@ export default async function FormLayout({
-
+
{children}
diff --git a/app/form/page.tsx b/app/form/page.tsx index 6e09eb1..6800a2a 100644 --- a/app/form/page.tsx +++ b/app/form/page.tsx @@ -31,8 +31,8 @@ export default async function FormsListingPage() { return ( <> -
-

Form ทั้งหมด

+
+

Form ทั้งหมด

{visibleForms.length === 0 ? (

ตอนนี้ยังไม่เปิดรับสัก Form

) : ( diff --git a/app/leaderboard/loading.tsx b/app/leaderboard/loading.tsx deleted file mode 100644 index 92f8f7a..0000000 --- a/app/leaderboard/loading.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function LeaderboardLoading() { - return ( -
-
- - -
- - - -
- -
-
- ); -} diff --git a/app/leaderboard/vc/page.tsx b/app/leaderboard/vc/page.tsx index 56579e4..69cc602 100644 --- a/app/leaderboard/vc/page.tsx +++ b/app/leaderboard/vc/page.tsx @@ -7,7 +7,7 @@ import { LeaderboardShell } from "@/components/leaderboard/leaderboard-shell"; import { RealtimeRefresh } from "@/components/realtime-refresh"; import { getVoiceLeaderboardPage } from "@/lib/leaderboard/database"; import { getCurrentDiscordVoiceChannels } from "@/lib/leaderboard/discord"; -import { normalizeRequestedPage } from "@/lib/leaderboard/ranking"; +import { normalizeLeaderboardSearch, normalizeRequestedPage } from "@/lib/leaderboard/ranking"; export const dynamic = "force-dynamic"; @@ -23,12 +23,14 @@ type VoiceLeaderboardPageProps = { searchParams: Promise<{ channel?: string | string[]; page?: string | string[]; + q?: string | string[]; }>; }; -function voiceLeaderboardUrl(page: number, channelId?: string) { +function voiceLeaderboardUrl(page: number, channelId?: string, searchQuery?: string) { const params = new URLSearchParams(); if (channelId) params.set("channel", channelId); + if (searchQuery) params.set("q", searchQuery); if (page > 1) params.set("page", String(page)); const query = params.toString(); return query ? `/leaderboard/vc?${query}` : "/leaderboard/vc"; @@ -39,6 +41,7 @@ export default async function VoiceLeaderboardPage({ }: VoiceLeaderboardPageProps) { const query = await searchParams; const requestedPage = normalizeRequestedPage(query.page); + const searchQuery = normalizeLeaderboardSearch(query.q); let channels; try { @@ -50,6 +53,7 @@ export default async function VoiceLeaderboardPage({ active="vc" title="อันดับเวลาในห้องเสียง" description="รวมเวลาจากห้องเสียงและห้องเวทีที่ยังอยู่ใน Discord ตอนนี้ หรือเลือกดูเฉพาะห้องได้" + searchQuery={searchQuery} > @@ -72,6 +76,7 @@ export default async function VoiceLeaderboardPage({ channels.map((channel) => channel.id), requestedPage, selectedChannelId, + searchQuery, ); } catch (error) { console.error("Failed to load voice leaderboard", error); @@ -80,10 +85,12 @@ export default async function VoiceLeaderboardPage({ active="vc" title="อันดับเวลาในห้องเสียง" description="รวมเวลาจากห้องเสียงและห้องเวทีที่ยังอยู่ใน Discord ตอนนี้ หรือเลือกดูเฉพาะห้องได้" + searchQuery={searchQuery} > @@ -94,7 +101,7 @@ export default async function VoiceLeaderboardPage({ invalidChannel || (query.page !== undefined && query.page !== String(data.page)) ) { - redirect(voiceLeaderboardUrl(data.page, selectedChannelId)); + redirect(voiceLeaderboardUrl(data.page, selectedChannelId, searchQuery)); } return ( @@ -102,6 +109,7 @@ export default async function VoiceLeaderboardPage({ active="vc" title="อันดับเวลาในห้องเสียง" description="รวมเวลาจากห้องเสียงและห้องเวทีที่ยังอยู่ใน Discord ตอนนี้ หรือเลือกดูเฉพาะห้องได้" + searchQuery={searchQuery} > ); diff --git a/app/leaderboard/xp/page.tsx b/app/leaderboard/xp/page.tsx index a56a8a1..ea95e8c 100644 --- a/app/leaderboard/xp/page.tsx +++ b/app/leaderboard/xp/page.tsx @@ -5,7 +5,7 @@ import { LeaderboardError } from "@/components/leaderboard/leaderboard-error"; import { LeaderboardShell } from "@/components/leaderboard/leaderboard-shell"; import { RealtimeRefresh } from "@/components/realtime-refresh"; import { getXpLeaderboardPage } from "@/lib/leaderboard/database"; -import { normalizeRequestedPage } from "@/lib/leaderboard/ranking"; +import { normalizeLeaderboardSearch, normalizeRequestedPage } from "@/lib/leaderboard/ranking"; export const dynamic = "force-dynamic"; @@ -20,6 +20,7 @@ export const metadata: Metadata = { type XpLeaderboardPageProps = { searchParams: Promise<{ page?: string | string[]; + q?: string | string[]; }>; }; @@ -28,10 +29,11 @@ export default async function XpLeaderboardPage({ }: XpLeaderboardPageProps) { const query = await searchParams; const requestedPage = normalizeRequestedPage(query.page); + const searchQuery = normalizeLeaderboardSearch(query.q); let data; try { - data = await getXpLeaderboardPage(requestedPage); + data = await getXpLeaderboardPage(requestedPage, searchQuery); } catch (error) { console.error("Failed to load XP leaderboard", error); return ( @@ -39,6 +41,7 @@ export default async function XpLeaderboardPage({ active="xp" title="อันดับ XP" description="จัดอันดับจาก XP รวมของสมาชิกที่มีเลเวล 1 ขึ้นไป อันดับที่มี XP เท่ากันจะแชร์อันดับเดียวกัน" + searchQuery={searchQuery} > @@ -46,7 +49,11 @@ export default async function XpLeaderboardPage({ } if (query.page !== undefined && query.page !== String(data.page)) { - redirect(data.page > 1 ? `/leaderboard/xp?page=${data.page}` : "/leaderboard/xp"); + const params = new URLSearchParams(); + if (searchQuery) params.set("q", searchQuery); + if (data.page > 1) params.set("page", String(data.page)); + const redirectQuery = params.toString(); + redirect(redirectQuery ? `/leaderboard/xp?${redirectQuery}` : "/leaderboard/xp"); } return ( @@ -54,9 +61,10 @@ export default async function XpLeaderboardPage({ active="xp" title="อันดับ XP" description="จัดอันดับจาก XP รวมของสมาชิกที่มีเลเวล 1 ขึ้นไป อันดับที่มี XP เท่ากันจะแชร์อันดับเดียวกัน" + searchQuery={searchQuery} > - + ); } diff --git a/components/leaderboard/channel-select.tsx b/components/leaderboard/channel-select.tsx index 1ddb85e..4506da4 100644 --- a/components/leaderboard/channel-select.tsx +++ b/components/leaderboard/channel-select.tsx @@ -17,9 +17,11 @@ const ALL_CHANNELS = "all"; export function ChannelSelect({ channels, selectedChannelId, + searchQuery, }: { channels: DiscordVoiceChannel[]; selectedChannelId?: string; + searchQuery?: string; }) { const router = useRouter(); @@ -31,6 +33,7 @@ export function ChannelSelect({ onValueChange={(value) => { const params = new URLSearchParams(); if (value !== ALL_CHANNELS) params.set("channel", value); + if (searchQuery) params.set("q", searchQuery); const query = params.toString(); router.push(query ? `/leaderboard/vc?${query}` : "/leaderboard/vc"); }} diff --git a/components/leaderboard/leaderboard-board.tsx b/components/leaderboard/leaderboard-board.tsx index 080b63a..0df444b 100644 --- a/components/leaderboard/leaderboard-board.tsx +++ b/components/leaderboard/leaderboard-board.tsx @@ -32,11 +32,13 @@ type LeaderboardBoardProps = | { kind: "xp"; data: LeaderboardPage; + searchQuery: string; channelId?: never; } | { kind: "vc"; data: LeaderboardPage; + searchQuery: string; channelId?: string; }; @@ -115,7 +117,7 @@ function Podium({ data }: LeaderboardBoardProps) { } export function LeaderboardBoard(props: LeaderboardBoardProps) { - const { kind, data, channelId } = props; + const { kind, data, channelId, searchQuery } = props; const firstRow = data.totalRows === 0 ? 0 : (data.page - 1) * data.pageSize + 1; @@ -133,7 +135,9 @@ export function LeaderboardBoard(props: LeaderboardBoardProps) { {data.totalRows > 0 ? `แสดง ${firstRow}–${lastRow} จาก ${formatInteger(String(data.totalRows))} คน` - : "ยังไม่มีข้อมูลในขอบเขตนี้"} + : searchQuery + ? "ไม่พบสมาชิกที่ตรงกับคำค้นหานี้" + : "ยังไม่มีข้อมูลในขอบเขตนี้"} @@ -181,6 +185,7 @@ export function LeaderboardBoard(props: LeaderboardBoardProps) { page={data.page} totalPages={data.totalPages} channelId={channelId} + searchQuery={searchQuery} /> diff --git a/components/leaderboard/leaderboard-pagination.tsx b/components/leaderboard/leaderboard-pagination.tsx index 6c199b9..b7a5164 100644 --- a/components/leaderboard/leaderboard-pagination.tsx +++ b/components/leaderboard/leaderboard-pagination.tsx @@ -16,9 +16,15 @@ function visiblePages(page: number, totalPages: number) { .sort((a, b) => a - b); } -function pageHref(path: string, page: number, channelId?: string) { +function pageHref( + path: string, + page: number, + channelId?: string, + searchQuery?: string, +) { const params = new URLSearchParams(); if (channelId) params.set("channel", channelId); + if (searchQuery) params.set("q", searchQuery); if (page > 1) params.set("page", String(page)); const query = params.toString(); return query ? `${path}?${query}` : path; @@ -29,11 +35,13 @@ export function LeaderboardPagination({ page, totalPages, channelId, + searchQuery, }: { path: string; page: number; totalPages: number; channelId?: string; + searchQuery?: string; }) { if (totalPages <= 1) return null; const pages = visiblePages(page, totalPages); @@ -44,7 +52,7 @@ export function LeaderboardPagination({ {page > 1 && ( @@ -60,7 +68,7 @@ export function LeaderboardPagination({ )} @@ -73,7 +81,7 @@ export function LeaderboardPagination({ {page < totalPages && ( diff --git a/components/leaderboard/leaderboard-search.tsx b/components/leaderboard/leaderboard-search.tsx new file mode 100644 index 0000000..3ff9da3 --- /dev/null +++ b/components/leaderboard/leaderboard-search.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Search, X } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; + +export function LeaderboardSearch({ query }: { query: string }) { + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + + function navigate(nextValue: string) { + const params = new URLSearchParams(searchParams); + const trimmed = nextValue.trim(); + + params.delete("page"); + if (trimmed) { + params.set("q", trimmed); + } else { + params.delete("q"); + } + + const nextQuery = params.toString(); + router.push(nextQuery ? `${pathname}?${nextQuery}` : pathname); + } + + return ( +
{ + event.preventDefault(); + const formData = new FormData(event.currentTarget); + navigate(String(formData.get("q") ?? "")); + }} + > + + ค้นหาสมาชิก +
+ + {query && ( + + )} + +
+
+
+ ); +} diff --git a/components/leaderboard/leaderboard-shell.tsx b/components/leaderboard/leaderboard-shell.tsx index 847ecf2..c715da6 100644 --- a/components/leaderboard/leaderboard-shell.tsx +++ b/components/leaderboard/leaderboard-shell.tsx @@ -1,8 +1,10 @@ import type { ReactNode } from "react"; -import Image from "next/image"; import Link from "next/link"; import { ArrowLeft } from "lucide-react"; +import { LeaderboardSearch } from "@/components/leaderboard/leaderboard-search"; import { LeaderboardTabs } from "@/components/leaderboard/leaderboard-tabs"; +import { SpaceBackground } from "@/components/space-background"; +import { ThemeToggle } from "@/components/theme-toggle"; import { Button } from "@/components/ui/button"; import { Card, @@ -11,12 +13,12 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import Background from "@/public/background/space.webp"; type LeaderboardShellProps = { active: "xp" | "vc"; title: string; description: string; + searchQuery: string; children: ReactNode; }; @@ -24,28 +26,21 @@ export function LeaderboardShell({ active, title, description, + searchQuery, children, }: LeaderboardShellProps) { return ( -
-
- -
+
+
-