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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user