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