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