diff --git a/apps/web/app/academic/academic-record.tsx b/apps/web/app/academic/academic-record.tsx index d092a8d3..477ebde3 100644 --- a/apps/web/app/academic/academic-record.tsx +++ b/apps/web/app/academic/academic-record.tsx @@ -1,5 +1,4 @@ "use client"; -import { toast } from "sonner"; import { useMemo, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { @@ -11,6 +10,7 @@ import { } from "@coursemap/ui/primitives/dialog"; import { useCoursemap } from "@/app/providers"; import { AppShell } from "@/ui/shell"; +import { showToast } from "@/ui/common/toast"; import { OnboardingPrompt } from "@/ui/common/onboarding-prompt"; import { ResultsEmptyState } from "@/ui/academic/results-empty-state"; import { PreviewLayout } from "@/ui/academic/previews/preview-layout"; @@ -97,10 +97,8 @@ export function AcademicRecord({ catalogue }: { catalogue: PlanCatalogue }) { ); if (!response.ok) { setSelected(null); - toast.error(response.message, { - description: response.detail ? ( - {response.detail} - ) : undefined, + showToast(response.message, "error", { + detail: response.detail, }); return; } @@ -109,7 +107,7 @@ export function AcademicRecord({ catalogue }: { catalogue: PlanCatalogue }) { router.refresh(); } catch { setSelected(null); - notify("We couldn't save that result. Try again.", "error"); + notify("Couldn't save that result. Try again.", "error"); } }); } diff --git a/apps/web/app/admin/courses/[year]/(directory)/loading.tsx b/apps/web/app/admin/courses/[year]/(directory)/loading.tsx index 66f3d6f0..6a058ea0 100644 --- a/apps/web/app/admin/courses/[year]/(directory)/loading.tsx +++ b/apps/web/app/admin/courses/[year]/(directory)/loading.tsx @@ -1,7 +1,5 @@ import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ( - - ); + return ; } diff --git a/apps/web/app/admin/majors/[year]/(directory)/loading.tsx b/apps/web/app/admin/majors/[year]/(directory)/loading.tsx index aa5600be..9e4d6df0 100644 --- a/apps/web/app/admin/majors/[year]/(directory)/loading.tsx +++ b/apps/web/app/admin/majors/[year]/(directory)/loading.tsx @@ -1,5 +1,5 @@ import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/admin/minors/[year]/(directory)/loading.tsx b/apps/web/app/admin/minors/[year]/(directory)/loading.tsx index 7b9506c3..02210f76 100644 --- a/apps/web/app/admin/minors/[year]/(directory)/loading.tsx +++ b/apps/web/app/admin/minors/[year]/(directory)/loading.tsx @@ -1,5 +1,5 @@ import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/admin/programmes/[year]/(directory)/loading.tsx b/apps/web/app/admin/programmes/[year]/(directory)/loading.tsx index ad1b04e3..09a1b0f9 100644 --- a/apps/web/app/admin/programmes/[year]/(directory)/loading.tsx +++ b/apps/web/app/admin/programmes/[year]/(directory)/loading.tsx @@ -1,7 +1,5 @@ import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ( - - ); + return ; } diff --git a/apps/web/app/admin/specialisations/[year]/(directory)/loading.tsx b/apps/web/app/admin/specialisations/[year]/(directory)/loading.tsx index 94a80755..b90ba01d 100644 --- a/apps/web/app/admin/specialisations/[year]/(directory)/loading.tsx +++ b/apps/web/app/admin/specialisations/[year]/(directory)/loading.tsx @@ -1,11 +1,5 @@ import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ( - - ); + return ; } diff --git a/apps/web/app/courses/(directory)/page.tsx b/apps/web/app/courses/(directory)/page.tsx index eb408a3a..2c513394 100644 --- a/apps/web/app/courses/(directory)/page.tsx +++ b/apps/web/app/courses/(directory)/page.tsx @@ -111,7 +111,6 @@ export default async function CoursesPage({

Explore courses

Course Year - Requisites Available Units @@ -89,39 +83,6 @@ export function CourseDirectory({ /> {academicYear} - -
- {course.prerequisiteCodes.length === 0 ? ( - - None - - ) : ( - course.prerequisiteCodes.map((prerequisite) => - course.availableCourseCodes.includes(prerequisite) ? ( - - {prerequisite} - - ) : ( - - {prerequisite} - - ), - ) - )} -
-
-
+

Dashboard

diff --git a/apps/web/app/help/[slug]/page.tsx b/apps/web/app/help/[slug]/page.tsx index a9588796..af4fa2e9 100644 --- a/apps/web/app/help/[slug]/page.tsx +++ b/apps/web/app/help/[slug]/page.tsx @@ -118,7 +118,7 @@ export default async function HelpArticlePage({

{article.title}

diff --git a/apps/web/app/help/page.tsx b/apps/web/app/help/page.tsx index 126df47a..5f0464ff 100644 --- a/apps/web/app/help/page.tsx +++ b/apps/web/app/help/page.tsx @@ -5,7 +5,7 @@ import { AppShell } from "@/ui/shell"; export default function HelpPage() { return ( -
+

Help centre

diff --git a/apps/web/app/profile/profile-editor.tsx b/apps/web/app/profile/profile-editor.tsx index c706d852..79ebe6e6 100644 --- a/apps/web/app/profile/profile-editor.tsx +++ b/apps/web/app/profile/profile-editor.tsx @@ -167,7 +167,7 @@ export function ProfileEditor({ async function save(event: FormEvent) { event.preventDefault(); if (nameError || !draft.degreeCode) { - notify("Add your name and select a degree before saving", "warning"); + notify("Add your name and degree before saving", "warning"); return; } if (studentNumberError) { diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index 2dd51216..f06da012 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -2,7 +2,7 @@ import { useInputModality } from "@/lib/browser/use-input-modality"; import { Toaster } from "@coursemap/ui/primitives/sonner"; import type { CSSProperties } from "react"; -import { toast } from "sonner"; +import { showToast, type ToastTone } from "@/ui/common/toast"; import { useTheme } from "next-themes"; import { createContext, @@ -27,8 +27,6 @@ import { type CoursemapActionResult, } from "@/lib/coursemap/actions"; -type ToastTone = "success" | "warning" | "info" | "error"; - type AppContextValue = { state: AppState; ready: boolean; @@ -129,10 +127,7 @@ export function AppProvider({ }, [viewer]); const notify = useCallback((message: string, tone: ToastTone = "success") => { - if (tone === "error") toast.error(message); - else if (tone === "warning") toast.warning(message); - else if (tone === "info") toast.info(message); - else toast.success(message); + showToast(message, tone); }, []); const updateProfile = useCallback( @@ -142,7 +137,7 @@ export function AppProvider({ const result = await saveProfileAndPlan(nextProfile); if (!result.ok) return result; setState((current) => ({ ...current, profile: nextProfile })); - return { ok: true, message: "Profile and academic plan saved" }; + return { ok: true, message: "Profile saved" }; }, [state.profile], ); @@ -247,7 +242,8 @@ export function AppProvider({ attemptedUnits?: number, ) => { const attempt = state.attempts.find((item) => item.id === attemptId); - if (!attempt) return { ok: false, message: "Course was not found" }; + if (!attempt) + return { ok: false, message: "That course is no longer in your plan" }; if (attempt.status !== "planned") { return { ok: false, @@ -312,7 +308,8 @@ export function AppProvider({ message: "Recorded attempts stay in your academic history", }; } - if (!attempt) return { ok: false, message: "Course was not found" }; + if (!attempt) + return { ok: false, message: "That course is no longer in your plan" }; const result = await removePlanCourse(attemptId); if (!result.ok) return result; setState((current) => ({ @@ -387,7 +384,7 @@ export function AppProvider({ : "system" } position="top-center" - style={{ "--width": "560px" } as CSSProperties} + style={{ "--width": "420px" } as CSSProperties} closeButton visibleToasts={3} /> diff --git a/apps/web/app/requirements/requirements.tsx b/apps/web/app/requirements/requirements.tsx index 9dd0bdff..657c0bed 100644 --- a/apps/web/app/requirements/requirements.tsx +++ b/apps/web/app/requirements/requirements.tsx @@ -130,7 +130,7 @@ export function Requirements({ router.refresh(); } } catch { - notify("Your selection could not be saved. Try again.", "warning"); + notify("Couldn't save your selection. Try again.", "warning"); } finally { setPending(false); } diff --git a/apps/web/app/roadmap/page.tsx b/apps/web/app/roadmap/page.tsx index f10611a8..89befcf4 100644 --- a/apps/web/app/roadmap/page.tsx +++ b/apps/web/app/roadmap/page.tsx @@ -179,7 +179,7 @@ const stages: RoadmapStage[] = [ export default function RoadmapPage() { return ( -
+

Roadmap

diff --git a/apps/web/app/vendor.css b/apps/web/app/vendor.css index 8b1ddf50..dc1b0224 100644 --- a/apps/web/app/vendor.css +++ b/apps/web/app/vendor.css @@ -45,6 +45,23 @@ color: var(--toast-status-colour, inherit); } +/* A toast is a short title and at most two lines of detail under it. The + title is clamped too, so a long server message can never grow the toast + into a paragraph; toastParts in ui/common/toast.tsx keeps titles short. */ +.toaster [data-sonner-toast] [data-title] { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + font-weight: 500; + line-height: 1.35; +} + +.toaster [data-sonner-toast] [data-description] { + color: var(--muted-foreground); + line-height: 1.4; +} + .toaster [data-sonner-toast][data-styled="true"] [data-close-button] { top: 12px; right: 12px; @@ -168,6 +185,16 @@ pointer-events: none; } +/* Collapsed inset sidebar: the rail already ends 16px past the icons, so the + panel drops its extra left margin to match the 16px on the left. */ +@media (width >= 48rem) { + .style-nova + .peer[data-variant="inset"][data-state="collapsed"] + ~ .cn-sidebar-inset { + margin-left: 0; + } +} + /* Compact tabs in light keep an outlined track so the white active tab reads against the muted fill. */ :root:not(.dark-mode) .style-nova .cn-tabs-list[data-variant="default"] { diff --git a/apps/web/lib/academic/actions.ts b/apps/web/lib/academic/actions.ts index 0000b33d..69f326d1 100644 --- a/apps/web/lib/academic/actions.ts +++ b/apps/web/lib/academic/actions.ts @@ -41,7 +41,7 @@ export async function saveAcademicResult( const { canAccessAdmin } = await getAuthContext(); return { ok: false, - message: "An unexpected error occurred", + message: "Couldn't save that result", ...(canAccessAdmin ? { detail } : {}), }; } diff --git a/apps/web/lib/coursemap/actions.ts b/apps/web/lib/coursemap/actions.ts index 3a3738b5..1a1c284b 100644 --- a/apps/web/lib/coursemap/actions.ts +++ b/apps/web/lib/coursemap/actions.ts @@ -28,7 +28,7 @@ function failure(error: unknown): CoursemapActionResult { const rawMessage = error && typeof error === "object" && "message" in error ? String(error.message) - : "Coursemap could not save that change."; + : "Couldn't save that change. Try again."; const message = rawMessage.includes("profiles_student_number_format_check") ? "Enter a student number in the format u1234567, or leave it blank." : rawMessage; @@ -65,7 +65,7 @@ export async function saveProfileAndPlan( ); if (error) throw error; revalidatePath("/", "layout"); - return { ok: true, id: data, message: "Profile and academic plan saved" }; + return { ok: true, id: data, message: "Profile saved" }; } catch (error) { return failure(error); } @@ -148,7 +148,7 @@ export async function removePlanCourse( if (data) revalidatePath("/plan"); return data ? { ok: true, message: "Course removed from the plan" } - : { ok: false, message: "Course was not found in your plan" }; + : { ok: false, message: "That course is no longer in your plan" }; } catch (error) { return failure(error); } diff --git a/apps/web/lib/coursemap/admin-catalogue.ts b/apps/web/lib/coursemap/admin-catalogue.ts index 5c6cb8a6..fe78e8c6 100644 --- a/apps/web/lib/coursemap/admin-catalogue.ts +++ b/apps/web/lib/coursemap/admin-catalogue.ts @@ -299,14 +299,21 @@ export async function loadCatalogueDirectoryPage({ : null, } satisfies CatalogueDirectoryRecord; }); - const needle = query.trim().toUpperCase(); + // Every word must appear somewhere in the code or title, in any order and + // ignoring punctuation, so "Human Centred Computing" finds + // "Human-Centred and Creative Computing". + const words = query + .toUpperCase() + .split(/[^A-Z0-9]+/u) + .filter(Boolean); const filtered = rows - .filter( - (row) => - !needle || - row.code.includes(needle) || - (row.title ?? "").toUpperCase().includes(needle), - ) + .filter((row) => { + if (!words.length) return true; + const haystack = `${row.code} ${row.title ?? ""}` + .toUpperCase() + .replace(/[^A-Z0-9]+/gu, " "); + return words.every((word) => haystack.includes(word)); + }) // The whole year is already in memory, so narrowing by state costs a pass // rather than a query, and it agrees with the badge by construction. .filter((row) => !state || catalogueRecordState(row) === state) diff --git a/apps/web/tests/academic-record.test.tsx b/apps/web/tests/academic-record.test.tsx index 8ff25dc4..bdca833c 100644 --- a/apps/web/tests/academic-record.test.tsx +++ b/apps/web/tests/academic-record.test.tsx @@ -111,14 +111,14 @@ test("save failures close the result form and display a toast", async () => { fireEvent.click(screen.getByRole("button", { name: "Edit test result" })); fireEvent.click(screen.getByRole("button", { name: "Save result" })); await waitFor(() => - expect(toastError).toHaveBeenCalledWith("Could not save.", { - description: expect.objectContaining({ - props: expect.objectContaining({ - className: "line-clamp-1 break-all", - children: "Missing database function", + expect(toastError).toHaveBeenCalledWith( + "Could not save", + expect.objectContaining({ + description: expect.objectContaining({ + props: { text: "Missing database function" }, }), }), - }), + ), ); await waitFor(() => expect(screen.queryByRole("button", { name: "Save result" })).toBeNull(), diff --git a/apps/web/tests/breadcrumbs.test.tsx b/apps/web/tests/breadcrumbs.test.tsx index 0d6df723..07597ae1 100644 --- a/apps/web/tests/breadcrumbs.test.tsx +++ b/apps/web/tests/breadcrumbs.test.tsx @@ -56,29 +56,32 @@ function measureAt(initialWidth: number) { }); } -test("caps long trails at three positions even when there is room", () => { +test("keeps the base, the parent and the current page on long trails", () => { + pathname = "/admin/courses/2026/infs1001/changelog"; const resize = measureAt(600); - render(); + render(); const trail = screen.getByRole("navigation", { name: "Breadcrumb" }); - expect(within(trail).getAllByRole("listitem")).toHaveLength(3); + expect(within(trail).getAllByRole("listitem")).toHaveLength(4); + expect(within(trail).getByRole("link", { name: "Admin" })).toHaveAttribute( + "href", + "/admin/dashboard", + ); expect( within(trail).queryByRole("link", { name: "Courses" }), ).not.toBeInTheDocument(); - expect( - within(trail).queryByRole("link", { name: "2026" }), - ).not.toBeInTheDocument(); - - resize(220); - expect(within(trail).getByRole("link", { name: "Admin" })).toHaveAttribute( + expect(within(trail).getByRole("link", { name: "INFS1001" })).toHaveAttribute( "href", - "/admin/dashboard", + "/admin/courses/2026/infs1001", ); - expect(within(trail).getByText("INFS1001")).toHaveAttribute( + expect(within(trail).getByText("Changelog")).toHaveAttribute( "class", "truncate", ); + expect(trail).not.toHaveTextContent("2026"); + + resize(220); expect( - within(trail).queryByRole("link", { name: "2026" }), + within(trail).queryByRole("link", { name: "INFS1001" }), ).not.toBeInTheDocument(); fireEvent.keyDown( within(trail).getByRole("button", { name: "Show hidden breadcrumbs" }), @@ -88,18 +91,25 @@ test("caps long trails at three positions even when there is room", () => { "href", "/admin/courses", ); - expect(screen.getByRole("menuitem", { name: "2026" })).toHaveAttribute( + expect(screen.getByRole("menuitem", { name: "INFS1001" })).toHaveAttribute( "href", - "/admin/courses/2026", + "/admin/courses/2026/infs1001", ); fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" }); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); resize(600); + expect(within(trail).getAllByRole("listitem")).toHaveLength(4); + expect(within(trail).getByRole("link", { name: "INFS1001" })).toBeVisible(); +}); + +test("leaves academic years out of the trail", () => { + measureAt(600); + render(); + const trail = screen.getByRole("navigation", { name: "Breadcrumb" }); expect(within(trail).getAllByRole("listitem")).toHaveLength(3); - expect( - within(trail).getByRole("button", { name: "Show hidden breadcrumbs" }), - ).toBeVisible(); + expect(within(trail).getByRole("link", { name: "Courses" })).toBeVisible(); + expect(trail).not.toHaveTextContent("2026"); }); test("keeps the base and current section when a page has a trailing tab", () => { @@ -122,8 +132,9 @@ test("keeps the base and current section when a page has a trailing tab", () => }); test("reveals the hidden links on mouse hover", () => { - measureAt(220); - render(); + pathname = "/admin/courses/2026/infs1001/changelog"; + measureAt(600); + render(); const trigger = screen.getByRole("button", { name: "Show hidden breadcrumbs", }); @@ -131,14 +142,11 @@ test("reveals the hidden links on mouse hover", () => { Object.defineProperty(event, "pointerType", { value: "mouse" }); fireEvent(trigger, event); expect(screen.getByRole("menuitem", { name: "Courses" })).toBeVisible(); - expect(screen.getByRole("menuitem", { name: "2026" })).toBeVisible(); }); test("shows three short breadcrumbs until width requires collapsing the middle", () => { const resize = measureAt(600); - render( - , - ); + render(); const trail = screen.getByRole("navigation", { name: "Breadcrumb" }); expect(within(trail).getByRole("link", { name: "Courses" })).toBeVisible(); expect(within(trail).queryByRole("button")).not.toBeInTheDocument(); @@ -155,7 +163,7 @@ test("shows three short breadcrumbs until width requires collapsing the middle", test("does not repeat a catalogue section on its year directory", () => { measureAt(600); - render(); + render(); const trail = screen.getByRole("navigation", { name: "Breadcrumb" }); expect(within(trail).getAllByRole("listitem")).toHaveLength(2); expect(within(trail).getByRole("link", { name: "Admin" })).toBeVisible(); diff --git a/apps/web/tests/catalogue-sync-button.test.tsx b/apps/web/tests/catalogue-sync-button.test.tsx index 3ca963e4..68b50e5d 100644 --- a/apps/web/tests/catalogue-sync-button.test.tsx +++ b/apps/web/tests/catalogue-sync-button.test.tsx @@ -61,7 +61,7 @@ test("starts one record-level ANU sync", async () => { expect(button).toBeDisabled(); expect(button).toHaveTextContent("Sync"); expect(progress).toHaveBeenCalledWith( - "Syncing COMP1100 from ANU", + "Syncing COMP1100", expect.objectContaining({ id: "sync:42" }), ); }); @@ -87,17 +87,16 @@ test("reports a sync that could not start in its own toast", async () => { await waitFor(() => expect(failure).toHaveBeenCalledWith( - "Syncing COMP1100 from ANU could not start", + "Couldn't start the COMP1100 sync", expect.objectContaining({ id: "sync:42" }), ), ); - // The description is clamped to one line, so the reason it failed is carried - // whole in the tooltip. + // The description is clamped, so the reason it failed is carried whole. const [, options] = failure.mock.calls[0] as [ string, - { description: { props: { title: string } } }, + { description: { props: { text: string } } }, ]; - expect(options.description.props.title).toBe("Sync permission is required."); + expect(options.description.props.text).toBe("Sync permission is required."); expect(screen.getByRole("button")).toBeEnabled(); }); @@ -126,7 +125,7 @@ test("hands the toast back when the page that was watching it goes", async () => // nothing left polling to ever finish it. await waitFor(() => expect(info).toHaveBeenCalledWith( - "The ANU sync is still running", + "Sync still running", expect.objectContaining({ id: "sync:42" }), ), ); diff --git a/apps/web/tests/filter-bar-url-search.test.tsx b/apps/web/tests/filter-bar-url-search.test.tsx new file mode 100644 index 00000000..5758fb58 --- /dev/null +++ b/apps/web/tests/filter-bar-url-search.test.tsx @@ -0,0 +1,72 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { TooltipProvider } from "@coursemap/ui/primitives/tooltip"; +import { FilterBar } from "@/ui/common/filter-bar"; + +const navigation = vi.hoisted(() => ({ + params: new URLSearchParams(), + replace: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: navigation.replace }), + usePathname: () => "/admin/catalogue/minors/2026", + useSearchParams: () => navigation.params, +})); + +function Bar() { + return ( + + + + ); +} + +beforeEach(() => { + vi.useFakeTimers(); + navigation.params = new URLSearchParams(); + navigation.replace.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function land(query: string, rerender: (ui: React.ReactElement) => void) { + navigation.params = new URLSearchParams(query ? { q: query } : {}); + rerender(); +} + +test("an earlier search landing does not overwrite what is still being typed", () => { + const { rerender } = render(); + const input = screen.getByRole("searchbox"); + + fireEvent.change(input, { target: { value: "micro" } }); + act(() => vi.advanceTimersByTime(250)); + expect(navigation.replace).toHaveBeenLastCalledWith( + "/admin/catalogue/minors/2026?q=micro", + { scroll: false }, + ); + + fireEvent.change(input, { target: { value: "microprocessor" } }); + land("micro", rerender); + expect(input).toHaveValue("microprocessor"); + + act(() => vi.advanceTimersByTime(250)); + expect(navigation.replace).toHaveBeenLastCalledWith( + "/admin/catalogue/minors/2026?q=microprocessor", + { scroll: false }, + ); + land("microprocessor", rerender); + expect(input).toHaveValue("microprocessor"); +}); + +test("a search changed from elsewhere still resets the box", () => { + navigation.params = new URLSearchParams({ q: "micro" }); + const { rerender } = render(); + const input = screen.getByRole("searchbox"); + expect(input).toHaveValue("micro"); + + land("", rerender); + expect(input).toHaveValue(""); +}); diff --git a/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx b/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx index 82021b9e..a800995c 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx +++ b/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx @@ -39,7 +39,6 @@ function columnsFor(noun: string, layout: CatalogueLoadingLayout): Column[] { return [ { label: "Course", kind: "identity" }, { label: "Year", kind: "text" }, - { label: "Requisites", kind: "text" }, { label: "Available", kind: "text" }, { label: "Units", kind: "text" }, { label: "Actions", kind: "actions" }, @@ -202,24 +201,12 @@ export function CatalogueTableLoading({ export function CatalogueLoading({ noun, layout, - hideAcademicYear = false, }: { noun: string; layout: CatalogueLoadingLayout; - hideAcademicYear?: boolean; }) { - const breadcrumbSegmentLabels = hideAcademicYear - ? Object.fromEntries( - Array.from({ length: 11 }, (_, index) => [String(2020 + index), null]), - ) - : undefined; return ( - +

Loading {noun}

diff --git a/apps/web/ui/admin/catalogue-table/catalogue-row-actions.tsx b/apps/web/ui/admin/catalogue-table/catalogue-row-actions.tsx index 8d44ccf2..b1faa7c6 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-row-actions.tsx +++ b/apps/web/ui/admin/catalogue-table/catalogue-row-actions.tsx @@ -15,11 +15,11 @@ import { MoreVertical, RefreshCw, } from "lucide-react"; -import { toast } from "sonner"; import { Button } from "@coursemap/ui/primitives/button"; import { OptionMenu } from "@/ui/common/option-menu"; import styles from "./catalogue-table.module.css"; +import { showToast } from "@/ui/common/toast"; type Action = { label: string; @@ -119,8 +119,8 @@ export function CatalogueRowActions({ setOpen(false); if (value === "copy" && code) { void navigator.clipboard.writeText(code).then( - () => toast.success("Code copied"), - () => toast.error("Could not copy the code"), + () => showToast("Code copied"), + () => showToast("Couldn't copy the code", "error"), ); } else if (value.startsWith("extra-")) extraActions[Number(value.slice(6))]?.onSelect(); diff --git a/apps/web/ui/admin/catalogue-table/catalogue-table.module.css b/apps/web/ui/admin/catalogue-table/catalogue-table.module.css index 7ad88c7e..d0348ad5 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-table.module.css +++ b/apps/web/ui/admin/catalogue-table/catalogue-table.module.css @@ -169,8 +169,8 @@ a.title:focus-visible { } .shell[data-layout="public-courses"] tr { - min-width: 870px; - grid-template-columns: minmax(240px, 1fr) 80px 210px 180px 70px 60px; + min-width: 660px; + grid-template-columns: minmax(240px, 1fr) 80px 180px 70px 60px; } .shell[data-layout="users"] tr { min-width: 700px; diff --git a/apps/web/ui/admin/catalogue/catalogue-directory.tsx b/apps/web/ui/admin/catalogue/catalogue-directory.tsx index 753bb3ba..84de98d5 100644 --- a/apps/web/ui/admin/catalogue/catalogue-directory.tsx +++ b/apps/web/ui/admin/catalogue/catalogue-directory.tsx @@ -119,7 +119,7 @@ export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { setRefreshing(true); const task = startTask({ id: `directory:${page.kind}:${page.academicYear}`, - title: `Refreshing the ANU ${labels.singular.toLowerCase()} listing`, + title: `Refreshing ${page.academicYear} ${labels.plural.toLowerCase()}`, detail: "Contacting ANU.", ceiling: 10, }); @@ -154,8 +154,8 @@ export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { }; if (result.isComplete === false) { task.note({ - ...outcome, - detail: `${outcome.detail}. The listing may be incomplete, so nothing was retired.`, + title: `${page.academicYear} ${labels.plural.toLowerCase()} partly refreshed`, + detail: `${outcome.detail}. ANU's listing looked incomplete, so nothing was retired.`, }); } else { task.done(outcome); @@ -163,7 +163,7 @@ export function CatalogueDirectory({ page }: { page: CatalogueDirectoryPage }) { router.refresh(); } catch (error) { task.fail({ - title: "The ANU listing refresh failed", + title: "Couldn't refresh the ANU listing", detail: error instanceof Error ? error.message : "The refresh failed.", retry: refreshDirectory, }); diff --git a/apps/web/ui/admin/catalogue/catalogue-editor-context.tsx b/apps/web/ui/admin/catalogue/catalogue-editor-context.tsx index 6f6db115..df0885b9 100644 --- a/apps/web/ui/admin/catalogue/catalogue-editor-context.tsx +++ b/apps/web/ui/admin/catalogue/catalogue-editor-context.tsx @@ -10,7 +10,6 @@ import { type ReactNode, type SetStateAction, } from "react"; -import { toast } from "sonner"; import type { CatalogueContent } from "@/lib/catalogue/content"; import { @@ -20,6 +19,7 @@ import { saveCatalogueDraftAction, unpublishAction, } from "@/lib/coursemap/admin-catalogue-actions"; +import { showToast } from "@/ui/common/toast"; export type CatalogueSaveState = "saved" | "saving" | "error" | "conflict"; @@ -179,7 +179,7 @@ export function CatalogueEditorProvider({ record: publishedRecord, }); if (!result.ok) throw new Error(result.error); - toast.success(result.message); + showToast(result.message ?? "Published"); setEditingSessionId(crypto.randomUUID()); setIsPublished(true); setHasDraft(false); @@ -196,7 +196,7 @@ export function CatalogueEditorProvider({ record: publishedRecord, }); if (!result.ok) throw new Error(result.error); - toast.success(result.message); + showToast(result.message ?? "Unpublished"); setEditingSessionId(crypto.randomUUID()); setIsPublished(false); router.refresh(); @@ -210,7 +210,7 @@ export function CatalogueEditorProvider({ path, }); if (!result.ok) throw new Error(result.error); - toast.success(result.message); + showToast(result.message ?? "Draft discarded"); setEditingSessionId(crypto.randomUUID()); setHasDraft(false); setHasUnpublishedChanges(false); @@ -235,7 +235,7 @@ export function CatalogueEditorProvider({ }); setOpening(false); if (!result.ok) { - toast.error(result.error); + showToast(result.error, "error"); return; } setRevision(result.revision ?? revision); diff --git a/apps/web/ui/admin/catalogue/catalogue-pages.tsx b/apps/web/ui/admin/catalogue/catalogue-pages.tsx index 2d6e1274..150129d1 100644 --- a/apps/web/ui/admin/catalogue/catalogue-pages.tsx +++ b/apps/web/ui/admin/catalogue/catalogue-pages.tsx @@ -46,11 +46,7 @@ export async function CatalogueDirectoryPage({ page: Number(first(params.page)) || 1, }); return ( - +

{labels.plural}

diff --git a/apps/web/ui/admin/catalogue/changelog/restore-version-button.tsx b/apps/web/ui/admin/catalogue/changelog/restore-version-button.tsx index 1a0fb1e5..a3b5e045 100644 --- a/apps/web/ui/admin/catalogue/changelog/restore-version-button.tsx +++ b/apps/web/ui/admin/catalogue/changelog/restore-version-button.tsx @@ -3,9 +3,9 @@ import { Button } from "@coursemap/ui/primitives/button"; import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; -import { toast } from "sonner"; import { restoreCatalogueVersionAction } from "@/lib/coursemap/admin-catalogue-actions"; import { ConfirmDialog } from "@/ui/common/confirm-dialog"; +import { showToast } from "@/ui/common/toast"; /** * Restores historical content as the working draft. The version itself never @@ -41,10 +41,10 @@ export function RestoreVersionButton({ path, }); if (!result.ok) { - toast.error(result.error); + showToast(result.error, "error"); return; } - toast.success(result.message ?? "Version restored as a draft."); + showToast(result.message ?? "Version restored as a draft"); setOpen(false); router.push(path); router.refresh(); diff --git a/apps/web/ui/admin/catalogue/changelog/version-page.tsx b/apps/web/ui/admin/catalogue/changelog/version-page.tsx index 14800fef..b55fc3bc 100644 --- a/apps/web/ui/admin/catalogue/changelog/version-page.tsx +++ b/apps/web/ui/admin/catalogue/changelog/version-page.tsx @@ -118,7 +118,7 @@ export async function CatalogueVersionPage({ currentBreadcrumbLabel={`Version ${versionOrdinal}`} breadcrumbSegmentLabels={{ [labels.segment]: labels.plural, - [String(academicYear)]: String(academicYear), + [encodeURIComponent(record.code.toLowerCase())]: record.code, changelog: "Changelog", }} > diff --git a/apps/web/ui/admin/catalogue/changes/changes-panel.tsx b/apps/web/ui/admin/catalogue/changes/changes-panel.tsx index 2f73ac74..545b5e02 100644 --- a/apps/web/ui/admin/catalogue/changes/changes-panel.tsx +++ b/apps/web/ui/admin/catalogue/changes/changes-panel.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; +import { cn } from "@/lib/cn"; import type { SnapshotChange } from "@/lib/catalogue-import/changes"; import type { summariseReviewNotes } from "@/lib/catalogue/review-notes"; import type { SourceReview } from "@/lib/catalogue/source-review-store"; @@ -99,9 +100,12 @@ export function CatalogueChangesPanel({ kindLabel, }); const showUnpublished = unpublishedCount > 0; + const isEmpty = conflicts.length === 0 && incoming.length === 0; + // The empty state reaches the page floor only when nothing follows it. + const fillsPage = isEmpty && overrides.length === 0 && !showUnpublished; return ( -
+
{/* Everything on this tab is the output of a sync, so the sync that produced it is named here rather than left to be found in Activity. @@ -123,7 +127,7 @@ export function CatalogueChangesPanel({

) : null} {notes ? : null} - {conflicts.length === 0 && incoming.length === 0 ? ( + {isEmpty ? ( ) : null} {conflicts.length > 0 ? ( diff --git a/apps/web/ui/admin/catalogue/changes/source-change-card.tsx b/apps/web/ui/admin/catalogue/changes/source-change-card.tsx index dc489602..cc53e59c 100644 --- a/apps/web/ui/admin/catalogue/changes/source-change-card.tsx +++ b/apps/web/ui/admin/catalogue/changes/source-change-card.tsx @@ -4,13 +4,13 @@ import { Badge } from "@coursemap/ui/components/badge"; import { Button } from "@coursemap/ui/primitives/button"; import { useRouter } from "next/navigation"; import { useTransition } from "react"; -import { toast } from "sonner"; import type { SourceReviewChange, SourceReviewDecision, } from "@/lib/catalogue/source-review-store"; import { resolveSourceChangeAction } from "@/lib/coursemap/admin-catalogue-actions"; import { ReviewValue } from "./review-value"; +import { showToast } from "@/ui/common/toast"; // The section heading already says a row was kept or has converged, so only // the two actionable classifications carry a badge of their own. @@ -50,10 +50,10 @@ export function SourceChangeCard({ path, }); if (!result.ok) { - toast.error(result.error); + showToast(result.error, "error"); return; } - toast.success(result.message ?? "The ANU change was resolved."); + showToast(result.message ?? "ANU change resolved"); router.refresh(); }); } diff --git a/apps/web/ui/admin/catalogue/directory-row-actions.tsx b/apps/web/ui/admin/catalogue/directory-row-actions.tsx index 29da6ccb..6d5b7466 100644 --- a/apps/web/ui/admin/catalogue/directory-row-actions.tsx +++ b/apps/web/ui/admin/catalogue/directory-row-actions.tsx @@ -3,7 +3,6 @@ import { EyeOff, RefreshCw, Send, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { toast } from "sonner"; import { discardDraftAction, @@ -21,6 +20,7 @@ import { import { anuSourceUrl } from "@/ui/admin/catalogue/anu-source"; import { CatalogueRowActions } from "@/ui/admin/catalogue-table/catalogue-row-actions"; import { ConfirmDialog } from "@/ui/common/confirm-dialog"; +import { showToast } from "@/ui/common/toast"; type DraftAction = "publish" | "discard" | "unpublish"; @@ -65,7 +65,10 @@ export function DirectoryRowActions({ async function startSync() { if (record.recordId === null) { - toast.error("Refresh the ANU listing before syncing this record."); + showToast( + "Refresh the ANU listing first. This record is not in it yet.", + "warning", + ); return; } setSyncing(true); @@ -77,13 +80,17 @@ export function DirectoryRowActions({ }); const result = (await response.json()) as { error?: string }; if (!response.ok) { - toast.error(result.error ?? "The ANU sync could not start."); + showToast("Couldn't start the ANU sync", "error", { + detail: result.error, + }); return; } - toast.success(`Syncing ${record.code} from ANU...`); + showToast(`Syncing ${record.code}`, "info", { + detail: "The row updates when ANU has been read.", + }); router.refresh(); } catch { - toast.error("The ANU sync could not start."); + showToast("Couldn't start the ANU sync", "error"); } finally { setSyncing(false); } @@ -115,8 +122,8 @@ export function DirectoryRowActions({ path: recordPath, record: publishedRecord, }); - if (result.ok) toast.success(result.message ?? "Done."); - else toast.error(result.error); + if (result.ok) showToast(result.message ?? "Saved"); + else showToast(result.error, "error"); // Even a refusal refreshes: the row is out of date either way, and the // menu it offers has to match what the record now is. router.refresh(); diff --git a/apps/web/ui/admin/catalogue/record-page.tsx b/apps/web/ui/admin/catalogue/record-page.tsx index a471aa5d..9280baa6 100644 --- a/apps/web/ui/admin/catalogue/record-page.tsx +++ b/apps/web/ui/admin/catalogue/record-page.tsx @@ -43,7 +43,7 @@ function FoundationEmpty({ description: string; }) { return ( -
+

{title}

{description} @@ -135,10 +135,10 @@ export async function CatalogueRecordPage({ } > @@ -152,7 +152,7 @@ export async function CatalogueRecordPage({ initialHasUnpublishedChanges={hasUnpublishedChanges} path={path} > -

+
{/* The toolbar reports the record's state, so it leads the page rather than the fields. It appears only where it can act: the @@ -169,7 +169,7 @@ export async function CatalogueRecordPage({ openChangeCount={openChanges} conflictCount={review?.conflicts.length ?? 0} /> - + {canWrite ? ( ) : ( @@ -179,14 +179,14 @@ export async function CatalogueRecordPage({ /> )} - + - + 0} @@ -207,7 +207,7 @@ export async function CatalogueRecordPage({ unpublished={unpublished} /> - + +

Nothing to preview yet

{`Sync from ANU or write the ${kindLabel} content, and this is what students will read.`} diff --git a/apps/web/ui/admin/catalogue/sync-button.tsx b/apps/web/ui/admin/catalogue/sync-button.tsx index 2192dedf..856cd499 100644 --- a/apps/web/ui/admin/catalogue/sync-button.tsx +++ b/apps/web/ui/admin/catalogue/sync-button.tsx @@ -22,20 +22,19 @@ const SYNC_PROGRESS: Record< running: { percent: 50, ceiling: 88, detail: "Reading the ANU page." }, }; -const SYNC_OUTCOMES = { - applied: { - title: "ANU changes applied", - detail: "The record is up to date.", - }, - review_required: { - title: "ANU changes need review", - detail: "Open the changes to accept or reject them.", - }, - unchanged: { - title: "No ANU changes", - detail: "ANU has not changed this record since the last sync.", - }, -} as const; +function syncOutcome(code: string, status: string) { + if (status === "review_required") + return { + title: `${code} has ANU changes to review`, + detail: "Open Changes to accept or reject them.", + }; + if (status === "unchanged") + return { + title: `${code} is up to date`, + detail: "Nothing has changed on ANU since the last sync.", + }; + return { title: `${code} updated from ANU` }; +} export function CatalogueSyncButton({ recordId, @@ -78,8 +77,8 @@ export function CatalogueSyncButton({ reportedStatus.current = null; task.current = startTask({ id: `sync:${recordId}`, - title: `Syncing ${code} from ANU`, - detail: "Asking ANU for the latest version.", + title: `Syncing ${code}`, + detail: "Contacting ANU.", ceiling: 12, }); startTransition(async () => { @@ -94,8 +93,8 @@ export function CatalogueSyncButton({ }; if (!response.ok || !result.syncId) { task.current?.fail({ - title: `Syncing ${code} from ANU could not start`, - detail: result.error ?? "The sync did not return an identifier.", + title: `Couldn't start the ${code} sync`, + detail: result.error ?? "The server did not return a sync.", retry: retrySync, }); task.current = null; @@ -117,8 +116,8 @@ export function CatalogueSyncButton({ useEffect( () => () => task.current?.abandon({ - title: "The ANU sync is still running", - detail: "Open the record again to see how it finished.", + title: "Sync still running", + detail: "Reopen the record to see the result.", }), [], ); @@ -137,20 +136,16 @@ export function CatalogueSyncButton({ } if (status === "failed") { task.current?.fail({ - title: `Syncing ${code} from ANU failed`, + title: `${code} sync failed`, detail: latestSync.errorMessage ?? "The sync did not finish.", retry: retrySync, }); } else if (status === "cancelled") { task.current?.note({ - title: `Syncing ${code} from ANU was cancelled`, + title: `${code} sync cancelled`, }); } else { - const outcome = SYNC_OUTCOMES[status as keyof typeof SYNC_OUTCOMES]; - task.current?.done({ - title: outcome?.title ?? `${code} synced from ANU`, - detail: outcome?.detail, - }); + task.current?.done(syncOutcome(code, status)); } task.current = null; }, [code, latestSync, retrySync, startedSyncId]); diff --git a/apps/web/ui/admin/imports/import-model-card.tsx b/apps/web/ui/admin/imports/import-model-card.tsx index deaa41b8..cfdc1985 100644 --- a/apps/web/ui/admin/imports/import-model-card.tsx +++ b/apps/web/ui/admin/imports/import-model-card.tsx @@ -3,7 +3,6 @@ import { useRef, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { ChevronsUpDown, Cpu, Plus, Settings2 } from "lucide-react"; -import { toast } from "sonner"; import { Button } from "@coursemap/ui/primitives/button"; import { Card } from "@coursemap/ui/primitives/card"; import { @@ -19,6 +18,7 @@ import { cn } from "@/lib/cn"; import { ImportModelLogo } from "./import-model-logo"; import { ImportModelPrice } from "./import-model-price"; import { ImportModelManager } from "./import-model-manager"; +import { showToast } from "@/ui/common/toast"; const dateFormatter = new Intl.DateTimeFormat("en-AU", { day: "numeric", @@ -50,13 +50,13 @@ export function ImportModelCard({ try { const result = await setImportModel(next); if (!result.ok) { - toast.error(result.message); + showToast(result.message, "error"); return; } - toast.success(result.message); + showToast(result.message); router.refresh(); } catch { - toast.error("The default model could not be saved. Try again."); + showToast("Couldn't save the default model. Try again.", "error"); } }); } diff --git a/apps/web/ui/admin/imports/import-model-manager.tsx b/apps/web/ui/admin/imports/import-model-manager.tsx index 738ee670..e5f5b37a 100644 --- a/apps/web/ui/admin/imports/import-model-manager.tsx +++ b/apps/web/ui/admin/imports/import-model-manager.tsx @@ -3,7 +3,6 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { ChevronDown } from "lucide-react"; -import { toast } from "sonner"; import { Button } from "@coursemap/ui/primitives/button"; import { Input } from "@coursemap/ui/primitives/input"; import { Label } from "@coursemap/ui/primitives/label"; @@ -21,6 +20,7 @@ import { import type { ImportModel } from "@/lib/admin/import-model"; import { ImportModelRow } from "./import-model-row"; import type { ImportModelRowAction } from "./import-model-row"; +import { showToast } from "@/ui/common/toast"; export function ImportModelManager({ open, @@ -55,7 +55,7 @@ export function ImportModelManager({ return; } if (action === "add" && model === id) setId(""); - toast.success(result.message); + showToast(result.message); router.refresh(); } catch { setError("The model could not be saved. Try again."); diff --git a/apps/web/ui/admin/key-dates/key-date-dialog.tsx b/apps/web/ui/admin/key-dates/key-date-dialog.tsx index ad3dfe09..03cc43fc 100644 --- a/apps/web/ui/admin/key-dates/key-date-dialog.tsx +++ b/apps/web/ui/admin/key-dates/key-date-dialog.tsx @@ -2,7 +2,6 @@ import { useId, useState, type FormEvent, type ReactNode } from "react"; import { Info } from "lucide-react"; -import { toast } from "sonner"; import { Button } from "@coursemap/ui/primitives/button"; import { Dialog, @@ -21,6 +20,7 @@ import { } from "@/lib/admin/key-dates-actions"; import { DatePicker } from "@/ui/common/date-picker"; import { Hint } from "@/ui/common/hint"; +import { showToast } from "@/ui/common/toast"; export type KeyDateDraft = { id?: number; date: string; title: string }; @@ -98,7 +98,7 @@ export function KeyDateDialog({ setError(result.message); return; } - toast.success(result.message); + showToast(result.message); changeOpen(false); } catch { setError("The key date could not be saved. Try again."); diff --git a/apps/web/ui/admin/key-dates/key-date-row-menu.tsx b/apps/web/ui/admin/key-dates/key-date-row-menu.tsx index 6a1e6cfe..e54df80c 100644 --- a/apps/web/ui/admin/key-dates/key-date-row-menu.tsx +++ b/apps/web/ui/admin/key-dates/key-date-row-menu.tsx @@ -2,7 +2,6 @@ import { useRef, useState } from "react"; import { EllipsisVertical, Pencil, Trash2 } from "lucide-react"; -import { toast } from "sonner"; import { Button } from "@coursemap/ui/primitives/button"; import { DropdownMenu, @@ -19,6 +18,7 @@ import type { UniversityCalendarReviewEvent } from "@/lib/coursemap/university-c import { ConfirmDialog } from "@/ui/common/confirm-dialog"; import { KeyDateDialog } from "@/ui/admin/key-dates/key-date-dialog"; import { calendarDateLabel } from "@/ui/key-dates/category-badge"; +import { showToast } from "@/ui/common/toast"; /** * Edit and remove for one row. A published date is changed directly; a date @@ -46,7 +46,7 @@ export function KeyDateRowMenu({ ? await reviseKeyDatesReviewAction(reviewId!, year, event, null) : await removeKeyDateAction(year, event.eventId!); if (!result.ok) throw new Error(result.message); - toast.success(result.message); + showToast(result.message); } return ( diff --git a/apps/web/ui/admin/key-dates/key-dates-review.tsx b/apps/web/ui/admin/key-dates/key-dates-review.tsx index ca6f4768..1ae7a214 100644 --- a/apps/web/ui/admin/key-dates/key-dates-review.tsx +++ b/apps/web/ui/admin/key-dates/key-dates-review.tsx @@ -9,7 +9,6 @@ import { ExternalLink, TriangleAlert, } from "lucide-react"; -import { toast } from "sonner"; import { Alert, AlertDescription, @@ -34,6 +33,7 @@ import type { ImportDiagnostic } from "@/lib/catalogue-import/import-source"; import { ConfirmDialog } from "@/ui/common/confirm-dialog"; import { OutlinedTabsList } from "@/ui/common/outlined-tabs-list"; import { KeyDatesMonthList } from "@/ui/admin/key-dates/key-dates-month-list"; +import { showToast } from "@/ui/common/toast"; const timestampFormat = new Intl.DateTimeFormat("en-AU", { dateStyle: "medium", @@ -120,14 +120,14 @@ export function KeyDatesReviewPanel({ async function approve() { const result = await approveKeyDatesReviewAction(review.id, year); if (!result.ok) throw new Error(result.message); - toast.success(result.message); + showToast(result.message); router.push(`/admin/key-dates/${year}`); } async function discard() { const result = await discardKeyDatesReviewAction(review.id, year); if (!result.ok) throw new Error(result.message); - toast.success(result.message); + showToast(result.message); } return ( diff --git a/apps/web/ui/admin/key-dates/key-dates-sync-button.tsx b/apps/web/ui/admin/key-dates/key-dates-sync-button.tsx index b7c93fac..0d67b3fd 100644 --- a/apps/web/ui/admin/key-dates/key-dates-sync-button.tsx +++ b/apps/web/ui/admin/key-dates/key-dates-sync-button.tsx @@ -3,10 +3,10 @@ import { useRouter } from "next/navigation"; import { useTransition } from "react"; import { RefreshCw } from "lucide-react"; -import { toast } from "sonner"; import { Button } from "@coursemap/ui/primitives/button"; import { cn } from "@/lib/cn"; import { syncKeyDatesAction } from "@/lib/admin/key-dates-actions"; +import { showToast } from "@/ui/common/toast"; /** Fetches the year from the ANU calendar and stages it for review. */ export function KeyDatesSyncButton({ @@ -25,12 +25,12 @@ export function KeyDatesSyncButton({ startTransition(async () => { try { const result = await syncKeyDatesAction(year); - if (result.ok) toast.success(result.message); - else toast.error(result.message); + if (result.ok) showToast(result.message); + else showToast(result.message, "error"); if (result.staged) router.push(`/admin/key-dates/${year}/sync`, { scroll: false }); } catch { - toast.error(`The ${year} calendar could not be synced. Try again.`); + showToast(`Couldn't sync the ${year} calendar. Try again.`, "error"); } }); } diff --git a/apps/web/ui/admin/rooms/indoor-editor.tsx b/apps/web/ui/admin/rooms/indoor-editor.tsx index 177914ef..d2fdbb4a 100644 --- a/apps/web/ui/admin/rooms/indoor-editor.tsx +++ b/apps/web/ui/admin/rooms/indoor-editor.tsx @@ -1,6 +1,5 @@ "use client"; import { buildingDrawingAngle } from "@/lib/rooms/indoor-orientation"; -import { toast } from "sonner"; import { useSearchParams } from "next/navigation"; import { @@ -80,6 +79,7 @@ import { type CampusMapData, type CampusMapPlace, } from "@/lib/rooms/campus-map"; +import { showToast } from "@/ui/common/toast"; /** Old links used `?tab=`; the 3D views map onto the perspective toggle. */ function perspectiveFromSearch(value: string | null) { @@ -361,7 +361,7 @@ export function IndoorEditor({ status, }); if (result.ok) { - toast.success(result.message); + showToast(result.message); setSavedRecord((current) => ({ ...current, name, @@ -377,13 +377,14 @@ export function IndoorEditor({ sourceName, }); } else { - toast.error(result.message); + showToast(result.message, "error"); } } catch (error) { - toast.error( + showToast( error instanceof Error ? error.message - : "This floor plan could not be saved.", + : "Couldn't save this floor plan", + "error", ); } finally { setSavingStatus(null); diff --git a/apps/web/ui/common/filter-bar.tsx b/apps/web/ui/common/filter-bar.tsx index 58346258..5e17d686 100644 --- a/apps/web/ui/common/filter-bar.tsx +++ b/apps/web/ui/common/filter-bar.tsx @@ -64,9 +64,19 @@ export function FilterBar({ const urlQuery = searchParams.get("q") ?? ""; const [localQuery, setLocalQuery] = useState(urlQuery); const [previousUrlQuery, setPreviousUrlQuery] = useState(urlQuery); + // Searches sent to the URL that have not landed yet. An earlier one landing + // while the reader keeps typing must not overwrite the box, so only a + // change from elsewhere (back, a clear link) resets the input. + const [sentQueries, setSentQueries] = useState([]); if (urlQuery !== previousUrlQuery) { setPreviousUrlQuery(urlQuery); - setLocalQuery(urlQuery); + const sent = sentQueries.indexOf(urlQuery); + if (sent >= 0) { + setSentQueries(sentQueries.slice(sent + 1)); + } else { + setSentQueries([]); + setLocalQuery(urlQuery); + } } const [isPending, startTransition] = useTransition(); const [menuOpen, setMenuOpen] = useState(false); @@ -171,7 +181,11 @@ export function FilterBar({ } setLocalQuery(value); if (timeout.current) clearTimeout(timeout.current); - timeout.current = setTimeout(() => update("q", value), 250); + timeout.current = setTimeout(() => { + const sent = value.trim(); + setSentQueries((current) => [...current, sent]); + update("q", sent); + }, 250); }} /> diff --git a/apps/web/ui/common/option-picker.tsx b/apps/web/ui/common/option-picker.tsx index b5792dc6..d05ef2cb 100644 --- a/apps/web/ui/common/option-picker.tsx +++ b/apps/web/ui/common/option-picker.tsx @@ -43,7 +43,10 @@ export function OptionPicker({ } return ( - + // Modal so the list takes over the scroll lock. Otherwise a picker inside a + // dialog cannot be wheel-scrolled, because its portalled list sits outside + // the dialog that owns the lock. +