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
-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>
);
}
-308
View File
@@ -1,308 +0,0 @@
"use client";
import { useCallback, useEffect, useState, useTransition } from "react";
import { usePathname } from "next/navigation";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarInput,
} from "@/components/ui/sidebar";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
ArrowLeft,
ClipboardList,
ChevronRight,
Eye,
FilePenLine,
LayoutDashboard,
Settings,
Search,
} from "lucide-react";
import { ThemeToggle } from "@/components/theme-toggle";
import {
DropdownMenuAvatar,
type DiscordMemberProfile,
} from "@/components/dropdown-menu-avatar";
import { GuardedLink } from "@/components/guarded-link";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { getResponseNavigation } from "@/app/admin/form/actions";
type ResponseNavigationData = Awaited<ReturnType<typeof getResponseNavigation>>;
export function FormAdminSidebar({
formId,
formTitle,
isOpen,
userName,
userImage,
profile,
responseCount,
}: {
formId: string;
formTitle: string;
isOpen: boolean;
userName?: string;
userImage?: string;
profile?: DiscordMemberProfile | null;
responseCount: number;
}) {
const pathname = usePathname();
const [responsesOpen, setResponsesOpen] = useState(
pathname.startsWith(`/admin/form/${formId}/result/`),
);
const [responseQuery, setResponseQuery] = useState("");
const [responseData, setResponseData] =
useState<ResponseNavigationData | null>(null);
const [responseError, setResponseError] = useState<string | null>(null);
const [isLoadingResponses, startResponseTransition] = useTransition();
const loadResponses = useCallback((page: number, query = responseQuery) => {
startResponseTransition(async () => {
try {
setResponseError(null);
setResponseData(await getResponseNavigation(formId, page, query));
} catch {
setResponseError("Could not load responses.");
}
});
}, [formId, responseQuery]);
useEffect(() => {
if (responsesOpen && responseCount > 0 && !responseData) {
loadResponses(1, "");
}
}, [loadResponses, responsesOpen, responseCount, responseData]);
const currentSection = pathname.startsWith(`/admin/form/${formId}/result`)
? "Responses"
: pathname.startsWith(`/admin/form/${formId}/extra`)
? "Settings"
: pathname.startsWith(`/admin/form/${formId}/preview`)
? "Preview"
: "Editor";
const items = [
{
href: `/admin/form/${formId}`,
label: "Editor",
icon: LayoutDashboard,
active: pathname === `/admin/form/${formId}`,
},
{
href: `/admin/form/${formId}/result`,
label: "Responses",
icon: ClipboardList,
active: pathname.startsWith(`/admin/form/${formId}/result`),
},
{
href: `/admin/form/${formId}/extra`,
label: "Settings",
icon: Settings,
active: pathname.startsWith(`/admin/form/${formId}/extra`),
},
{
href: `/admin/form/${formId}/preview`,
label: "Preview",
icon: Eye,
active: pathname.startsWith(`/admin/form/${formId}/preview`),
},
];
return (
<Sidebar variant="sidebar" collapsible="icon">
<SidebarHeader className="border-b p-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
size="lg"
tooltip={`${formTitle} · ${isOpen ? "Open" : "Closed"}`}
className="h-auto min-h-14"
>
<GuardedLink
href={`/admin/form/${formId}`}
aria-label={`${formTitle}, ${isOpen ? "open" : "closed"}. Open editor`}
>
<FilePenLine />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">
{formTitle}
</span>
<Badge
variant={isOpen ? "default" : "secondary"}
className="shrink-0"
>
{isOpen ? "Open" : "Closed"}
</Badge>
</div>
<span className="mt-1 block text-xs text-muted-foreground">
{currentSection}
</span>
</div>
</GuardedLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<GuardedLink href="/admin/form">
<ArrowLeft />
<span>All forms</span>
</GuardedLink>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map(({ href, label, icon: Icon, active }) => (
<SidebarMenuItem key={href}>
<SidebarMenuButton asChild isActive={active}>
<GuardedLink href={href}>
<Icon />
<span>{label}</span>
</GuardedLink>
</SidebarMenuButton>
{label === "Responses" && responseCount > 0 && (
<SidebarMenuBadge>{responseCount}</SidebarMenuBadge>
)}
{label === "Responses" && responseCount > 0 && (
<SidebarMenuAction
className="right-8"
onClick={() => setResponsesOpen((open) => !open)}
aria-label={`${responsesOpen ? "Collapse" : "Expand"} responses`}
aria-expanded={responsesOpen}
>
<ChevronRight
className={cn(
"transition-transform",
responsesOpen && "rotate-90"
)}
/>
</SidebarMenuAction>
)}
{label === "Responses" &&
responsesOpen &&
responseCount > 0 && (
<div className="mt-1 flex flex-col gap-2 group-data-[collapsible=icon]:hidden">
<form
className="flex items-center gap-1 px-2"
onSubmit={(event) => {
event.preventDefault();
loadResponses(1);
}}
>
<SidebarInput
value={responseQuery}
onChange={(event) => setResponseQuery(event.target.value)}
placeholder="Search responses"
aria-label="Search response navigation"
/>
<Button
type="submit"
size="icon-sm"
variant="ghost"
disabled={isLoadingResponses}
aria-label="Search responses"
>
<Search />
</Button>
</form>
<ScrollArea className="max-h-[min(50vh,28rem)]">
<SidebarMenuSub>
{responseData?.items.map((response) => {
const responseHref = `/admin/form/${formId}/result/${response.id}`;
return (
<SidebarMenuSubItem key={response.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === responseHref}
>
<GuardedLink href={responseHref}>
<span className="min-w-0 truncate">
{response.queueNumber}. {response.displayName}
</span>
</GuardedLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
{isLoadingResponses && !responseData && (
<SidebarMenuSubItem className="px-2 py-1 text-xs text-muted-foreground">
Loading responses…
</SidebarMenuSubItem>
)}
{responseError && (
<SidebarMenuSubItem className="px-2 py-1 text-xs text-destructive">
{responseError}
</SidebarMenuSubItem>
)}
</SidebarMenuSub>
</ScrollArea>
{responseData && responseData.pageCount > 1 && (
<div className="flex items-center justify-between gap-1 px-2">
<Button
size="sm"
variant="ghost"
disabled={isLoadingResponses || responseData.page === 1}
onClick={() => loadResponses(responseData.page - 1)}
>
Previous
</Button>
<span className="text-xs text-muted-foreground">
{responseData.page} / {responseData.pageCount}
</span>
<Button
size="sm"
variant="ghost"
disabled={isLoadingResponses || responseData.page === responseData.pageCount}
onClick={() => loadResponses(responseData.page + 1)}
>
Next
</Button>
</div>
)}
</div>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t p-4">
<div className="flex items-center gap-1">
{(userName || profile) && (
<DropdownMenuAvatar
userName={userName || ""}
userImage={userImage || ""}
profile={profile || null}
showName
/>
)}
<div className="ml-auto">
<ThemeToggle />
</div>
</div>
</SidebarFooter>
</Sidebar>
);
}
-53
View File
@@ -1,53 +0,0 @@
"use client";
import { useState, type ReactNode } from "react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
export function LinkCarousel({
primary,
secondary,
}: {
primary: ReactNode;
secondary: ReactNode;
}) {
const [page, setPage] = useState<0 | 1>(0);
return (
<CardContent>
<div
key={page}
className="animate-in fade-in slide-in-from-bottom-2 duration-150"
>
{page === 0 ? primary : secondary}
</div>
<div className="flex items-center justify-between gap-3 pt-4">
<Button
variant="ghost"
size="icon"
onClick={() => setPage(0)}
disabled={page === 0}
aria-label="หน้าก่อนหน้า"
>
<ArrowLeft />
</Button>
<span
className="text-xs font-medium text-muted-foreground"
aria-live="polite"
>
{page + 1} / 2
</span>
<Button
variant="ghost"
size="icon"
onClick={() => setPage(1)}
disabled={page === 1}
aria-label="หน้าถัดไป"
>
<ArrowRight />
</Button>
</div>
</CardContent>
);
}
-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>
);
}
-234
View File
@@ -1,234 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { ResultFilters } from "@/lib/form/admin-form-results";
import { Download, X, CalendarDays } from "lucide-react";
import { format, parseISO } from "date-fns";
function DateFilterPicker({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
const selected = value ? parseISO(value) : undefined;
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
data-empty={!selected}
className="w-full justify-start text-left font-normal data-[empty=true]:text-muted-foreground lg:w-44"
>
<CalendarDays data-icon="inline-start" />
{selected ? format(selected, "PP") : label}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
onSelect={(date) => onChange(date ? format(date, "yyyy-MM-dd") : "")}
/>
{selected && (
<div className="border-t p-2">
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
onClick={() => onChange("")}
>
Clear date
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
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);
if (values.sort !== "submittedAt") params.set("sort", values.sort);
if (values.direction !== "desc") params.set("direction", values.direction);
if (values.edited !== "all") params.set("edited", values.edited);
if (values.dateFrom) params.set("dateFrom", values.dateFrom);
if (values.dateTo) params.set("dateTo", values.dateTo);
return params.toString();
}, [values]);
const appliedQuery = useMemo(() => {
const params = new URLSearchParams();
if (filters.q) params.set("q", filters.q);
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();
}, [filters]);
useEffect(() => {
if (onFiltersChange) return;
if (filterQuery === appliedQuery) return;
const timeout = window.setTimeout(() => {
router.replace(`${base}${filterQuery ? `?${filterQuery}` : ""}`, {
scroll: false,
});
}, 350);
return () => window.clearTimeout(timeout);
}, [appliedQuery, base, filterQuery, onFiltersChange, router]);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 lg:flex-row">
<Input
value={values.q}
onChange={(event) =>
updateValues((current) => ({ ...current, q: event.target.value }))
}
placeholder="Search name or Discord ID"
aria-label="Search responses"
/>
<Select
value={values.edited}
onValueChange={(edited: ResultFilters["edited"]) =>
updateValues((current) => ({ ...current, edited }))
}
>
<SelectTrigger className="w-full lg:w-40" aria-label="Edit status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All responses</SelectItem>
<SelectItem value="edited">Edited</SelectItem>
<SelectItem value="unedited">Not edited</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Select
value={`${values.sort}-${values.direction}`}
onValueChange={(value) => {
const [sort, direction] = value.split("-") as [
ResultFilters["sort"],
ResultFilters["direction"],
];
updateValues((current) => ({ ...current, sort, direction }));
}}
>
<SelectTrigger className="w-full lg:w-44" aria-label="Sort responses">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="submittedAt-desc">Newest first</SelectItem>
<SelectItem value="submittedAt-asc">Oldest first</SelectItem>
<SelectItem value="respondent-asc">Respondent A–Z</SelectItem>
<SelectItem value="respondent-desc">Respondent Z–A</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<DateFilterPicker
label="From date"
value={values.dateFrom}
onChange={(dateFrom) =>
updateValues((current) => ({ ...current, dateFrom }))
}
/>
<DateFilterPicker
label="Through date"
value={values.dateTo}
onChange={(dateTo) =>
updateValues((current) => ({ ...current, dateTo }))
}
/>
<Button variant="outline" asChild>
<a href={`${base}/export${filterQuery ? `?${filterQuery}` : ""}`}>
<Download data-icon="inline-start" />
CSV
</a>
</Button>
{(values.q ||
values.edited !== "all" ||
values.sort !== "submittedAt" ||
values.direction !== "desc" ||
values.dateFrom ||
values.dateTo) && (
<Button
variant="ghost"
size="icon"
className="self-start lg:self-auto"
aria-label="Clear filters"
asChild={!onFiltersChange}
onClick={onFiltersChange ? () => updateValues(() => ({
q: "",
page: 1,
sort: "submittedAt",
direction: "desc",
edited: "all",
dateFrom: "",
dateTo: "",
})) : undefined}
>
{onFiltersChange ? (
<X />
) : (
<a href={base}>
<X />
</a>
)}
</Button>
)}
</div>
</div>
);
}
+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>
);
}