diff --git a/app/admin/form/[id]/client.tsx b/app/admin/form/[id]/client.tsx
index ee7f87f..1100239 100644
--- a/app/admin/form/[id]/client.tsx
+++ b/app/admin/form/[id]/client.tsx
@@ -19,7 +19,7 @@ import {
deleteQuestion,
bulkUpdateQuestions,
} from "@/app/actions/questions";
-import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Image as ImageIcon, X } from "lucide-react";
+import { Plus, Trash, ArrowUp, ArrowDown, GripVertical, Loader2, Image as ImageIcon, X } from "lucide-react";
import Image from 'next/image';
import { format, parseISO } from "date-fns";
import { DateTimePicker } from "@/components/date-time-picker";
@@ -43,8 +43,10 @@ import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
+ useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
import {
useDebouncedAutoSave,
} from "@/hooks/use-debounced-autosave";
@@ -73,10 +75,7 @@ import {
getQuestionResponseType,
type QuestionResponseType,
} from "@/lib/form/question-response-type";
-import {
- SaveStatusIndicator,
- SortableQuestion,
-} from "@/components/admin-form-editor-parts";
+import { SaveStatusIndicator } from "@/components/save-status-indicator";
const RichTextEditor = dynamic(
() =>
@@ -91,6 +90,50 @@ const RichTextEditor = dynamic(
},
);
+function SortableQuestion({
+ id,
+ position,
+ children,
+}: {
+ id: string;
+ position: number;
+ children: React.ReactNode;
+}) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id });
+
+ return (
+
+
+
+
+ {children}
+
+ );
+}
+
interface Question {
id: string;
formId: string;
diff --git a/app/admin/form/[id]/extra/client.tsx b/app/admin/form/[id]/extra/client.tsx
index d45e227..7b97eca 100644
--- a/app/admin/form/[id]/extra/client.tsx
+++ b/app/admin/form/[id]/extra/client.tsx
@@ -9,7 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { testDiscordWebhook, updateForm } from "@/app/admin/form/actions";
-import { Loader2, ChevronDown, Check, AlertCircle, Send } from "lucide-react";
+import { Loader2, ChevronDown, Send } from "lucide-react";
import { getGuildRoles } from "@/app/actions/discord";
import type { DiscordRole } from "@/lib/discord/discord";
import {
@@ -26,9 +26,9 @@ import {
} from "@/lib/discord/discord-webhook-settings";
import {
useDebouncedAutoSave,
- type AutoSaveStatus,
} from "@/hooks/use-debounced-autosave";
import { Badge } from "@/components/ui/badge";
+import { SaveStatusIndicator } from "@/components/save-status-indicator";
interface FormExtraClientProps {
form: {
@@ -408,47 +408,3 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
);
}
-
-function SaveStatusIndicator({
- status,
- error,
- onRetry,
-}: {
- status: AutoSaveStatus;
- error: string | null;
- onRetry: () => Promise;
-}) {
- if (status === "clean") return null;
-
- return (
-
- {status === "dirty" && "Unsaved"}
- {status === "saving" && (
- <>
-
- Saving
- >
- )}
- {status === "saved" && (
- <>
-
- Saved
- >
- )}
- {status === "error" && (
- <>
-
- {error || "Could not save."}
- void onRetry().catch(() => undefined)}
- >
- Retry
-
- >
- )}
-
- );
-}
diff --git a/app/admin/form/[id]/layout.tsx b/app/admin/form/[id]/layout.tsx
index 80c556a..76f9195 100644
--- a/app/admin/form/[id]/layout.tsx
+++ b/app/admin/form/[id]/layout.tsx
@@ -1,5 +1,5 @@
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
-import { FormAdminSidebar } from "@/components/form-admin-sidebar";
+import { FormAdminSidebar } from "./sidebar-client";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/auth-options";
diff --git a/app/admin/form/[id]/result/[submissionId]/client.tsx b/app/admin/form/[id]/result/[submissionId]/client.tsx
index fd03adc..784d9e7 100644
--- a/app/admin/form/[id]/result/[submissionId]/client.tsx
+++ b/app/admin/form/[id]/result/[submissionId]/client.tsx
@@ -19,13 +19,11 @@ import {
ContextMenuGroup
} from "@/components/ui/context-menu";
import {
- AlertCircle,
Calendar,
Check,
Clock,
Copy,
ExternalLink,
- Loader2,
Maximize2,
Users,
} from "lucide-react";
@@ -47,12 +45,12 @@ import { normalizeStoredAnswerSnapshot } from "@/lib/form/submission-intake";
import { updateSubmissionAdminNote } from "@/app/admin/form/actions";
import {
useDebouncedAutoSave,
- type AutoSaveStatus,
} from "@/hooks/use-debounced-autosave";
import {
DiscordProfileDropdown,
type DiscordMemberProfile,
} from "@/components/dropdown-menu-avatar";
+import { SaveStatusIndicator } from "@/components/save-status-indicator";
interface Question {
id: string;
@@ -181,6 +179,7 @@ export default function SubmissionDetailClient({
status={noteAutoSave.status}
error={noteAutoSave.error}
onRetry={noteAutoSave.retry}
+ fallbackError="Could not save the note."
/>
@@ -351,54 +350,6 @@ export default function SubmissionDetailClient({
);
}
-function SaveStatusIndicator({
- status,
- error,
- onRetry,
-}: {
- status: AutoSaveStatus;
- error: string | null;
- onRetry: () => Promise;
-}) {
- if (status === "clean") return null;
-
- return (
-
- {status === "dirty" && "Unsaved"}
- {status === "saving" && (
- <>
-
- Saving
- >
- )}
- {status === "saved" && (
- <>
-
- Saved
- >
- )}
- {status === "error" && (
- <>
-
- {error || "Could not save the note."}
- void onRetry().catch(() => undefined)}
- >
- Retry
-
- >
- )}
-
- );
-}
-
function AnswerImagePreview({ src, label }: { src: string; label: string }) {
const [unavailable, setUnavailable] = useState(false);
diff --git a/components/results-filters.tsx b/app/admin/form/[id]/result/client.tsx
similarity index 100%
rename from components/results-filters.tsx
rename to app/admin/form/[id]/result/client.tsx
diff --git a/app/admin/form/[id]/result/page.tsx b/app/admin/form/[id]/result/page.tsx
index f1f2c14..c612c75 100644
--- a/app/admin/form/[id]/result/page.tsx
+++ b/app/admin/form/[id]/result/page.tsx
@@ -12,7 +12,7 @@ import {
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DeleteAllSubmissionsButton, DeleteSubmissionButton } from "@/components/delete-buttons";
import { RealtimeRefresh } from "@/components/realtime-refresh";
-import { ResultsFilters } from "@/components/results-filters";
+import { ResultsFilters } from "./client";
import { getDiscordMemberProfile, type DiscordMemberProfile } from "@/lib/discord/discord";
import {
getFilteredSubmissions,
diff --git a/components/form-admin-sidebar.tsx b/app/admin/form/[id]/sidebar-client.tsx
similarity index 100%
rename from components/form-admin-sidebar.tsx
rename to app/admin/form/[id]/sidebar-client.tsx
diff --git a/components/link-carousel.tsx b/app/client.tsx
similarity index 100%
rename from components/link-carousel.tsx
rename to app/client.tsx
diff --git a/app/form/[id]/page.tsx b/app/form/[id]/page.tsx
index 07c7709..83e0ddc 100644
--- a/app/form/[id]/page.tsx
+++ b/app/form/[id]/page.tsx
@@ -1,9 +1,8 @@
import { db } from "@/db";
-import { FormFillerClient } from "../client";
+import { FormFillerClient, LoginDialog } from "../client";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/auth-options";
import { notFound } from "next/navigation";
-import LoginDialog from "@/components/login-dialog";
import { canAccessForm, getSessionDiscordId } from "@/lib/auth/auth";
import { RealtimeRefresh } from "@/components/realtime-refresh";
import {
diff --git a/app/form/client.tsx b/app/form/client.tsx
index 5190e42..18bca8e 100644
--- a/app/form/client.tsx
+++ b/app/form/client.tsx
@@ -20,7 +20,14 @@ import { Checkbox } from "@/components/ui/checkbox";
import { stripHtml } from "@/lib/utils";
import { HtmlDisplay } from "@/components/html-display";
import { FormattedText } from "@/components/formatted-text";
-import { Dialog, DialogContent, DialogTrigger, DialogTitle } from "@/components/ui/dialog";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { submitForm, deleteOwnSubmission } from "@/app/actions/submissions";
@@ -109,6 +116,42 @@ export function LoginButton() {
);
}
+export function LoginDialog() {
+ return (
+
+ event.preventDefault()}
+ >
+
+
+ เข้าสู่ระบบก่อนกรอกแบบฟอร์ม
+
+
+ ระบบใช้บัญชี Discord เพื่อตรวจสอบสิทธิ์และเก็บคำตอบหนึ่งชุดต่อคน
+
+
+
+ signIn("discord", { callbackUrl: window.location.href })
+ }
+ >
+
+
+
+ ดำเนินการต่อด้วย Discord
+
+
+
+ );
+}
+
const LS_KEY = (formId: string) => `form-draft-${formId}`;
export function FormFillerClient({
diff --git a/app/page.tsx b/app/page.tsx
index bf1152e..77d5bc2 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,20 +1,109 @@
-import { config } from "@/lib/config";
+import type { ComponentType, SVGProps } from "react";
+import { config, type IconName, type LinkConfig } from "@/lib/config";
import { Card, CardHeader } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
import Image from "next/image";
+import Link from "next/link";
import { Finger_Paint } from "next/font/google";
import { getFollowerCounts } from "@/lib/followers";
-import { LinkCard } from "@/components/LinkCard";
import { headers } from "next/headers";
-import { Users } from "lucide-react";
+import { Trophy, Users } from "lucide-react";
import { Kbd } from "@/components/ui/kbd";
import Background from "@/public/background/space.webp";
import { trackUniqueVisitor } from "@/lib/visitor/unique-visitors";
+import {
+ formatCount,
+ getCountForLink,
+ type FollowerCounts,
+} from "@/lib/followers/utils";
+import {
+ DiscordIcon,
+ DonateIcon,
+ FormIcon,
+ RobloxIcon,
+ TiktokIcon,
+ YoutubeIcon,
+} from "@/lib/icons";
+import { LinkCarousel } from "./client";
const fingerPaint = Finger_Paint({
weight: "400",
subsets: ["latin"],
});
+const iconMap: Record>> = {
+ roblox: RobloxIcon,
+ discord: DiscordIcon,
+ tiktok: TiktokIcon,
+ youtube: YoutubeIcon,
+ form: FormIcon,
+ donate: DonateIcon,
+ leaderboard: Trophy,
+};
+
+function LinkList({
+ counts,
+ label,
+ links,
+}: {
+ counts: FollowerCounts;
+ label: string;
+ links: readonly LinkConfig[];
+}) {
+ return (
+
+ {links.map((item) => {
+ const IconComponent = iconMap[item.icon];
+ const count = getCountForLink(item.name, counts);
+ const external = !item.link.startsWith("/");
+
+ return (
+
+
+
+
+
+
+
+
+
+ {item.name}
+
+ {item.desc && (
+
+ {item.desc}
+
+ )}
+
+
+ {item.icon === "donate" ? (
+
+ ขึ้นจอ 10 บาท+
+
+ ) : count !== null ? (
+
+ {formatCount(count.count)}{" "}
+ {count.label}
+
+ ) : null}
+
+
+
+ );
+ })}
+
+ );
+}
+
export default async function Home() {
const visitorCountPromise = headers()
.then(trackUniqueVisitor)
@@ -87,7 +176,16 @@ export default async function Home() {
-
+ }
+ secondary={
+
+ }
+ />
);
diff --git a/components/LinkCard.tsx b/components/LinkCard.tsx
deleted file mode 100644
index 1ef3041..0000000
--- a/components/LinkCard.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import type { ComponentType, SVGProps } from "react";
-import { Button } from "@/components/ui/button";
-import Link from "next/link";
-import { Trophy } from "lucide-react";
-import type { LinkConfig, IconName } from "@/lib/config";
-import {
- getCountForLink,
- formatCount,
- type FollowerCounts,
-} from "@/lib/followers/utils";
-import { RobloxIcon, DiscordIcon, TiktokIcon, YoutubeIcon, FormIcon, DonateIcon } from "@/lib/icons";
-import { LinkCarousel } from "@/components/link-carousel";
-
-const iconMap: Record>> = {
- roblox: RobloxIcon,
- discord: DiscordIcon,
- tiktok: TiktokIcon,
- youtube: YoutubeIcon,
- form: FormIcon,
- donate: DonateIcon,
- leaderboard: Trophy,
-};
-
-function LinkList({
- counts,
- label,
- links,
-}: {
- counts: FollowerCounts;
- label: string;
- links: readonly LinkConfig[];
-}) {
- return (
-
- {links.map((item) => {
- const IconComponent = iconMap[item.icon];
- const count = getCountForLink(item.name, counts);
- const external = !item.link.startsWith("/");
-
- return (
-
-
-
-
-
-
-
-
-
- {item.name}
-
- {item.desc && (
-
- {item.desc}
-
- )}
-
-
- {item.icon === "donate" ? (
-
- ขึ้นจอ 10 บาท+
-
- ) : count !== null ? (
-
- {formatCount(count.count)}{" "}
- {count.label}
-
- ) : null}
-
-
-
- );
- })}
-
- );
-}
-
-export function LinkCard({ counts, links1, links2 }: { counts: FollowerCounts; links1: readonly LinkConfig[]; links2: readonly LinkConfig[] }) {
- return (
- }
- secondary={ }
- />
- );
-}
diff --git a/components/admin-form-editor-parts.tsx b/components/admin-form-editor-parts.tsx
deleted file mode 100644
index 9332522..0000000
--- a/components/admin-form-editor-parts.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-"use client";
-
-import type React from "react";
-import { AlertCircle, Check, GripVertical, Loader2 } from "lucide-react";
-import { useSortable } from "@dnd-kit/sortable";
-import { CSS } from "@dnd-kit/utilities";
-import { Button } from "@/components/ui/button";
-import type { AutoSaveStatus } from "@/hooks/use-debounced-autosave";
-
-export function SaveStatusIndicator({
- status,
- error,
- onRetry,
-}: {
- status: AutoSaveStatus;
- error: string | null;
- onRetry: () => Promise;
-}) {
- if (status === "clean") return null;
-
- return (
-
- {status === "dirty" && "Unsaved"}
- {status === "saving" && (
- <>
-
- Saving
- >
- )}
- {status === "saved" && (
- <>
-
- Saved
- >
- )}
- {status === "error" && (
- <>
-
- {error || "Could not save."}
- void onRetry().catch(() => undefined)}
- >
- Retry
-
- >
- )}
-
- );
-}
-
-export function SortableQuestion({
- id,
- position,
- children,
-}: {
- id: string;
- position: number;
- children: React.ReactNode;
-}) {
- const {
- attributes,
- listeners,
- setNodeRef,
- transform,
- transition,
- isDragging,
- } = useSortable({ id });
-
- return (
-
-
-
-
- {children}
-
- );
-}
diff --git a/components/login-dialog.tsx b/components/login-dialog.tsx
deleted file mode 100644
index fbddfa2..0000000
--- a/components/login-dialog.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-"use client";
-
-import { signIn } from "next-auth/react";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-
-export default function LoginDialog() {
- return (
-
- event.preventDefault()}
- >
-
-
- เข้าสู่ระบบก่อนกรอกแบบฟอร์ม
-
-
- ระบบใช้บัญชี Discord เพื่อตรวจสอบสิทธิ์และเก็บคำตอบหนึ่งชุดต่อคน
-
-
- signIn("discord", { callbackUrl: window.location.href })}
- >
-
-
-
- ดำเนินการต่อด้วย Discord
-
-
-
- );
-}
diff --git a/components/save-status-indicator.tsx b/components/save-status-indicator.tsx
new file mode 100644
index 0000000..0834af7
--- /dev/null
+++ b/components/save-status-indicator.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { AlertCircle, Check, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import type { AutoSaveStatus } from "@/hooks/use-debounced-autosave";
+
+export function SaveStatusIndicator({
+ status,
+ error,
+ onRetry,
+ fallbackError = "Could not save.",
+}: {
+ status: AutoSaveStatus;
+ error: string | null;
+ onRetry: () => Promise;
+ fallbackError?: string;
+}) {
+ if (status === "clean") return null;
+
+ return (
+
+ {status === "dirty" && "Unsaved"}
+ {status === "saving" && (
+ <>
+
+ Saving
+ >
+ )}
+ {status === "saved" && (
+ <>
+
+ Saved
+ >
+ )}
+ {status === "error" && (
+ <>
+
+ {error || fallbackError}
+ void onRetry().catch(() => undefined)}
+ >
+ Retry
+
+ >
+ )}
+
+ );
+}
diff --git a/components/space-background.tsx b/components/space-background.tsx
deleted file mode 100644
index b4e2473..0000000
--- a/components/space-background.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import Image from "next/image";
-import Background from "@/public/background/space.webp";
-
-export function SpaceBackground() {
- return (
-
-
-
- );
-}
diff --git a/docs/component-usage-audit.md b/docs/component-usage-audit.md
new file mode 100644
index 0000000..097293b
--- /dev/null
+++ b/docs/component-usage-audit.md
@@ -0,0 +1,58 @@
+# Component Usage Audit
+
+This audit counts source files that import each component. “Used once” means a component file is imported by exactly one source file; the exported component may still be rendered multiple times inside that importer.
+
+## Consolidated route-specific components
+
+The following one-importer files were removed from the shared `components` directory:
+
+| Previous component | New location |
+| --- | --- |
+| `components/LinkCard.tsx` | Static link-list rendering is in [`app/page.tsx`](../app/page.tsx). |
+| `components/link-carousel.tsx` | The interactive boundary is in [`app/client.tsx`](../app/client.tsx). |
+| `components/admin-form-editor-parts.tsx` | `SortableQuestion` is in [`app/admin/form/[id]/client.tsx`](../app/admin/form/%5Bid%5D/client.tsx); `SaveStatusIndicator` is now shared. |
+| `components/form-admin-sidebar.tsx` | [`app/admin/form/[id]/sidebar-client.tsx`](../app/admin/form/%5Bid%5D/sidebar-client.tsx) |
+| `components/login-dialog.tsx` | [`app/form/client.tsx`](../app/form/client.tsx) |
+| `components/results-filters.tsx` | [`app/admin/form/[id]/result/client.tsx`](../app/admin/form/%5Bid%5D/result/client.tsx) |
+| `components/space-background.tsx` | Removed because the homepage already renders its background directly and the component had no imports. |
+
+These route client files remain separate where hooks, event handlers, or browser APIs require a narrow Next.js client boundary.
+
+## Shared component created from duplicated code
+
+[`components/save-status-indicator.tsx`](../components/save-status-indicator.tsx) is imported by all three autosaving admin clients:
+
+- [`app/admin/form/[id]/client.tsx`](../app/admin/form/%5Bid%5D/client.tsx)
+- [`app/admin/form/[id]/extra/client.tsx`](../app/admin/form/%5Bid%5D/extra/client.tsx)
+- [`app/admin/form/[id]/result/[submissionId]/client.tsx`](../app/admin/form/%5Bid%5D/result/%5BsubmissionId%5D/client.tsx)
+
+This replaced three nearly identical `SaveStatusIndicator` implementations.
+
+## Remaining one-importer shared components
+
+| Component | Why it remains shared |
+| --- | --- |
+| [`components/admin-shell.tsx`](../components/admin-shell.tsx) | It is transitively used by five admin routes through `admin-shell-server.tsx` and provides the client half of a server/client boundary. |
+| [`components/formatted-text.tsx`](../components/formatted-text.tsx) | It is generic and rendered twice by the public form client. |
+| [`components/guarded-link.tsx`](../components/guarded-link.tsx) | It is generic autosave-safe navigation and is rendered repeatedly by the form admin sidebar. |
+| [`components/page-progress.tsx`](../components/page-progress.tsx) | It is intentionally a root-layout singleton. |
+| [`components/rich-text-editor.tsx`](../components/rich-text-editor.tsx) | It is a generic editor control, rendered twice, and kept behind a dynamic client import. |
+| [`components/theme-provider.tsx`](../components/theme-provider.tsx) | It is intentionally a root-layout provider singleton. |
+
+## One-importer shadcn primitives
+
+The following design-system primitives each currently have one importer:
+
+- [`components/ui/alert.tsx`](../components/ui/alert.tsx)
+- [`components/ui/avatar.tsx`](../components/ui/avatar.tsx)
+- [`components/ui/context-menu.tsx`](../components/ui/context-menu.tsx)
+- [`components/ui/form.tsx`](../components/ui/form.tsx)
+- [`components/ui/input-group.tsx`](../components/ui/input-group.tsx)
+- [`components/ui/radio-group.tsx`](../components/ui/radio-group.tsx)
+- [`components/ui/sheet.tsx`](../components/ui/sheet.tsx)
+
+These remain in `components/ui` because they are design-system primitives rather than feature components.
+
+## Remaining reuse opportunity
+
+[`components/guarded-link.tsx`](../components/guarded-link.tsx) protects navigation while autosaves are pending, but the back, previous, and next submission links in [`app/admin/form/[id]/result/[submissionId]/page.tsx`](../app/admin/form/%5Bid%5D/result/%5BsubmissionId%5D/page.tsx) still use ordinary links. Those links should use `GuardedLink` if pending note saves must always finish before navigation.