"use client"; import { useCallback, useState } from "react"; import { useAuthGuard } from "@/hooks/useAuthGuard"; import { useDebouncedSearch } from "@/hooks/useDebouncedSearch"; import { useCourseFilterOptions } from "@/hooks/useCourseFilterOptions"; import { useInfiniteResource } from "@/hooks/useInfiniteResource"; import { useDeleteResource } from "@/hooks/useDeleteResource"; import { coursesAPI, Course } from "@/lib/api/courses"; import { CoursesPageFrame } from "@/components/courses/courses-page-frame"; import { CourseCard } from "@/components/courses/course-card"; import { hasCourseFilters, NO_COURSE_FILTERS, type CourseListFilters, } from "@/components/courses/courses-filters"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { CourseDialog } from "@/components/courses/course-dialog"; import { useQuickCreate } from "@/components/layout/quick-create"; import { PageSpinner } from "@/components/ui/page-spinner"; export function CoursesPageContent() { const { user, isAuthenticated, isLoading: isAuthLoading } = useAuthGuard(); const openCreate = useQuickCreate(); const [editingCourse, setEditingCourse] = useState(null); const { input: searchInput, setInput: setSearchInput, term: search, } = useDebouncedSearch(); // No debounce twin: each of these commits a whole value at once, so what is // held and what has been asked for are the same thing. const [filters, setFilters] = useState(NO_COURSE_FILTERS); // Latched by the filter panel's first open, since nothing before that needs // to know which agencies and statuses the diver's courses use. const [wantsFilterOptions, setWantsFilterOptions] = useState(false); const { agencies, statuses, reload: reloadFilterOptions, } = useCourseFilterOptions(!!user && wantsFilterOptions); // Straight through, empties and all: `getCourses` drops an unset filter from // the query string itself, so there is no per-field conversion here to fall // out of step with the controls. const fetchCourses = useCallback( (page: number, perPage: number) => coursesAPI.getCourses(page, perPage, { search, ...filters }), [search, filters], ); // Changing the search term or any filter changes this callback's identity, // which is what makes `useInfiniteResource` throw away every page it has // loaded and read the new query from the first - rows of the unfiltered list // are not rows of the filtered one, however many are already on screen. const { items: courses, isLoading: isLoadingCourses, isLoadingMore, totalCount, isCountNarrowed, itemsPerPage, hasMore, loadFailed, loadMore, removeItem, applySaved, } = useInfiniteResource(fetchCourses, { keyOf: (course) => course.uuid, enabled: !!user, isNarrowed: search.length > 0 || hasCourseFilters(filters), errorMessage: "Failed to load courses. Please try again.", }); const { deletingId, pendingId, confirmMessage, requestDelete, cancelDelete, confirmDelete, } = useDeleteResource(coursesAPI.deleteCourse, { // No "move its dives somewhere else" offer, unlike a trip: a trip groups a // whole holiday's dives and moving them is a real operation, while a deleted // course simply unlinks - the dives and cards survive without it. confirmMessage: "Are you sure you want to delete this course? The dives and certifications on it are kept, but they will no longer name it.", successMessage: "Course deleted successfully.", errorMessage: "Failed to delete course. Please try again.", // The row goes locally rather than by re-reading the pages around it: a // diver who has scrolled several pages in should not have the list // collapse back to the first one under them. onDeleted: (uuid: string) => { removeItem(uuid); reloadFilterOptions(); }, }); if (isAuthLoading) { return ; } if (!isAuthenticated) { return null; // Will redirect to signin } const isSearching = search.length > 0; return ( <> openCreate("course")} onFiltersOpened={() => setWantsFilterOptions(true)} agencies={agencies} statuses={statuses} cards={courses.map((course) => ( setEditingCourse(course)} onDelete={() => requestDelete(course.uuid)} isDeleting={deletingId === course.uuid} /> ))} /> !open && setEditingCourse(null)} course={editingCourse} // The vocabulary too, not just the row: an edit naming a new agency is // what adds that agency to the filter. onSaved={(course) => { applySaved(course); reloadFilterOptions(); }} /> !open && cancelDelete()} title="Delete course" description={confirmMessage} confirmText="Delete" isLoading={deletingId === pendingId} onConfirm={confirmDelete} /> ); }