refactor : remove one time used componenets file
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user