refactor : remove one time used componenets file

This commit is contained in:
2026-08-15 23:07:13 +07:00 Unverified
parent 285187ca41
commit 42b441a4d9
17 changed files with 314 additions and 363 deletions
+48 -5
View File
@@ -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 (
<div
ref={setNodeRef}
className="relative"
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
}}
>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute left-2 top-4"
aria-label={`Drag question ${position} to reorder`}
{...attributes}
{...listeners}
>
<GripVertical />
</Button>
{children}
</div>
);
}
interface Question {
id: string;
formId: string;
+2 -46
View File
@@ -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) {
</div>
);
}
function SaveStatusIndicator({
status,
error,
onRetry,
}: {
status: AutoSaveStatus;
error: string | null;
onRetry: () => Promise<void>;
}) {
if (status === "clean") return null;
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{status === "dirty" && "Unsaved"}
{status === "saving" && (
<>
<Loader2 className="animate-spin" />
Saving
</>
)}
{status === "saved" && (
<>
<Check />
Saved
</>
)}
{status === "error" && (
<>
<AlertCircle />
<span>{error || "Could not save."}</span>
<Button
variant="link"
size="sm"
className="h-auto px-1"
onClick={() => void onRetry().catch(() => undefined)}
>
Retry
</Button>
</>
)}
</span>
);
}
+1 -1
View File
@@ -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";
@@ -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."
/>
</Field>
</FieldGroup>
@@ -351,54 +350,6 @@ export default function SubmissionDetailClient({
);
}
function SaveStatusIndicator({
status,
error,
onRetry,
}: {
status: AutoSaveStatus;
error: string | null;
onRetry: () => Promise<void>;
}) {
if (status === "clean") return null;
return (
<span
className="flex items-center gap-1.5 text-xs text-muted-foreground"
aria-live="polite"
>
{status === "dirty" && "Unsaved"}
{status === "saving" && (
<>
<Loader2 className="animate-spin" />
Saving
</>
)}
{status === "saved" && (
<>
<Check />
Saved
</>
)}
{status === "error" && (
<>
<AlertCircle />
<span>{error || "Could not save the note."}</span>
<Button
type="button"
variant="link"
size="sm"
className="h-auto px-1"
onClick={() => void onRetry().catch(() => undefined)}
>
Retry
</Button>
</>
)}
</span>
);
}
function AnswerImagePreview({ src, label }: { src: string; label: string }) {
const [unavailable, setUnavailable] = useState(false);
+1 -1
View File
@@ -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,
+1 -2
View File
@@ -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 {
+44 -1
View File
@@ -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 (
<Dialog open>
<DialogContent
showCloseButton={false}
onInteractOutside={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle className="text-xl font-bold">
เข้าสู่ระบบก่อนกรอกแบบฟอร์ม
</DialogTitle>
<DialogDescription className="mt-1 text-base">
ระบบใช้บัญชี Discord เพื่อตรวจสอบสิทธิ์และเก็บคำตอบหนึ่งชุดต่อคน
</DialogDescription>
</DialogHeader>
<Button
className="mt-2 w-full"
onClick={() =>
signIn("discord", { callbackUrl: window.location.href })
}
>
<svg
data-icon="inline-start"
viewBox="0 0 127.14 96.36"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z" />
</svg>
ดำเนินการต่อด้วย Discord
</Button>
</DialogContent>
</Dialog>
);
}
const LS_KEY = (formId: string) => `form-draft-${formId}`;
export function FormFillerClient({
+102 -4
View File
@@ -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<IconName, ComponentType<SVGProps<SVGSVGElement>>> = {
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 (
<nav aria-label={label} className="flex flex-col gap-3">
{links.map((item) => {
const IconComponent = iconMap[item.icon];
const count = getCountForLink(item.name, counts);
const external = !item.link.startsWith("/");
return (
<Button
key={item.link}
variant="outline"
className="group h-12 w-full justify-start p-4"
asChild
>
<Link
href={item.link}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
>
<span className="flex w-full items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-3">
<span className="flex size-4 shrink-0">
<IconComponent className="size-5 text-muted-foreground" />
</span>
<span className="flex min-w-0 flex-col items-start text-left">
<span className="truncate font-medium text-foreground">
{item.name}
</span>
{item.desc && (
<span className="truncate text-xs text-muted-foreground">
{item.desc}
</span>
)}
</span>
</span>
{item.icon === "donate" ? (
<span className="shrink-0 text-xs font-medium text-muted-foreground">
ขึ้นจอ 10 บาท+
</span>
) : count !== null ? (
<span className="shrink-0 text-xs font-medium">
{formatCount(count.count)}{" "}
<span className="text-muted-foreground">{count.label}</span>
</span>
) : null}
</span>
</Link>
</Button>
);
})}
</nav>
);
}
export default async function Home() {
const visitorCountPromise = headers()
.then(trackUniqueVisitor)
@@ -87,7 +176,16 @@ export default async function Home() {
</div>
</CardHeader>
<LinkCard counts={counts} links1={links1} links2={config.secondaryLinks} />
<LinkCarousel
primary={<LinkList counts={counts} label="ลิงก์หลัก" links={links1} />}
secondary={
<LinkList
counts={counts}
label="ลิงก์เพิ่มเติม"
links={config.secondaryLinks}
/>
}
/>
</Card>
</main>
);
-94
View File
@@ -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<IconName, ComponentType<SVGProps<SVGSVGElement>>> = {
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 (
<nav aria-label={label} className="flex flex-col gap-3">
{links.map((item) => {
const IconComponent = iconMap[item.icon];
const count = getCountForLink(item.name, counts);
const external = !item.link.startsWith("/");
return (
<Button
key={item.link}
variant="outline"
className="group h-12 w-full justify-start p-4"
asChild
>
<Link
href={item.link}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
>
<span className="flex w-full items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-3">
<span className="flex size-4 shrink-0">
<IconComponent className="size-5 text-muted-foreground" />
</span>
<span className="flex min-w-0 flex-col items-start text-left">
<span className="truncate font-medium text-foreground">
{item.name}
</span>
{item.desc && (
<span className="truncate text-xs text-muted-foreground">
{item.desc}
</span>
)}
</span>
</span>
{item.icon === "donate" ? (
<span className="shrink-0 text-xs font-medium text-muted-foreground">
ขึ้นจอ 10 บาท+
</span>
) : count !== null ? (
<span className="shrink-0 text-xs font-medium">
{formatCount(count.count)}{" "}
<span className="text-muted-foreground">{count.label}</span>
</span>
) : null}
</span>
</Link>
</Button>
);
})}
</nav>
);
}
export function LinkCard({ counts, links1, links2 }: { counts: FollowerCounts; links1: readonly LinkConfig[]; links2: readonly LinkConfig[] }) {
return (
<LinkCarousel
primary={<LinkList counts={counts} label="ลิงก์หลัก" links={links1} />}
secondary={<LinkList counts={counts} label="ลิงก์เพิ่มเติม" links={links2} />}
/>
);
}
-96
View File
@@ -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<void>;
}) {
if (status === "clean") return null;
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground" aria-live="polite">
{status === "dirty" && "Unsaved"}
{status === "saving" && (
<>
<Loader2 className="animate-spin" />
Saving
</>
)}
{status === "saved" && (
<>
<Check />
Saved
</>
)}
{status === "error" && (
<>
<AlertCircle />
<span>{error || "Could not save."}</span>
<Button
variant="link"
size="sm"
className="h-auto px-1"
onClick={() => void onRetry().catch(() => undefined)}
>
Retry
</Button>
</>
)}
</span>
);
}
export function SortableQuestion({
id,
position,
children,
}: {
id: string;
position: number;
children: React.ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
className="relative"
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
}}
>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute left-2 top-4"
aria-label={`Drag question ${position} to reorder`}
{...attributes}
{...listeners}
>
<GripVertical />
</Button>
{children}
</div>
);
}
-45
View File
@@ -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 (
<Dialog open>
<DialogContent
showCloseButton={false}
onInteractOutside={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle className="text-xl font-bold">
เข้าสู่ระบบก่อนกรอกแบบฟอร์ม
</DialogTitle>
<DialogDescription className="mt-1 text-base">
ระบบใช้บัญชี Discord เพื่อตรวจสอบสิทธิ์และเก็บคำตอบหนึ่งชุดต่อคน
</DialogDescription>
</DialogHeader>
<Button
className="mt-2 w-full"
onClick={() => signIn("discord", { callbackUrl: window.location.href })}
>
<svg
data-icon="inline-start"
viewBox="0 0 127.14 96.36"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z" />
</svg>
ดำเนินการต่อด้วย Discord
</Button>
</DialogContent>
</Dialog>
);
}
+55
View File
@@ -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<void>;
fallbackError?: string;
}) {
if (status === "clean") return null;
return (
<span
className="flex items-center gap-1.5 text-xs text-muted-foreground"
aria-live="polite"
>
{status === "dirty" && "Unsaved"}
{status === "saving" && (
<>
<Loader2 className="animate-spin" />
Saving
</>
)}
{status === "saved" && (
<>
<Check />
Saved
</>
)}
{status === "error" && (
<>
<AlertCircle />
<span>{error || fallbackError}</span>
<Button
type="button"
variant="link"
size="sm"
className="h-auto px-1"
onClick={() => void onRetry().catch(() => undefined)}
>
Retry
</Button>
</>
)}
</span>
);
}
-17
View File
@@ -1,17 +0,0 @@
import Image from "next/image";
import Background from "@/public/background/space.webp";
export function SpaceBackground() {
return (
<div className="pointer-events-none fixed inset-0 bg-background" aria-hidden>
<Image
src={Background}
alt=""
fill
priority
sizes="100vw"
className="object-cover opacity-60"
/>
</div>
);
}
+58
View File
@@ -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.