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);
+234
View File
@@ -0,0 +1,234 @@
"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>
);
}
+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,
+308
View File
@@ -0,0 +1,308 @@
"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>
);
}