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."} - - - )} - - ); -} 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."} - - - )} - - ); -} - 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 เพื่อตรวจสอบสิทธิ์และเก็บคำตอบหนึ่งชุดต่อคน + + + + + + ); +} + 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 ( + + ); +} + 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 ( - - ); -} - -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."} - - - )} - - ); -} - -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 เพื่อตรวจสอบสิทธิ์และเก็บคำตอบหนึ่งชุดต่อคน - - - - - - ); -} 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} + + + )} + + ); +} 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.