diff --git a/package-lock.json b/package-lock.json index 3926e5a..78950ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", "vercel": "^25.2.0", "xlsx": "^0.18.5", "zod": "^4.1.13", @@ -18559,6 +18560,19 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/vercel": { "version": "25.2.3", "resolved": "https://registry.npmjs.org/vercel/-/vercel-25.2.3.tgz", diff --git a/package.json b/package.json index 55eceb6..d622716 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", "vercel": "^25.2.0", "xlsx": "^0.18.5", "zod": "^4.1.13", diff --git a/src/app/chart/page.tsx b/src/app/chart/page.tsx index f9ca7a1..6634164 100644 --- a/src/app/chart/page.tsx +++ b/src/app/chart/page.tsx @@ -13,6 +13,7 @@ import { CalendarDays, + Check, ChartColumn, ChevronDown, LineChart, @@ -58,15 +59,16 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import { addToCart, downloadSingleGraph } from "@/lib/export-to-pdf"; import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; -import { Cart } from "@/components/Cart"; + downloadSingleGraph, + downloadGraphs, + captureChartAsDataUrl, +} from "@/lib/export-to-pdf"; +import { addToCart, loadCart, type CartItem } from "@/lib/cart-db"; +import { ExportCartDrawer } from "@/components/ExportCartDrawer"; import { Kbd } from "@/components/ui/kbd"; import { useHotkey } from "@/hooks/useHotkey"; +import LoadError from "@/components/LoadError"; type Project = { id: number; @@ -187,6 +189,13 @@ const generateChartTitle = ( export default function ChartPage() { const [allProjects, setAllProjects] = useState([]); const [gatewaySchools, setGatewaySchools] = useState([]); + const [isProjectsLoading, setIsProjectsLoading] = useState(true); + const [projectLoadError, setProjectLoadError] = useState( + null, + ); + const [gatewaySchoolsError, setGatewaySchoolsError] = useState< + string | null + >(null); const [isExporting, setIsExporting] = useState(false); const [exportDialogOpen, setExportDialogOpen] = useState(false); const [isLoaded, setIsLoaded] = useState(false); @@ -273,9 +282,12 @@ export default function ChartPage() { parseAsBoolean.withDefault(false), ); - const [cart, setCart] = useState([]); + const [cartItems, setCartItems] = useState([]); + const [isCartExporting, setIsCartExporting] = useState(false); - const [filterNames, setFilterNames] = useState([]); + useEffect(() => { + loadCart().then(setCartItems); + }, []); const filters: Filters = useMemo( () => ({ @@ -309,40 +321,119 @@ export default function ChartPage() { ); const chartRef = useRef(null); - // Fetch all project data - useEffect(() => { - const fetchProjects = async () => { - setIsLoaded(false); - try { - const response = await fetch("/api/projects"); - if (!response.ok) throw new Error("Failed to fetch"); - - const data = await response.json(); - - const updatedProjects = data.map((p: Project) => ({ - ...p, - gatewaySchool: gatewaySchools.includes(p.schoolName) - ? "Gateway" - : "Non-Gateway", - })); - - setAllProjects(updatedProjects); - } catch { - toast.error( - "Failed to load project data. Please refresh the page.", - ); - } finally { - setIsLoaded(true); + // Apply URL search params to all filter state + const applyParams = useCallback( + (params: string) => { + const sp = new URLSearchParams(params); + setTimePeriod(sp.get("period") ?? "custom"); + setStartYear(parseInt(sp.get("startYear") ?? "2020", 10)); + setEndYear(parseInt(sp.get("endYear") ?? "2025", 10)); + setChartType(sp.get("type") ?? "bar"); + setGroupBy(sp.get("groupBy") ?? "none"); + setMeasuredAs(sp.get("measuredAs") ?? "total-school-count"); + setSelectedSchools(sp.getAll("schools")); + setSelectedCities(sp.getAll("cities")); + setSelectedProjectTypes(sp.getAll("projectTypes")); + setTeacherYearsValue(sp.get("teacherYearsValue") ?? ""); + setTeacherYearsOperator(sp.get("teacherYearsOperator") ?? "="); + setTeacherYearsValue2(sp.get("teacherYearsValue2") ?? ""); + setOnlyGatewaySchools(sp.get("onlyGatewaySchools") === "true"); + }, + [ + setTimePeriod, + setStartYear, + setEndYear, + setChartType, + setGroupBy, + setMeasuredAs, + setSelectedSchools, + setSelectedCities, + setSelectedProjectTypes, + setTeacherYearsValue, + setTeacherYearsOperator, + setTeacherYearsValue2, + setOnlyGatewaySchools, + ], + ); + + // Export all cart items to PDF + const handleCartExport = useCallback(async () => { + if (cartItems.length === 0) return; + + setIsCartExporting(true); + const screenshots: string[] = []; + const names: string[] = []; + + // Save current params to restore later + const originalParams = window.location.search; + + try { + for (const item of cartItems) { + // Apply this item's params + applyParams(item.params); + + // Wait for React to re-render and chart to update + await new Promise((resolve) => { + requestAnimationFrame(() => { + setTimeout(resolve, 100); + }); + }); + + // Capture the chart + const dataUrl = await captureChartAsDataUrl(chartRef); + if (dataUrl) { + screenshots.push(dataUrl); + names.push(item.name); + } } - }; + } catch (err) { + console.error("[Export Cart] Export failed:", err); + toast.error( + "Export failed. Check the browser console for details.", + ); + // Try to restore original params on error + applyParams(originalParams); + } finally { + setIsCartExporting(false); + } + }, [cartItems, applyParams]); - fetchProjects(); + const fetchProjects = useCallback(async () => { + setIsProjectsLoading(true); + setProjectLoadError(null); + try { + const response = await fetch("/api/projects"); + if (!response.ok) throw new Error("Failed to fetch"); + + const data = await response.json(); + + const updatedProjects = data.map((p: Project) => ({ + ...p, + gatewaySchool: gatewaySchools.includes(p.schoolName) + ? "Gateway" + : "Non-Gateway", + })); + + setAllProjects(updatedProjects); + } catch { + setAllProjects([]); + setProjectLoadError( + "Failed to load project data. Please refresh the page.", + ); + } finally { + setIsProjectsLoading(false); + } }, [gatewaySchools]); - // Fetch gateway schools - useEffect(() => { + const fetchGatewaySchools = useCallback(() => { + setGatewaySchoolsError(null); fetch("/api/schools?gateway=true&list=true") - .then((res) => res.json()) + .then((res) => { + if (!res.ok) { + throw new Error("Failed to load gateway schools"); + } + return res.json(); + }) .then((data) => { const schoolNames: string[] = data.map( (school: { name: string }) => school.name, @@ -350,10 +441,20 @@ export default function ChartPage() { setGatewaySchools(schoolNames); }) - .catch(() => toast.error("Failed to load gateway schools")); + .catch(() => + setGatewaySchoolsError("Failed to load gateway schools."), + ); }, []); - /* Fetch and set cart to and from session storage to persist between refreshes */ + // Fetch all project data + useEffect(() => { + fetchProjects(); + }, [fetchProjects]); + + // Fetch gateway schools + useEffect(() => { + fetchGatewaySchools(); + }, [fetchGatewaySchools]); const filterName = generateChartTitle( chartType, @@ -742,22 +843,25 @@ export default function ChartPage() { {/* Main Content Area */} -
-
- {/* Header */} -
-
- - + {projectLoadError ? ( +
+ +
+ ) : allProjects.length > 0 ? ( +
+ {/* Header */} +
+
+ {generateChartTitle( chartType, @@ -857,11 +961,78 @@ export default function ChartPage() { Add to + + + + + Export graph to PDF? + + + This will download a PDF of the + current graph to your computer. + + + + + Cancel + + { + setExportDialogOpen(false); + setIsExporting(true); + await downloadSingleGraph( + chartRef, + filterName, + ); + setIsExporting(false); + toast.success( + "Graph exported successfully!", + ); + }} + > + Download + + + + + + +
-
+ ) : ( +
+ {isProjectsLoading ? ( + + ) : ( +
+

+ No chart data found +

+

+ Try changing the year range or removing one + of the active filters. +

+
+ )} +
+ )} + +
); diff --git a/src/app/map/page.tsx b/src/app/map/page.tsx index c3a31c7..daa73f1 100644 --- a/src/app/map/page.tsx +++ b/src/app/map/page.tsx @@ -11,7 +11,14 @@ **************************************************************/ import { Map } from "@/components/ui/map"; -import { Suspense, useEffect, useState, useRef, useMemo } from "react"; +import { + Suspense, + useEffect, + useState, + useRef, + useMemo, + useCallback, +} from "react"; import { toast } from "sonner"; import { Loader2, Link, Share } from "lucide-react"; @@ -33,6 +40,7 @@ import { Button } from "@/components/ui/button"; import { exportMapToPDF } from "@/lib/heatmap-export"; import { useHeatmapLayers } from "@/hooks/useHeatmapLayers"; import { Cart } from "@/components/Cart"; +import LoadError from "@/components/LoadError"; import { HoverCard, HoverCardContent, @@ -156,11 +164,22 @@ function HeatMapPage() { }; const [gatewaySchools, setGatewaySchools] = useState([]); + const [gatewaySchoolsError, setGatewaySchoolsError] = useState< + string | null + >(null); + const [schoolPointsError, setSchoolPointsError] = useState( + null, + ); - // Fetch gateway schools - useEffect(() => { + const fetchGatewaySchools = useCallback(() => { + setGatewaySchoolsError(null); fetch("/api/schools?gateway=true&list=true") - .then((res) => res.json()) + .then((res) => { + if (!res.ok) { + throw new Error("Failed to load gateway schools"); + } + return res.json(); + }) .then((data) => { const schoolNames: string[] = data.map( (school: { name: string }) => school.name, @@ -168,31 +187,49 @@ function HeatMapPage() { setGatewaySchools(schoolNames); }) - .catch(() => toast.error("Failed to load gateway schools")); + .catch(() => + setGatewaySchoolsError("Failed to load gateway schools."), + ); }, []); + const fetchSchoolPoints = useCallback( + (signal?: AbortSignal) => { + setIsLoaded(false); + setSchoolPointsError(null); + fetch(`/api/heat-layer?year=${year}`, { signal }) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to fetch school data"); + } + return response.json(); + }) + .then((data) => { + setSchoolPoints(data); + setIsLoaded(true); + }) + .catch((error) => { + if (error.name === "AbortError") return; + setSchoolPoints(null); + setSchoolPointsError( + error.message || "Failed to load school data", + ); + setIsLoaded(true); + }); + }, + [year], + ); + + // Fetch gateway schools + useEffect(() => { + fetchGatewaySchools(); + }, [fetchGatewaySchools]); + // Fetch school point data for heat layer useEffect(() => { const controller = new AbortController(); - setIsLoaded(false); - fetch(`/api/heat-layer?year=${year}`, { signal: controller.signal }) - .then((response) => { - if (!response.ok) { - throw new Error(`Failed to fetch school data`); - } - return response.json(); - }) - .then((data) => { - setSchoolPoints(data); - setIsLoaded(true); - }) - .catch((error) => { - if (error.name === "AbortError") return; - toast.error(error.message || "Failed to load school data"); - setIsLoaded(true); - }); + fetchSchoolPoints(controller.signal); return () => controller.abort(); - }, [year]); + }, [fetchSchoolPoints]); // Filter school points based on the gateway toggle const filteredSchoolPoints = useMemo(() => { @@ -424,6 +461,14 @@ function HeatMapPage() { {showSchools ? "Hide Schools" : "Show Schools"}
+ {gatewaySchoolsError && ( + + )}
+ {schoolPointsError && ( +
+ fetchSchoolPoints()} + className="h-full min-h-0" + /> +
+ )} {!isLoaded && ( // Gray overlay + loading wheel
diff --git a/src/app/schools/[name]/page.tsx b/src/app/schools/[name]/page.tsx index 9abfc07..b6cf013 100644 --- a/src/app/schools/[name]/page.tsx +++ b/src/app/schools/[name]/page.tsx @@ -162,7 +162,7 @@ export default function SchoolProfilePage() { return (
- +
@@ -187,7 +187,7 @@ export default function SchoolProfilePage() { return (
- + {/* Header with school name — double-click to edit */}
{editingName ? ( diff --git a/src/app/schools/page.tsx b/src/app/schools/page.tsx index 32fc8d4..abdca2a 100644 --- a/src/app/schools/page.tsx +++ b/src/app/schools/page.tsx @@ -19,6 +19,7 @@ import { SchoolsDataTable } from "@/components/DataTableSchools"; import { SaveDiscardBar } from "@/components/EditableCells"; import SchoolSearchBar from "@/components/SchoolSearchbar"; import YearDropdown from "@/components/YearDropdown"; +import LoadError from "@/components/LoadError"; import { standardize } from "@/lib/school-name-standardize"; export default function SchoolsPage() { @@ -27,6 +28,10 @@ export default function SchoolsPage() { const [year, setYear] = useState(null); const [search, setSearch] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [schoolLoadError, setSchoolLoadError] = useState(null); + const [prevYearLoadError, setPrevYearLoadError] = useState( + null, + ); const [originalSchoolInfo, setOriginalSchoolInfo] = useState([]); const [pendingChanges, setPendingChanges] = useState< Map> @@ -94,15 +99,14 @@ export default function SchoolsPage() { const columns = useMemo(() => createColumns(onCommit), [onCommit]); - useEffect(() => { - if (!year) return; - + const fetchSchoolInfo = useCallback((selectedYear: number) => { setIsLoading(true); + setSchoolLoadError(null); - fetch(`/api/schools?year=${year}`) + fetch(`/api/schools?year=${selectedYear}`) .then((response) => { if (!response.ok) { - throw new Error(`Failed to fetch school data`); + throw new Error("Failed to fetch school data"); } return response.json(); }) @@ -112,20 +116,23 @@ export default function SchoolsPage() { setPendingChanges(new Map()); }) .catch(() => { - toast.error("Failed to load school data."); + setSchoolInfo([]); + setOriginalSchoolInfo([]); + setPendingChanges(new Map()); + setSchoolLoadError("Failed to load school data."); }) .finally(() => { setIsLoading(false); }); - }, [year]); + }, []); - useEffect(() => { - if (!year) return; + const fetchPrevYearSchoolInfo = useCallback((selectedYear: number) => { + setPrevYearLoadError(null); - fetch(`/api/schools?year=${year - 1}`) + fetch(`/api/schools?year=${selectedYear - 1}`) .then((response) => { if (!response.ok) { - throw new Error(`Failed to fetch school data`); + throw new Error("Failed to fetch school data"); } return response.json(); }) @@ -133,9 +140,20 @@ export default function SchoolsPage() { setPrevYearSchoolInfo(data); }) .catch(() => { - toast.error("Failed to load previous year data."); + setPrevYearSchoolInfo([]); + setPrevYearLoadError("Failed to load previous year data."); }); - }, [year]); + }, []); + + useEffect(() => { + if (!year) return; + fetchSchoolInfo(year); + }, [year, fetchSchoolInfo]); + + useEffect(() => { + if (!year) return; + fetchPrevYearSchoolInfo(year); + }, [year, fetchPrevYearSchoolInfo]); return (
@@ -158,14 +176,39 @@ export default function SchoolsPage() {
- + {schoolLoadError ? ( +
+ year && fetchSchoolInfo(year)} + className="h-full min-h-0" + /> +
+ ) : ( +
+ {prevYearLoadError && year && ( +
+ + fetchPrevYearSchoolInfo(year) + } + compact + /> +
+ )} +
+ +
+
+ )}
{/* Data management tab */} -
-

Preferences

-

- How would you like to view charts... -

-
-
-

Configuration

-

- These settings configure how data is calculated. - Only edit these settings if you really mean to. -

-

@@ -246,6 +234,9 @@ interface SchoolEntry { function SchoolLocationEditor() { const [schools, setSchools] = useState([]); + const [schoolsLoadError, setSchoolsLoadError] = useState( + null, + ); const [selectedSchoolId, setSelectedSchoolId] = useState(""); const [editing, setEditing] = useState(false); const [newPin, setNewPin] = useState<{ @@ -259,13 +250,26 @@ function SchoolLocationEditor() { setMounted(true); }, []); - useEffect(() => { + const fetchSchools = useCallback(() => { + setSchoolsLoadError(null); fetch("/api/schools?list=true") - .then((res) => res.json()) - .then((data) => setSchools(data)) - .catch(() => toast.error("Failed to load schools")); + .then(async (res) => { + const data = await res.json(); + if (!res.ok || !Array.isArray(data)) { + throw new Error("Failed to load schools."); + } + setSchools(data); + }) + .catch(() => { + setSchools([]); + setSchoolsLoadError("Failed to load schools."); + }); }, []); + useEffect(() => { + fetchSchools(); + }, [fetchSchools]); + const selectedSchool = schools.find( (s) => String(s.id) === selectedSchoolId, ); @@ -368,16 +372,27 @@ function SchoolLocationEditor() {

School Locations

-
- -
+ ) : ( +
+ +
+ )} - {selectedSchool && mounted && ( + {selectedSchool && mounted && !schoolsLoadError && (
s !== ""); + if (segments.length === 0) { + return { href: "/", label: labelForPathname("/") }; + } + const href = + segments.length === 1 ? "/" : `/${segments.slice(0, -1).join("/")}`; + return { href, label: labelForPathname(href.split("?")[0]) }; +} -export function Breadcrumbs({ - labels = {}, -}: { - labels?: Record; -}) { +function BreadcrumbsInner({ className }: { className?: string }) { const pathname = usePathname(); - const crumbTrail = pathname.split("/").filter((item) => item !== ""); - const backArrowHref = `/${crumbTrail.slice(0, crumbTrail.length - 1).join("/")}`; + const searchParams = useSearchParams(); + const rawReturn = searchParams.get(RETURN_TO_QUERY_KEY); + const returnTo = safeInternalReturnTo(rawReturn); + + const segments = pathname.split("/").filter((s) => s !== ""); + if (segments.length === 0) { + return null; + } - // Determine if we're on an Analysis page (map or graphs) - const isAnalysisPage = pathname === "/map" || pathname === "/graphs"; - const firstBreadcrumbLabel = isAnalysisPage ? "ANALYSIS" : "OVERVIEW"; - const firstBreadcrumbHref = isAnalysisPage - ? pathname === "/map" - ? "/map" - : "/graphs" - : "/"; + const fromQuery = + returnTo != null + ? { + href: returnTo, + label: labelForPathname(returnTo.split("?")[0]), + } + : null; + + const { href, label } = fromQuery ?? backTargetFromPath(pathname); return ( -
- - - - - - - {firstBreadcrumbLabel} - - - - {crumbTrail.map((link, index) => { - const href = `/${crumbTrail.slice(0, index + 1).join("/")}`; - const isCurrent = pathname === href; + {label} + + + ); +} - return ( - - - - - - {link in labels ? ( - labels[link] !== undefined ? ( - labels[link].toUpperCase() - ) : ( - - ) - ) : ( - link.toUpperCase() - )} - - - - - ); - })} - - +function BreadcrumbsFallback({ className }: { className?: string }) { + return ( +
+
+
); } + +export function Breadcrumbs({ className }: { className?: string }) { + return ( + }> + + + ); +} diff --git a/src/components/Cart.tsx b/src/components/Cart.tsx index a4af0d1..22beff7 100644 --- a/src/components/Cart.tsx +++ b/src/components/Cart.tsx @@ -13,7 +13,7 @@ import { Loader2, Trash2 } from "lucide-react"; import { Button } from "./ui/button"; -import { clearCart, deleteFromCart, downloadGraphs } from "@/lib/export-to-pdf"; +import { downloadGraphs } from "@/lib/export-to-pdf"; import { Dispatch, SetStateAction, useState } from "react"; import { toast } from "sonner"; @@ -44,12 +44,21 @@ export function Cart({ }: CartProps) { const [isExporting, setIsExporting] = useState(false); return ( -
-
- {filterNames.map((filterName, index) => ( -
+ {filterNames.map((filterName, index) => ( +
+

{filterName}

+ + + + + +
+ +
+ + Export Cart + + {items.length > 0 && ( + + {items.length} + + )} +
+ +
+ {items.length === 0 ? ( +

+ No items in cart. Add charts or maps to export + them as PDF. +

+ ) : ( +
+ {items.map((item, index) => ( +
+

+ {item.name} +

+ +
+ ))} +
+ )} +
+ +
+ {items.length > 0 && ( +
+ +
+ )} + + + + + + + + + Export {items.length} graph + {items.length !== 1 ? "s" : ""} to PDF? + + + This will download a PDF containing{" "} + {items.length} graph + {items.length !== 1 ? "s" : ""} to your + computer. + + + + + Cancel + + + Download + + + + +
+ + + + ); +} diff --git a/src/components/GatewaySchools.tsx b/src/components/GatewaySchools.tsx index abcdd70..8f928ab 100644 --- a/src/components/GatewaySchools.tsx +++ b/src/components/GatewaySchools.tsx @@ -16,6 +16,7 @@ import { useEffect, useState, useImperativeHandle, forwardRef } from "react"; import { Combobox } from "@/components/Combobox"; import { Trash } from "lucide-react"; import { toast } from "sonner"; +import LoadError from "@/components/LoadError"; import { standardize } from "@/lib/school-name-standardize"; /** @@ -53,21 +54,49 @@ const GatewaySchools = forwardRef< const [selectedSchoolId, setSelectedSchoolId] = useState(""); const [pendingAdditions, setPendingAdditions] = useState([]); const [pendingRemovals, setPendingRemovals] = useState([]); + const [schoolsLoadError, setSchoolsLoadError] = useState( + null, + ); + const [gatewaySchoolsLoadError, setGatewaySchoolsLoadError] = useState< + string | null + >(null); - // Load all schools for dropdown - useEffect(() => { + const fetchSchools = () => { + setSchoolsLoadError(null); fetch("/api/schools?list=true") - .then((res) => res.json()) + .then((res) => { + if (!res.ok) { + throw new Error("Failed to load schools"); + } + return res.json(); + }) .then((data) => setSchools(data)) - .catch(() => toast.error("Failed to load schools")); + .catch(() => setSchoolsLoadError("Failed to load schools.")); + }; + + const fetchGatewaySchools = () => { + setGatewaySchoolsLoadError(null); + fetch("/api/schools?gateway=true&list=true") + .then((res) => { + if (!res.ok) { + throw new Error("Failed to load gateway schools"); + } + return res.json(); + }) + .then((data) => setGatewaySchools(data)) + .catch(() => + setGatewaySchoolsLoadError("Failed to load gateway schools."), + ); + }; + + // Load all schools for dropdown + useEffect(() => { + fetchSchools(); }, []); // Load only gateway schools on mount useEffect(() => { - fetch("/api/schools?gateway=true&list=true") - .then((res) => res.json()) - .then((data) => setGatewaySchools(data)) - .catch(() => toast.error("Failed to load gateway schools")); + fetchGatewaySchools(); }, []); const schoolOptions = schools.map((s) => ({ @@ -158,63 +187,86 @@ const GatewaySchools = forwardRef< return (
-
- -
- -
- - - - - - - - - {gatewaySchools.length > 0 ? ( - gatewaySchools.map((school) => ( - - - +
- School - - Actions -
- {school.name} - - + ) : ( +
+ +
+ )} + + {gatewaySchoolsLoadError ? ( + + ) : ( +
+ + + + + + + + + {gatewaySchools.length > 0 ? ( + gatewaySchools.map((school) => ( + + + + + )) + ) : ( + + - )) - ) : ( - - - - )} - -
+ School + + Actions +
+ {school.name} + + +
+ No gateway schools
- No gateway schools -
-
+ )} +
+
+ )}
); }); diff --git a/src/components/LoadError.tsx b/src/components/LoadError.tsx new file mode 100644 index 0000000..8f2c61a --- /dev/null +++ b/src/components/LoadError.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { AlertCircle, RotateCcw } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type LoadErrorProps = { + message: string; + onRetry: () => void; + className?: string; + compact?: boolean; + heading?: string; + retryLabel?: string; + description?: string; +}; + +export default function LoadError({ + message, + onRetry, + className, + compact = false, + heading, + retryLabel = "Retry", + description = "Try again to reload this section.", +}: LoadErrorProps) { + const title = heading ?? message; + + if (compact) { + return ( +
+
+ +
+
+

+ {title} +

+

+ {description} +

+
+ +
+ ); + } + + return ( +
+
+
+ +
+

{title}

+

+ {description} +

+
+ + +
+ ); +} diff --git a/src/components/skeletons/SchoolProfileSkeleton.tsx b/src/components/skeletons/SchoolProfileSkeleton.tsx index 86080d6..66922a0 100644 --- a/src/components/skeletons/SchoolProfileSkeleton.tsx +++ b/src/components/skeletons/SchoolProfileSkeleton.tsx @@ -13,8 +13,8 @@ export function SchoolProfileSkeleton({ <> {!skipHeader && ( <> - {/* Breadcrumbs skeleton */} - + {/* Back link skeleton */} + {/* Header skeleton */} diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx deleted file mode 100644 index 040d802..0000000 --- a/src/components/ui/breadcrumb.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { Slot } from "@radix-ui/react-slot"; -import { ChevronRight, MoreHorizontal } from "lucide-react"; -import * as React from "react"; - -import { cn } from "@/lib/utils"; - -const Breadcrumb = React.forwardRef< - HTMLElement, - React.ComponentPropsWithoutRef<"nav"> & { - separator?: React.ReactNode; - } ->(({ ...props }, ref) =>