diff --git a/apps/web/app/admin/courses/loading.tsx b/apps/web/app/admin/courses/(directory)/loading.tsx
similarity index 100%
rename from apps/web/app/admin/courses/loading.tsx
rename to apps/web/app/admin/courses/(directory)/loading.tsx
diff --git a/apps/web/app/admin/courses/page.tsx b/apps/web/app/admin/courses/(directory)/page.tsx
similarity index 100%
rename from apps/web/app/admin/courses/page.tsx
rename to apps/web/app/admin/courses/(directory)/page.tsx
diff --git a/apps/web/app/admin/courses/[year]/(directory)/loading.tsx b/apps/web/app/admin/courses/[year]/(directory)/loading.tsx
new file mode 100644
index 00000000..66f3d6f0
--- /dev/null
+++ b/apps/web/app/admin/courses/[year]/(directory)/loading.tsx
@@ -0,0 +1,7 @@
+import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";
+
+export default function Loading() {
+ return (
+
+ );
+}
diff --git a/apps/web/app/admin/courses/[year]/page.tsx b/apps/web/app/admin/courses/[year]/(directory)/page.tsx
similarity index 99%
rename from apps/web/app/admin/courses/[year]/page.tsx
rename to apps/web/app/admin/courses/[year]/(directory)/page.tsx
index 5f11ec61..d05f4d78 100644
--- a/apps/web/app/admin/courses/[year]/page.tsx
+++ b/apps/web/app/admin/courses/[year]/(directory)/page.tsx
@@ -1,6 +1,8 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueYearRoute } from "@/ui/admin/catalogue/catalogue-route-pages";
+
export const dynamic = "force-dynamic";
+
export default async function Page({
params,
searchParams,
diff --git a/apps/web/app/admin/majors/loading.tsx b/apps/web/app/admin/majors/(directory)/loading.tsx
similarity index 100%
rename from apps/web/app/admin/majors/loading.tsx
rename to apps/web/app/admin/majors/(directory)/loading.tsx
diff --git a/apps/web/app/admin/majors/page.tsx b/apps/web/app/admin/majors/(directory)/page.tsx
similarity index 100%
rename from apps/web/app/admin/majors/page.tsx
rename to apps/web/app/admin/majors/(directory)/page.tsx
diff --git a/apps/web/app/admin/majors/[year]/(directory)/loading.tsx b/apps/web/app/admin/majors/[year]/(directory)/loading.tsx
new file mode 100644
index 00000000..aa5600be
--- /dev/null
+++ b/apps/web/app/admin/majors/[year]/(directory)/loading.tsx
@@ -0,0 +1,5 @@
+import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";
+
+export default function Loading() {
+ return ;
+}
diff --git a/apps/web/app/admin/majors/[year]/page.tsx b/apps/web/app/admin/majors/[year]/(directory)/page.tsx
similarity index 99%
rename from apps/web/app/admin/majors/[year]/page.tsx
rename to apps/web/app/admin/majors/[year]/(directory)/page.tsx
index 1e0ff5cf..63dd1811 100644
--- a/apps/web/app/admin/majors/[year]/page.tsx
+++ b/apps/web/app/admin/majors/[year]/(directory)/page.tsx
@@ -1,6 +1,8 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueYearRoute } from "@/ui/admin/catalogue/catalogue-route-pages";
+
export const dynamic = "force-dynamic";
+
export default async function Page({
params,
searchParams,
diff --git a/apps/web/app/admin/minors/loading.tsx b/apps/web/app/admin/minors/(directory)/loading.tsx
similarity index 100%
rename from apps/web/app/admin/minors/loading.tsx
rename to apps/web/app/admin/minors/(directory)/loading.tsx
diff --git a/apps/web/app/admin/minors/page.tsx b/apps/web/app/admin/minors/(directory)/page.tsx
similarity index 100%
rename from apps/web/app/admin/minors/page.tsx
rename to apps/web/app/admin/minors/(directory)/page.tsx
diff --git a/apps/web/app/admin/minors/[year]/(directory)/loading.tsx b/apps/web/app/admin/minors/[year]/(directory)/loading.tsx
new file mode 100644
index 00000000..7b9506c3
--- /dev/null
+++ b/apps/web/app/admin/minors/[year]/(directory)/loading.tsx
@@ -0,0 +1,5 @@
+import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";
+
+export default function Loading() {
+ return ;
+}
diff --git a/apps/web/app/admin/minors/[year]/page.tsx b/apps/web/app/admin/minors/[year]/(directory)/page.tsx
similarity index 99%
rename from apps/web/app/admin/minors/[year]/page.tsx
rename to apps/web/app/admin/minors/[year]/(directory)/page.tsx
index 673b30a3..00a42ba6 100644
--- a/apps/web/app/admin/minors/[year]/page.tsx
+++ b/apps/web/app/admin/minors/[year]/(directory)/page.tsx
@@ -1,6 +1,8 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueYearRoute } from "@/ui/admin/catalogue/catalogue-route-pages";
+
export const dynamic = "force-dynamic";
+
export default async function Page({
params,
searchParams,
diff --git a/apps/web/app/admin/programmes/loading.tsx b/apps/web/app/admin/programmes/(directory)/loading.tsx
similarity index 100%
rename from apps/web/app/admin/programmes/loading.tsx
rename to apps/web/app/admin/programmes/(directory)/loading.tsx
diff --git a/apps/web/app/admin/programmes/page.tsx b/apps/web/app/admin/programmes/(directory)/page.tsx
similarity index 100%
rename from apps/web/app/admin/programmes/page.tsx
rename to apps/web/app/admin/programmes/(directory)/page.tsx
diff --git a/apps/web/app/admin/programmes/[year]/(directory)/loading.tsx b/apps/web/app/admin/programmes/[year]/(directory)/loading.tsx
new file mode 100644
index 00000000..ad1b04e3
--- /dev/null
+++ b/apps/web/app/admin/programmes/[year]/(directory)/loading.tsx
@@ -0,0 +1,7 @@
+import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";
+
+export default function Loading() {
+ return (
+
+ );
+}
diff --git a/apps/web/app/admin/programmes/[year]/page.tsx b/apps/web/app/admin/programmes/[year]/(directory)/page.tsx
similarity index 99%
rename from apps/web/app/admin/programmes/[year]/page.tsx
rename to apps/web/app/admin/programmes/[year]/(directory)/page.tsx
index e2a914b2..c0eeb402 100644
--- a/apps/web/app/admin/programmes/[year]/page.tsx
+++ b/apps/web/app/admin/programmes/[year]/(directory)/page.tsx
@@ -1,6 +1,8 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueYearRoute } from "@/ui/admin/catalogue/catalogue-route-pages";
+
export const dynamic = "force-dynamic";
+
export default async function Page({
params,
searchParams,
diff --git a/apps/web/app/admin/specialisations/loading.tsx b/apps/web/app/admin/specialisations/(directory)/loading.tsx
similarity index 100%
rename from apps/web/app/admin/specialisations/loading.tsx
rename to apps/web/app/admin/specialisations/(directory)/loading.tsx
diff --git a/apps/web/app/admin/specialisations/page.tsx b/apps/web/app/admin/specialisations/(directory)/page.tsx
similarity index 100%
rename from apps/web/app/admin/specialisations/page.tsx
rename to apps/web/app/admin/specialisations/(directory)/page.tsx
diff --git a/apps/web/app/admin/specialisations/[year]/(directory)/loading.tsx b/apps/web/app/admin/specialisations/[year]/(directory)/loading.tsx
new file mode 100644
index 00000000..94a80755
--- /dev/null
+++ b/apps/web/app/admin/specialisations/[year]/(directory)/loading.tsx
@@ -0,0 +1,11 @@
+import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";
+
+export default function Loading() {
+ return (
+
+ );
+}
diff --git a/apps/web/app/admin/specialisations/[year]/page.tsx b/apps/web/app/admin/specialisations/[year]/(directory)/page.tsx
similarity index 99%
rename from apps/web/app/admin/specialisations/[year]/page.tsx
rename to apps/web/app/admin/specialisations/[year]/(directory)/page.tsx
index 70f3691f..db5c2b26 100644
--- a/apps/web/app/admin/specialisations/[year]/page.tsx
+++ b/apps/web/app/admin/specialisations/[year]/(directory)/page.tsx
@@ -1,6 +1,8 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueYearRoute } from "@/ui/admin/catalogue/catalogue-route-pages";
+
export const dynamic = "force-dynamic";
+
export default async function Page({
params,
searchParams,
diff --git a/apps/web/lib/assistant/draft-store.ts b/apps/web/lib/assistant/draft-store.ts
index 17034fcd..9242b05a 100644
--- a/apps/web/lib/assistant/draft-store.ts
+++ b/apps/web/lib/assistant/draft-store.ts
@@ -7,29 +7,31 @@ export function createAssistantDraftStore(key: string) {
let snapshot = empty;
let loaded = false;
const listeners = new Set<() => void>();
- function getSnapshot() {
- if (!loaded && typeof window !== "undefined") {
- try {
- snapshot = readAssistantHistory(localStorage.getItem(key));
- } catch {
- snapshot = empty;
- }
- loaded = true;
+ function loadStoredSnapshot() {
+ if (loaded) return;
+ try {
+ snapshot = readAssistantHistory(localStorage.getItem(key));
+ } catch {
+ snapshot = empty;
}
- return snapshot;
+ loaded = true;
}
function notify() {
listeners.forEach((listener) => listener());
}
return {
- getSnapshot,
+ // Keep the first browser snapshot equal to the server snapshot. Storage is
+ // read when React subscribes after hydration, then React's subscription
+ // check applies the restored drafts without changing the server markup.
+ getSnapshot: () => snapshot,
getServerSnapshot: () => empty,
subscribe(listener: () => void) {
listeners.add(listener);
+ loadStoredSnapshot();
function onStorage(event: StorageEvent) {
if (event.key === key || event.key === null) {
loaded = false;
- getSnapshot();
+ loadStoredSnapshot();
notify();
}
}
@@ -40,7 +42,8 @@ export function createAssistantDraftStore(key: string) {
};
},
update(change: (previous: AssistantDraft[]) => AssistantDraft[]) {
- snapshot = change(getSnapshot());
+ loadStoredSnapshot();
+ snapshot = change(snapshot);
try {
localStorage.setItem(key, JSON.stringify(snapshot));
} catch {
diff --git a/apps/web/lib/coursemap/catalogue-kinds.ts b/apps/web/lib/coursemap/catalogue-kinds.ts
index 2695215a..40d616c4 100644
--- a/apps/web/lib/coursemap/catalogue-kinds.ts
+++ b/apps/web/lib/coursemap/catalogue-kinds.ts
@@ -47,6 +47,16 @@ export function adminCatalogueVersionPath(
return `${adminCatalogueRecordPath(kind, year, code)}/changelog/${versionOrdinal}`;
}
+export const ADMIN_CATALOGUE_OPERATIONS_PATH = "/admin/operations/catalogue";
+
+export function adminCatalogueSyncPath(syncId: string) {
+ return `${ADMIN_CATALOGUE_OPERATIONS_PATH}/syncs/${syncId}`;
+}
+
+export function adminCatalogueDiscoveryPath(checkId: number) {
+ return `${ADMIN_CATALOGUE_OPERATIONS_PATH}/discovery/${checkId}`;
+}
+
export function publicCatalogueRecordPath(
kind: CatalogueKind,
year: number,
@@ -80,6 +90,13 @@ export type CatalogueDirectoryRecord = {
} | null;
};
+export type CatalogueTableLayout =
+ | "public-courses"
+ | "users"
+ | "directory"
+ | "operations-syncs"
+ | "operations-discovery";
+
export type CatalogueDirectoryPage = {
kind: CatalogueKind;
academicYear: number;
diff --git a/apps/web/tests/assistant-panel.test.tsx b/apps/web/tests/assistant-panel.test.tsx
index 36a5031d..c67f594f 100644
--- a/apps/web/tests/assistant-panel.test.tsx
+++ b/apps/web/tests/assistant-panel.test.tsx
@@ -8,6 +8,7 @@ import { ASSISTANT_PREVIEW_RESPONSE } from "@/lib/assistant/history";
import { SidebarProvider, SidebarMenu } from "@coursemap/ui/primitives/sidebar";
import { AssistantRecentChat } from "@/ui/assistant/assistant-recent-chat";
import { assistantTitle, readAssistantHistory } from "@/lib/assistant/history";
+import { createAssistantDraftStore } from "@/lib/assistant/draft-store";
import {
AssistantProvider,
useAssistant,
@@ -50,6 +51,29 @@ vi.mock("sonner", () => ({ toast: { info: vi.fn() } }));
vi.mock("@coursemap/ui/hooks/use-mobile", () => ({ useIsMobile: () => false }));
+test("restores browser drafts only after the hydration snapshot", () => {
+ localStorage.setItem(
+ "coursemap:compass:drafts:test",
+ JSON.stringify([
+ {
+ id: "saved",
+ draft: "Continue planning",
+ model: "",
+ updatedAt: "2026-09-22T00:00:00.000Z",
+ messages: [],
+ },
+ ]),
+ );
+ const store = createAssistantDraftStore("coursemap:compass:drafts:test");
+ expect(store.getServerSnapshot()).toEqual([]);
+ expect(store.getSnapshot()).toEqual([]);
+ const unsubscribe = store.subscribe(() => {});
+ expect(store.getSnapshot()).toEqual([
+ expect.objectContaining({ id: "saved", draft: "Continue planning" }),
+ ]);
+ unsubscribe();
+});
+
test("keeps a draft when closed and reopened, and clears it for a new chat", () => {
const close = vi.fn();
const { rerender } = render(
diff --git a/apps/web/tests/breadcrumbs.test.tsx b/apps/web/tests/breadcrumbs.test.tsx
index 7aa8b49e..b5524975 100644
--- a/apps/web/tests/breadcrumbs.test.tsx
+++ b/apps/web/tests/breadcrumbs.test.tsx
@@ -149,3 +149,16 @@ test("shows three short breadcrumbs until width requires collapsing the middle",
resize(600);
expect(within(trail).getByRole("link", { name: "Courses" })).toBeVisible();
});
+
+test("does not repeat a catalogue section on its year directory", () => {
+ measureAt(600);
+ render();
+ const trail = screen.getByRole("navigation", { name: "Breadcrumb" });
+ expect(within(trail).getAllByRole("listitem")).toHaveLength(2);
+ expect(within(trail).getByRole("link", { name: "Admin" })).toBeVisible();
+ expect(within(trail).getByRole("link", { name: "Courses" })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ expect(trail).not.toHaveTextContent("2026");
+});
diff --git a/apps/web/tests/catalogue-empty.test.tsx b/apps/web/tests/catalogue-empty.test.tsx
new file mode 100644
index 00000000..a22921c0
--- /dev/null
+++ b/apps/web/tests/catalogue-empty.test.tsx
@@ -0,0 +1,21 @@
+import { render } from "@testing-library/react";
+import { expect, test } from "vitest";
+import { CatalogueEmpty } from "@/ui/admin/catalogue-table/catalogue-empty";
+
+test("uses stable artwork for catalogue empty states", () => {
+ const { container, rerender } = render(
+ ,
+ );
+ expect(container.querySelector("svg")).toBeInTheDocument();
+ expect(container).not.toHaveTextContent("0.");
+
+ rerender(
+ ,
+ );
+ expect(container.querySelector("svg")).toBeInTheDocument();
+ expect(container.querySelectorAll("svg")).toHaveLength(1);
+});
diff --git a/apps/web/tests/operations-sync-views.test.tsx b/apps/web/tests/operations-sync-views.test.tsx
index a82f38e7..f71ea429 100644
--- a/apps/web/tests/operations-sync-views.test.tsx
+++ b/apps/web/tests/operations-sync-views.test.tsx
@@ -173,7 +173,7 @@ test("the sync list carries the technical columns an operator needs", () => {
test("an empty list says what fills it rather than showing an empty table", () => {
renderSyncList(syncPage({ rows: [], total: 0 }));
- expect(screen.getByText("No syncs match")).toBeTruthy();
+ expect(screen.getByText("No syncs yet")).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
});
@@ -217,6 +217,7 @@ test("an incomplete listing check says so, because it cannot retire anything", (
render();
expect(screen.getByText("Partial")).toBeTruthy();
expect(
- screen.getByRole("link", { name: "course" }).getAttribute("href"),
+ screen.getByRole("link", { name: "Courses" }).getAttribute("href"),
).toBe("/admin/operations/catalogue/discovery/7");
+ expect(screen.getByText("120 discovered")).toBeTruthy();
});
diff --git a/apps/web/ui/admin/catalogue-table/catalogue-empty.tsx b/apps/web/ui/admin/catalogue-table/catalogue-empty.tsx
index bef74758..5c17f5ec 100644
--- a/apps/web/ui/admin/catalogue-table/catalogue-empty.tsx
+++ b/apps/web/ui/admin/catalogue-table/catalogue-empty.tsx
@@ -9,7 +9,6 @@ export function CatalogueEmpty({
title,
description,
filtered = false,
- imports = false,
error = false,
clearHref,
onSync,
@@ -18,7 +17,6 @@ export function CatalogueEmpty({
title: string;
description: string;
filtered?: boolean;
- imports?: boolean;
error?: boolean;
clearHref?: string;
onSync?: () => void;
@@ -26,11 +24,7 @@ export function CatalogueEmpty({
}) {
return (
- {error ? (
-
- ) : (
-
- )}
+ {error ?
:
}
{filtered ? "No matches this time." : title}
diff --git a/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx b/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx
index b2aa4392..51508a17 100644
--- a/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx
+++ b/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx
@@ -18,7 +18,12 @@ import {
* replaces it and the whole list reflows on arrival.
*/
export type CatalogueLoadingLayout =
- "public-courses" | "users" | "directory" | "import-records";
+ | "public-courses"
+ | "users"
+ | "directory"
+ | "import-records"
+ | "operations-syncs"
+ | "operations-discovery";
/**
* A skeleton cell per real cell. The kind decides the shape, so a placeholder
@@ -47,6 +52,27 @@ function columnsFor(noun: string, layout: CatalogueLoadingLayout): Column[] {
{ label: "Updated", kind: "text" },
{ label: "Actions", kind: "actions" },
];
+ if (layout === "operations-syncs")
+ return [
+ { label: "Record", kind: "identity" },
+ { label: "Year", kind: "text" },
+ { label: "Status", kind: "text" },
+ { label: "Trigger", kind: "text" },
+ { label: "Started", kind: "text" },
+ { label: "Duration", kind: "text" },
+ { label: "Model", kind: "text" },
+ { label: "Cost", kind: "text" },
+ ];
+ if (layout === "operations-discovery")
+ return [
+ { label: "Listing", kind: "identity" },
+ { label: "Year", kind: "text" },
+ { label: "Status", kind: "text" },
+ { label: "Complete", kind: "text" },
+ { label: "Discovered", kind: "text" },
+ { label: "Started", kind: "text" },
+ { label: "Duration", kind: "text" },
+ ];
if (layout === "import-records")
return [
{ label: "Import", kind: "identity" },
@@ -174,12 +200,24 @@ 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-table.module.css b/apps/web/ui/admin/catalogue-table/catalogue-table.module.css
index 82f100f2..a9920e90 100644
--- a/apps/web/ui/admin/catalogue-table/catalogue-table.module.css
+++ b/apps/web/ui/admin/catalogue-table/catalogue-table.module.css
@@ -186,6 +186,24 @@ a.title:focus-visible {
170px 170px 60px;
}
+/* Operations syncs: record, year, status, trigger, started, duration, model,
+ cost. Wider than the catalogue layouts because every column here is a fact
+ rather than a label. */
+.shell[data-layout="operations-syncs"] tr {
+ min-width: 1020px;
+ grid-template-columns:
+ minmax(220px, 1.2fr) 72px 150px 100px 170px
+ 92px minmax(150px, 1fr) 96px;
+}
+/* Operations discovery: kind, year, status, completeness, discovered,
+ started, duration. */
+.shell[data-layout="operations-discovery"] tr {
+ min-width: 880px;
+ grid-template-columns:
+ minmax(180px, 1fr) 72px 130px 110px 110px 170px
+ 92px;
+}
+
@media (pointer: coarse) {
.actions button {
min-height: 44px;
diff --git a/apps/web/ui/admin/catalogue-table/catalogue-table.tsx b/apps/web/ui/admin/catalogue-table/catalogue-table.tsx
index 6d99022f..db95d128 100644
--- a/apps/web/ui/admin/catalogue-table/catalogue-table.tsx
+++ b/apps/web/ui/admin/catalogue-table/catalogue-table.tsx
@@ -14,6 +14,7 @@ import {
Globe,
Scale,
} from "lucide-react";
+import type { CatalogueTableLayout } from "@/lib/coursemap/catalogue-kinds";
import styles from "./catalogue-table.module.css";
export {
@@ -38,7 +39,7 @@ export function DataTableShell({
footer?: ReactNode;
selectable?: boolean;
imports?: boolean;
- layout?: "public-courses" | "users" | "directory";
+ layout?: CatalogueTableLayout;
}) {
return (
;
const params = await searchParams;
const labels = CATALOGUE_KIND_LABELS[kind];
- const page = loadCatalogueDirectoryPage({
+ const page = await loadCatalogueDirectoryPage({
kind,
academicYear,
query: first(params.q) ?? "",
page: Number(first(params.page)) || 1,
});
return (
-
+
{labels.plural}
-
- }
- >
-
-
+
);
}
-
-async function DirectoryContent({
- page,
-}: {
- page: ReturnType;
-}) {
- const resolved = await page;
- return ;
-}
diff --git a/apps/web/ui/admin/operations/discovery-detail.tsx b/apps/web/ui/admin/operations/discovery-detail.tsx
index 50fa7daf..aa5d8b15 100644
--- a/apps/web/ui/admin/operations/discovery-detail.tsx
+++ b/apps/web/ui/admin/operations/discovery-detail.tsx
@@ -1,3 +1,4 @@
+import { ADMIN_CATALOGUE_OPERATIONS_PATH } from "@/lib/coursemap/catalogue-kinds";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import {
@@ -8,7 +9,6 @@ import {
import { Badge } from "@coursemap/ui/components/badge";
import type { DiscoveryCheckDetail } from "@/lib/coursemap/admin-operations";
import { formatDuration, formatTimestamp } from "./operations-format";
-import { CATALOGUE_OPERATIONS_PATH } from "./operations-tabs";
/**
* One listing check. Only a complete check can retire a record, so its
@@ -23,7 +23,7 @@ export function DiscoveryDetailView({
Back to discovery
diff --git a/apps/web/ui/admin/operations/discovery-list.tsx b/apps/web/ui/admin/operations/discovery-list.tsx
index ca297207..4837a69b 100644
--- a/apps/web/ui/admin/operations/discovery-list.tsx
+++ b/apps/web/ui/admin/operations/discovery-list.tsx
@@ -1,6 +1,11 @@
-import Link from "next/link";
+import {
+ CATALOGUE_KIND_LABELS,
+ adminCatalogueDiscoveryPath,
+} from "@/lib/coursemap/catalogue-kinds";
import { Badge } from "@coursemap/ui/components/badge";
import {
+ CatalogueIdentity,
+ DataTableShell,
Table,
TableBody,
TableCaption,
@@ -8,12 +13,11 @@ import {
TableHead,
TableHeader,
TableRow,
-} from "@coursemap/ui/primitives/table";
+} from "@/ui/admin/catalogue-table/catalogue-table";
import type { DiscoveryCheckRow } from "@/lib/coursemap/admin-operations";
-import { DataTableEmpty, DataTableShell } from "@/ui/common/data-table";
+import { CatalogueEmpty } from "@/ui/admin/catalogue-table/catalogue-empty";
import { LinkedTableRow } from "@/ui/common/linked-table-row";
import { formatDuration, formatTimestamp } from "./operations-format";
-import { CATALOGUE_OPERATIONS_PATH } from "./operations-tabs";
/**
* ANU listing checks. An incomplete check is why a record can be missing from
@@ -22,19 +26,19 @@ import { CATALOGUE_OPERATIONS_PATH } from "./operations-tabs";
export function DiscoveryList({ checks }: { checks: DiscoveryCheckRow[] }) {
if (checks.length === 0) {
return (
-
);
}
return (
-
-
+
+
ANU listing checks
- Kind
+ Listing
Year
Status
Complete
@@ -47,12 +51,12 @@ export function DiscoveryList({ checks }: { checks: DiscoveryCheckRow[] }) {
{checks.map((check) => (
-
- {check.kind}
-
+
{check.academicYear}
diff --git a/apps/web/ui/admin/operations/operations-pages.tsx b/apps/web/ui/admin/operations/operations-pages.tsx
index 63d7d05a..81487a5a 100644
--- a/apps/web/ui/admin/operations/operations-pages.tsx
+++ b/apps/web/ui/admin/operations/operations-pages.tsx
@@ -1,3 +1,4 @@
+import { Suspense } from "react";
import { notFound } from "next/navigation";
import { canManageCatalogueOperations } from "@/lib/auth/viewer";
import {
@@ -6,6 +7,7 @@ import {
loadSyncDetail,
loadSyncOperationsPage,
} from "@/lib/coursemap/admin-operations";
+import { CatalogueTableLoading } from "@/ui/admin/catalogue-table/catalogue-loading";
import { AccessDeniedError } from "@/ui/errors/access-denied-error";
import { AppShell } from "@/ui/shell";
import { DiscoveryDetailView } from "./discovery-detail";
@@ -37,31 +39,63 @@ export async function CatalogueOperationsPage({
if (!(await canManageCatalogueOperations())) return ;
const page =
section === "syncs"
- ? await loadSyncOperationsPage({
+ ? loadSyncOperationsPage({
query: first(searchParams.q) ?? "",
status: first(searchParams.status) ?? "all",
page: Number(first(searchParams.page)) || 1,
})
: null;
- const checks = section === "discovery" ? await loadDiscoveryChecks() : [];
+ const checks = section === "discovery" ? loadDiscoveryChecks() : null;
return (
}
>
-
-
Catalogue operations
- {page ? : }
-
+ Catalogue operations
+
+ }
+ >
+ {page ? (
+
+ ) : (
+
+ )}
+
);
}
+async function SyncsContent({
+ page,
+}: {
+ page: ReturnType;
+}) {
+ return ;
+}
+
+async function DiscoveryContent({
+ checks,
+}: {
+ checks: ReturnType;
+}) {
+ return ;
+}
+
export async function CatalogueSyncDetailPage({ syncId }: { syncId: string }) {
if (!(await canManageCatalogueOperations())) return ;
const sync = await loadSyncDetail(syncId);
diff --git a/apps/web/ui/admin/operations/operations-tabs.tsx b/apps/web/ui/admin/operations/operations-tabs.tsx
index ce2269b1..cd65bb50 100644
--- a/apps/web/ui/admin/operations/operations-tabs.tsx
+++ b/apps/web/ui/admin/operations/operations-tabs.tsx
@@ -3,12 +3,11 @@
import { Tabs } from "@coursemap/ui/primitives/tabs";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
+import { ADMIN_CATALOGUE_OPERATIONS_PATH } from "@/lib/coursemap/catalogue-kinds";
import { SectionTabs } from "@/ui/common/section-tabs";
export type OperationsSection = "syncs" | "discovery";
-export const CATALOGUE_OPERATIONS_PATH = "/admin/operations/catalogue";
-
export function OperationsTabs({
value,
children,
@@ -23,8 +22,8 @@ export function OperationsTabs({
onValueChange={(next) =>
router.push(
next === "syncs"
- ? CATALOGUE_OPERATIONS_PATH
- : `${CATALOGUE_OPERATIONS_PATH}/${next}`,
+ ? ADMIN_CATALOGUE_OPERATIONS_PATH
+ : `${ADMIN_CATALOGUE_OPERATIONS_PATH}/${next}`,
)
}
value={value}
diff --git a/apps/web/ui/admin/operations/sync-detail.tsx b/apps/web/ui/admin/operations/sync-detail.tsx
index e52a61d0..16caf4af 100644
--- a/apps/web/ui/admin/operations/sync-detail.tsx
+++ b/apps/web/ui/admin/operations/sync-detail.tsx
@@ -1,3 +1,7 @@
+import {
+ ADMIN_CATALOGUE_OPERATIONS_PATH,
+ adminCatalogueRecordPath,
+} from "@/lib/coursemap/catalogue-kinds";
import { ArrowLeft, ExternalLink } from "lucide-react";
import Link from "next/link";
import {
@@ -17,7 +21,6 @@ import {
} from "@coursemap/ui/primitives/table";
import { badgeVariantForTone } from "@/lib/ui";
import type { SyncDetail } from "@/lib/coursemap/admin-operations";
-import { adminCatalogueRecordPath } from "@/lib/coursemap/catalogue-kinds";
import { DataTableShell } from "@/ui/common/data-table";
import { ArtefactViewer } from "./artefact-viewer";
import {
@@ -28,7 +31,6 @@ import {
syncStatusLabel,
syncStatusTone,
} from "./operations-format";
-import { CATALOGUE_OPERATIONS_PATH } from "./operations-tabs";
const STAGE_LABELS: Record = {
source_fetch: "Source fetch",
@@ -88,7 +90,7 @@ export function SyncDetailView({ sync }: { sync: SyncDetail }) {
Back to syncs
diff --git a/apps/web/ui/admin/operations/sync-list.tsx b/apps/web/ui/admin/operations/sync-list.tsx
index d3c280b1..1cbc16a9 100644
--- a/apps/web/ui/admin/operations/sync-list.tsx
+++ b/apps/web/ui/admin/operations/sync-list.tsx
@@ -1,6 +1,11 @@
-import Link from "next/link";
import { Badge } from "@coursemap/ui/components/badge";
import {
+ ADMIN_CATALOGUE_OPERATIONS_PATH,
+ adminCatalogueSyncPath,
+} from "@/lib/coursemap/catalogue-kinds";
+import {
+ CatalogueIdentity,
+ DataTableShell,
Table,
TableBody,
TableCaption,
@@ -8,10 +13,10 @@ import {
TableHead,
TableHeader,
TableRow,
-} from "@coursemap/ui/primitives/table";
+} from "@/ui/admin/catalogue-table/catalogue-table";
import { badgeVariantForTone } from "@/lib/ui";
import type { SyncOperationsPage } from "@/lib/coursemap/admin-operations";
-import { DataTableEmpty, DataTableShell } from "@/ui/common/data-table";
+import { CatalogueEmpty } from "@/ui/admin/catalogue-table/catalogue-empty";
import { FilterBar } from "@/ui/common/filter-bar";
import { LinkedTableRow } from "@/ui/common/linked-table-row";
import { Pagination } from "@/ui/common/pagination";
@@ -22,7 +27,6 @@ import {
syncStatusLabel,
syncStatusTone,
} from "./operations-format";
-import { CATALOGUE_OPERATIONS_PATH } from "./operations-tabs";
const STATUS_OPTIONS = [
"queued",
@@ -43,18 +47,22 @@ export function SyncList({ page }: { page: SyncOperationsPage }) {
filters={[{ key: "status", label: "Status", options: STATUS_OPTIONS }]}
/>
{page.rows.length === 0 ? (
-
) : (
}
>
-
+
Catalogue syncs
@@ -81,15 +89,12 @@ export function SyncList({ page }: { page: SyncOperationsPage }) {
{page.rows.map((row) => (
-
- {row.code}
-
-
- {row.kind}
-
+
{row.academicYear}
diff --git a/apps/web/ui/common/catalogue-illustration.module.css b/apps/web/ui/common/catalogue-illustration.module.css
index a7f5eb10..972e49f8 100644
--- a/apps/web/ui/common/catalogue-illustration.module.css
+++ b/apps/web/ui/common/catalogue-illustration.module.css
@@ -24,184 +24,6 @@
.document {
animation: float 7s ease-in-out infinite;
}
-.cssArt {
- width: 240px;
- height: 150px;
- position: relative;
- margin: 0 auto 24px;
-}
-.cssArt > span {
- position: absolute;
- display: block;
- border: 1px solid var(--color-border-primary, var(--border));
- background: var(--color-bg-primary, var(--background));
- border-radius: 10px;
-}
-.orbit > span {
- width: 44px;
- height: 44px;
- top: 52px;
- left: 98px;
- animation: tileOrbit 8s ease-in-out infinite;
-}
-.orbit > span:nth-child(2) {
- animation-delay: -2.66s;
-}
-.orbit > span:nth-child(3) {
- animation-delay: -5.33s;
- border-color: var(--color-border-brand, var(--primary));
- background: var(--color-bg-brand-secondary, var(--accent));
-}
-.orbit::before {
- content: "";
- position: absolute;
- width: 118px;
- height: 118px;
- border: 1px dashed var(--color-border-secondary, var(--border));
- border-radius: 50%;
- left: 61px;
- top: 15px;
-}
-.orbit i {
- position: absolute;
- inset: 16px;
- border-radius: 3px;
- background: var(--color-fg-brand-secondary, var(--primary));
-}
-.stack > span {
- width: 108px;
- height: 72px;
- left: 66px;
- top: 42px;
- box-shadow: 0 6px 18px
- color-mix(
- in srgb,
- var(--color-text-primary, var(--foreground)) 4%,
- transparent
- );
- animation: stackLift 8s ease-in-out infinite;
-}
-.stack > span:nth-child(1) {
- transform: translate(-14px, -16px) rotate(-8deg);
- animation: none;
-}
-.stack > span:nth-child(2) {
- transform: translate(8px, -8px) rotate(5deg);
- animation: none;
-}
-.stack i {
- position: absolute;
- width: 18px;
- height: 18px;
- left: 14px;
- top: 14px;
- background: var(--color-bg-brand-secondary, var(--accent));
- border: 1px solid var(--color-border-brand, var(--primary));
- border-radius: 5px;
-}
-.stack b,
-.stack em {
- position: absolute;
- height: 4px;
- left: 14px;
- right: 18px;
- top: 43px;
- background: var(--color-border-secondary, var(--border));
- border-radius: 2px;
-}
-.stack em {
- top: 54px;
- right: 38px;
-}
-.quietGrid {
- display: grid;
- grid-template-columns: repeat(3, 28px);
- grid-template-rows: repeat(3, 28px);
- place-content: center;
- gap: 7px;
-}
-.quietGrid > span {
- position: static;
- border-radius: 6px;
- animation: gridRipple 6s ease-in-out infinite;
-}
-.quietGrid > span:nth-child(3n + 2) {
- animation-delay: 0.5s;
-}
-.quietGrid > span:nth-child(3n) {
- animation-delay: 1s;
-}
-.quietGrid > span:nth-child(5) {
- border-color: var(--color-border-brand, var(--primary));
-}
-.typeOnly > span {
- position: static;
- background: none;
- border: 0;
- font-size: 96px;
- font-weight: 500;
- line-height: 150px;
- letter-spacing: -0.07em;
- color: var(--color-text-quaternary, var(--muted-foreground));
-}
-.typeDot {
- color: var(--color-fg-brand-secondary, var(--primary));
- animation: glow 6s ease-in-out infinite;
-}
-@keyframes tileOrbit {
- 0%,
- 100% {
- transform: translate(-55px, 15px) rotate(-12deg);
- }
- 33% {
- transform: translate(0, -38px) rotate(8deg);
- }
- 66% {
- transform: translate(55px, 15px) rotate(-4deg);
- }
-}
-@keyframes stackLift {
- 0%,
- 65%,
- 100% {
- transform: translateY(0);
- }
- 30% {
- transform: translateY(-12px);
- }
-}
-@keyframes gridRipple {
- 0%,
- 65%,
- 100% {
- background: var(--color-bg-primary, var(--background));
- transform: scale(1);
- }
- 30% {
- background: var(--color-bg-brand-secondary, var(--accent));
- transform: scale(0.88);
- }
-}
-@media (prefers-reduced-motion: reduce) {
- .orbit > span:nth-child(1) {
- transform: translate(-55px, 15px);
- }
- .orbit > span:nth-child(2) {
- transform: translateY(-38px);
- }
- .orbit > span:nth-child(3) {
- transform: translate(55px, 15px);
- }
-}
-
-.errorArtwork {
- --color-border-brand: var(--color-fg-error-primary, var(--destructive));
- --color-bg-brand-secondary: color-mix(
- in srgb,
- var(--color-fg-error-primary, var(--destructive)) 15%,
- var(--color-bg-primary, var(--background))
- );
-}
@keyframes float {
0%,
@@ -213,18 +35,7 @@
transform: translateY(-9px);
}
}
-@keyframes glow {
- 0%,
- 65%,
- 100% {
- opacity: 1;
- }
- 30% {
- opacity: 0.5;
- }
-}
@media (prefers-reduced-motion: reduce) {
- .cssArt *,
.illustration * {
animation: none !important;
}
diff --git a/apps/web/ui/common/catalogue-illustration.tsx b/apps/web/ui/common/catalogue-illustration.tsx
index a64479f4..05207e9b 100644
--- a/apps/web/ui/common/catalogue-illustration.tsx
+++ b/apps/web/ui/common/catalogue-illustration.tsx
@@ -1,27 +1,6 @@
import styles from "./catalogue-illustration.module.css";
-export function CatalogueIllustration({ variant }: { variant: number }) {
- if (variant >= 3)
- return (
-
- {variant === 6 ? (
-
- 0.
-
- ) : (
- Array.from({ length: variant === 5 ? 9 : 3 }, (_, index) => (
-
-
-
-
-
- ))
- )}
-
- );
+export function CatalogueIllustration() {
return (