Add registry kind indexes and entity pages.
CONTENT-WP-0002-T02 reads kinds from each snapshot and serves both example dataspaces without oncology imports. Assistant: grok Assistant-Session: 01a1346c-fdff-7741-b1af-b7283661554b
This commit is contained in:
parent
a6e360aaa5
commit
e16aa03393
21 changed files with 608 additions and 59 deletions
31
ui/components/DataspaceHome.tsx
Normal file
31
ui/components/DataspaceHome.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Hero } from "@/components/Hero";
|
||||
import { RecordBrowser } from "@/components/RecordBrowser";
|
||||
import { findDataspace, listNavigation } from "@/lib/catalog";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export function DataspaceHome({ id }: { id: string }) {
|
||||
const dataspace = findDataspace(id);
|
||||
if (!dataspace) notFound();
|
||||
const others = listNavigation().filter((item) => item.id !== dataspace.id);
|
||||
return (
|
||||
<>
|
||||
<Hero
|
||||
dataspace={dataspace.title}
|
||||
description={dataspace.description}
|
||||
kinds={dataspace.kinds.map((kind) => ({ href: kind.href, label: kind.plural }))}
|
||||
/>
|
||||
{others.length > 0 ? (
|
||||
<p className="mx-auto max-w-7xl px-4 pt-8 text-sm text-muted sm:px-6">
|
||||
Other fields:{" "}
|
||||
{others.map((item, index) => (
|
||||
<span key={item.id}>
|
||||
{index > 0 ? ", " : null}
|
||||
<a className="underline" href={`/${item.id}/`}>{item.title}</a>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
) : null}
|
||||
<RecordBrowser rows={dataspace.rows} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
ui/components/EntityDetail.test.tsx
Normal file
26
ui/components/EntityDetail.test.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findEntity, findKind } from "@/lib/catalog";
|
||||
import { EntityDetail } from "./EntityDetail";
|
||||
import { KindIndex } from "./KindIndex";
|
||||
|
||||
describe("kind and detail views", () => {
|
||||
it("renders the registry plural and a linked record", () => {
|
||||
const kind = findKind("software-architecture", "concept");
|
||||
expect(kind).not.toBeNull();
|
||||
const html = renderToStaticMarkup(<KindIndex kind={kind!} />);
|
||||
expect(html).toContain(">Concepts<");
|
||||
expect(html).toContain("Coupling");
|
||||
expect(html).toContain('href="/software-architecture/concept/coupling/"');
|
||||
});
|
||||
|
||||
it("renders the entity title, an unknown claim, and a neighbour", () => {
|
||||
const entity = findEntity("systems-thinking", "concept", "system-boundary");
|
||||
expect(entity).not.toBeNull();
|
||||
const html = renderToStaticMarkup(<EntityDetail entity={entity!} />);
|
||||
expect(html).toContain(">System boundary<");
|
||||
expect(html).toContain("formal-measure");
|
||||
expect(html).toContain("unknown");
|
||||
expect(html).toContain("Stock and flow model");
|
||||
});
|
||||
});
|
||||
78
ui/components/EntityDetail.tsx
Normal file
78
ui/components/EntityDetail.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { PageHeader } from "@/components/PageHeader";
|
||||
import type { EntityView } from "@/lib/catalog";
|
||||
|
||||
/**
|
||||
* Generic record page: title, summary, sources, claims, and neighbouring relations.
|
||||
* OnCo EntityDetail was not copied. That file special-cases domain panels.
|
||||
*/
|
||||
export function EntityDetail({ entity }: { entity: EntityView }) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader kicker={entity.kindLabel} title={entity.title} lede={entity.tldr} />
|
||||
<div className="mx-auto max-w-7xl space-y-10 px-4 py-10 sm:px-6">
|
||||
<p className="max-w-3xl text-body leading-relaxed">{entity.summary}</p>
|
||||
{entity.tags.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{entity.tags.map((tag) => (
|
||||
<li key={tag} className="chip">{tag}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<section aria-labelledby="sources-heading">
|
||||
<h2 id="sources-heading" className="mb-3 text-lg">Sources</h2>
|
||||
{entity.sources.length === 0 ? (
|
||||
<p className="text-sm text-muted">No sources.</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{entity.sources.map((source) => (
|
||||
<li key={source.title}>
|
||||
{source.title}
|
||||
{source.license ? <span className="text-muted"> · {source.license}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<section aria-labelledby="claims-heading">
|
||||
<h2 id="claims-heading" className="mb-3 text-lg">Claims</h2>
|
||||
{entity.claims.length === 0 ? (
|
||||
<p className="text-sm text-muted">No claims.</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{entity.claims.map((claim) => (
|
||||
<li key={`${claim.key}:${claim.status}:${claim.value ?? ""}`}>
|
||||
<span className="font-medium">{claim.key}</span>
|
||||
<span className="text-muted"> · {claim.status}</span>
|
||||
{claim.value ? <span> · {claim.value}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<section aria-labelledby="relations-heading">
|
||||
<h2 id="relations-heading" className="mb-3 text-lg">Relations</h2>
|
||||
{entity.relations.length === 0 ? (
|
||||
<p className="text-sm text-muted">No relations.</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{entity.relations.map((relation) => (
|
||||
<li key={`${relation.direction}:${relation.wording}:${relation.otherTitle}`}>
|
||||
<span className="text-muted">{relation.wording}</span>
|
||||
{" · "}
|
||||
{relation.otherHref ? (
|
||||
<a className="underline" href={relation.otherHref}>{relation.otherTitle}</a>
|
||||
) : (
|
||||
relation.otherTitle
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<p className="text-sm">
|
||||
<a className="underline" href={entity.kindHref}>Back to {entity.kindLabel}</a>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,15 @@
|
|||
* Copyright (c) 2026 OnCo for the adapted pattern. MIT License. See ui/NOTICE.
|
||||
* The heading and lede are Content Explorer copy.
|
||||
*/
|
||||
export function Hero({ dataspace, description }: { dataspace: string; description: string }) {
|
||||
export function Hero({
|
||||
dataspace,
|
||||
description,
|
||||
kinds = [],
|
||||
}: {
|
||||
dataspace: string;
|
||||
description: string;
|
||||
kinds?: { href: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<section className="hero border-b border-border">
|
||||
<div className="mx-auto max-w-7xl px-4 pt-14 pb-10 sm:px-6 sm:pt-20 sm:pb-12">
|
||||
|
|
@ -11,10 +19,15 @@ export function Hero({ dataspace, description }: { dataspace: string; descriptio
|
|||
<p className="kicker">{dataspace}</p>
|
||||
<h1 className="display mt-4">Explore a field of knowledge.</h1>
|
||||
<p className="mt-6 max-w-2xl text-[17px] leading-relaxed text-foreground/85 sm:text-xl">{description}</p>
|
||||
<div className="mt-8">
|
||||
<div className="mt-8 flex flex-wrap gap-2">
|
||||
<a href="#records" className="btn btn-primary">
|
||||
Browse records
|
||||
</a>
|
||||
{kinds.map((kind) => (
|
||||
<a key={kind.href} href={kind.href} className="btn">
|
||||
{kind.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
15
ui/components/KindIndex.tsx
Normal file
15
ui/components/KindIndex.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { RecordBrowser } from "@/components/RecordBrowser";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import type { KindView } from "@/lib/catalog";
|
||||
|
||||
/** Kind directory. The heading and rows come from the snapshot kind registry. */
|
||||
export function KindIndex({ kind }: { kind: KindView }) {
|
||||
const count = kind.rows.length;
|
||||
const lede = count === 1 ? `1 record in ${kind.dataspaceTitle}.` : `${count} records in ${kind.dataspaceTitle}.`;
|
||||
return (
|
||||
<>
|
||||
<PageHeader kicker={kind.dataspaceTitle} title={kind.plural} lede={lede} />
|
||||
<RecordBrowser rows={kind.rows} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
ui/components/PageHeader.tsx
Normal file
16
ui/components/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Page heading adapted from OnCo PageHeader: kicker, title, lede.
|
||||
* Copyright (c) 2026 OnCo for the adapted pattern. MIT License. See ui/NOTICE.
|
||||
* Garden art and domain logos are not included.
|
||||
*/
|
||||
export function PageHeader({ kicker, title, lede }: { kicker?: string; title: string; lede?: string }) {
|
||||
return (
|
||||
<header className="border-b border-border">
|
||||
<div className="mx-auto max-w-7xl px-4 pt-8 pb-6 sm:px-6 sm:pt-10">
|
||||
{kicker ? <p className="kicker mb-2">{kicker}</p> : null}
|
||||
<h1 className="text-3xl font-semibold tracking-tight first-letter:uppercase sm:text-4xl">{title}</h1>
|
||||
{lede ? <p className="mt-3 max-w-3xl text-[17px] leading-relaxed text-foreground/85 sm:text-lg">{lede}</p> : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,17 +6,29 @@ import { nextSort, sortRows } from "@/lib/sort-rows";
|
|||
import type { RecordRow, SortState } from "@/lib/types";
|
||||
|
||||
const columns = [
|
||||
{ key: "title", label: "Title", sortable: true, render: (row: RecordRow) => row.title },
|
||||
{ key: "kindLabel", label: "Kind", sortable: true, render: (row: RecordRow) => <span className="chip">{row.kindLabel}</span> },
|
||||
{
|
||||
key: "title",
|
||||
label: "Title",
|
||||
sortable: true,
|
||||
render: (row: RecordRow) => row.href ? <a className="underline" href={row.href}>{row.title}</a> : row.title,
|
||||
},
|
||||
{
|
||||
key: "kindLabel",
|
||||
label: "Kind",
|
||||
sortable: true,
|
||||
render: (row: RecordRow) => row.kindHref
|
||||
? <a className="chip" href={row.kindHref}>{row.kindLabel}</a>
|
||||
: <span className="chip">{row.kindLabel}</span>,
|
||||
},
|
||||
{ key: "tldr", label: "Summary", sortable: true, render: (row: RecordRow) => row.tldr },
|
||||
];
|
||||
|
||||
export function RecordBrowser({ rows }: { rows: RecordRow[] }) {
|
||||
export function RecordBrowser({ rows, heading = "Records" }: { rows: RecordRow[]; heading?: string }) {
|
||||
const [sort, setSort] = useState<SortState | undefined>({ key: "title", dir: 1 });
|
||||
const ordered = sortRows(rows, sort, (row, key) => String(row[key as keyof RecordRow] ?? ""));
|
||||
return (
|
||||
<section id="records" aria-labelledby="records-heading" className="mx-auto max-w-7xl px-4 py-14 sm:px-6">
|
||||
<h2 id="records-heading" className="mb-6 text-2xl">Records</h2>
|
||||
<h2 id="records-heading" className="mb-6 text-2xl">{heading}</h2>
|
||||
<ResultsTable
|
||||
columns={columns}
|
||||
rows={ordered}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export function SiteFooter() {
|
|||
<p className="max-w-2xl text-sm leading-relaxed text-muted">
|
||||
Content Explorer browses an interconnected field of knowledge. Interface patterns on this page are adapted
|
||||
from OnCo under the MIT License, commit b594d7433bf4e408b44215e25e5304696d6f840e. The OnCo corpus is not
|
||||
included. The sample records are the CC0 systems-thinking fixture.
|
||||
included. The sample records are the CC0 systems-thinking and software-architecture fixtures.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { DataspaceNav } from "@/lib/catalog";
|
||||
|
||||
/**
|
||||
* Sticky header shell adapted from OnCo SiteHeader.
|
||||
* Copyright (c) 2026 OnCo. MIT License. See ui/NOTICE.
|
||||
* The OnCo mark, command palette, and kind routes are not included.
|
||||
* Kind routes arrive in CONTENT-WP-0002-T02; this header only links to the home page.
|
||||
* The OnCo mark and command palette are not included.
|
||||
* Kind links come from the snapshot registry passed in by the layout.
|
||||
*/
|
||||
export function SiteHeader() {
|
||||
export function SiteHeader({ dataspaces }: { dataspaces: DataspaceNav[] }) {
|
||||
const path = usePathname() ?? "/";
|
||||
const active = dataspaces.find((item) => path === `/${item.id}` || path.startsWith(`/${item.id}/`)) ?? dataspaces.find((item) => item.id === "systems-thinking") ?? dataspaces[0];
|
||||
return (
|
||||
<header className="sticky top-0 z-40 h-14 border-b border-border bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/75">
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center gap-6 px-4 sm:px-6">
|
||||
<a href="/" className="inline-flex h-10 items-center rounded-lg font-semibold tracking-tight" aria-label="Content Explorer home">
|
||||
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/75">
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center gap-4 px-4 sm:gap-6 sm:px-6">
|
||||
<a href="/" className="inline-flex h-10 shrink-0 items-center rounded-lg font-semibold tracking-tight" aria-label="Content Explorer home">
|
||||
<span className="text-[15px]">Content Explorer</span>
|
||||
</a>
|
||||
<nav aria-label="Primary" className="flex items-center gap-4 text-sm">
|
||||
<a href="#records" className="text-muted hover:text-foreground">
|
||||
Records
|
||||
</a>
|
||||
</nav>
|
||||
{active ? (
|
||||
<nav aria-label="Primary" className="flex min-w-0 items-center gap-4 overflow-x-auto text-sm">
|
||||
<a href={`/${active.id}/`} className="shrink-0 text-foreground">{active.title}</a>
|
||||
{active.kinds.map((kind) => (
|
||||
<a key={kind.id} href={kind.href} className="shrink-0 text-muted hover:text-foreground">
|
||||
{kind.plural}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue