Replace placeholder index with the app-shell People dashboard (todo §1); wire parseListQuery/paginate/composeNav + partials into a real zero-JS list page

This commit is contained in:
2026-06-15 15:57:42 +02:00
parent c06429e4d5
commit 947851b4ff
8 changed files with 322 additions and 32 deletions

View File

@@ -360,12 +360,13 @@ src/jwt.ts JWS signature verify via node:crypto, no jose; claims+JWKS
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4) src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
src/context.ts RequestContext handed to handlers + buildContext() src/context.ts RequestContext handed to handlers + buildContext()
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
src/dashboard.ts buildDashboardModel(): the home "/" People list view model (mock data, wires the §1 helpers)
src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
src/plugin.ts definePlugin() + the host's plugin discovery/router (planned) src/plugin.ts definePlugin() + the host's plugin discovery/router (planned)
views/ Core EJS templates (index, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite) views/ Core EJS templates (index = the app-shell People dashboard, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/menu.ts Central menu override + branding (planned) config/menu.ts Central menu override + branding (planned)
plugins/ Drop-in plugin folders, auto-discovered (planned) plugins/ Drop-in plugin folders, auto-discovered (planned)

View File

@@ -21,11 +21,22 @@ before(async () => {
after(() => server.close()); after(() => server.close());
test("serves the home page as HTML", async () => { test("serves the home page: the app-shell People dashboard, filterable via the URL", async () => {
const res = await fetch(base + "/"); const res = await fetch(base + "/");
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/html/); assert.match(res.headers.get("content-type") ?? "", /text\/html/);
assert.match(await res.text(), /Plainpages/); const html = await res.text();
// Shell + building blocks composed around the mock data.
assert.match(html, /Plainpages/); // sidebar brand
assert.match(html, /<aside class="sidebar"/);
assert.match(html, /<form class="filters"/);
assert.match(html, /<table class="table"/);
assert.match(html, /<footer class="pager"/);
assert.match(html, /Avery Kline/); // a mock person on page 1
// A search query filters server-side: a no-match query drops every row.
const empty = await fetch(base + "/?q=zzz-no-such-person");
assert.doesNotMatch(await empty.text(), /Avery Kline/);
}); });
test("serves a static file: GET sends body + content-type, HEAD sends headers only", async () => { test("serves a static file: GET sends body + content-type, HEAD sends headers only", async () => {

View File

@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import * as ejs from "ejs";
import { buildContext } from "./context.ts"; import { buildContext } from "./context.ts";
import { buildDashboardModel } from "./dashboard.ts";
import { serveStatic } from "./static.ts"; import { serveStatic } from "./static.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -37,7 +38,8 @@ export function createApp(options: AppOptions = {}): Server {
// The request shape handlers receive (§2/§4 router passes it on); routing // The request shape handlers receive (§2/§4 router passes it on); routing
// reuses its parsed URL instead of building a throwaway. // reuses its parsed URL instead of building a throwaway.
const { pathname } = buildContext(req, res).url; const { url } = buildContext(req, res);
const pathname = url.pathname;
if (pathname.startsWith("/public/")) { if (pathname.startsWith("/public/")) {
await serveStatic(publicDir, pathname.slice("/public/".length), res, req.method === "HEAD"); await serveStatic(publicDir, pathname.slice("/public/".length), res, req.method === "HEAD");
@@ -45,7 +47,8 @@ export function createApp(options: AppOptions = {}): Server {
} }
if (pathname === "/") { if (pathname === "/") {
sendHtml(res, 200, await render("index", { title: "Plainpages" })); // Mock data + no roles until the plugin host (§2) and auth (§4) land.
sendHtml(res, 200, await render("index", { model: buildDashboardModel(url) }));
return; return;
} }

73
src/dashboard.test.ts Normal file
View File

@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildDashboardModel } from "./dashboard.ts";
// Pull the first data-table column ("Name") and the rendered row count for terse assertions.
const rowCount = (m: ReturnType<typeof buildDashboardModel>): number => m.table.rows.length;
const nameOf = (m: ReturnType<typeof buildDashboardModel>, i: number): string =>
// first cell is the user cell { user: { name } }
((m.table.rows[i] as { cells: unknown[] }).cells[0] as { user: { name: string } }).user.name;
const col0 = (m: ReturnType<typeof buildDashboardModel>) =>
m.table.columns[0] as { href?: string; label: string; sort?: string };
test("dashboard default: page 1, mock data, nav + shell wired", () => {
const m = buildDashboardModel(new URL("http://x/"));
assert.equal(m.shell.title, "People");
assert.ok(m.nav.length > 0); // composeNav produced a tree
assert.equal(col0(m).label, "Name");
assert.equal(m.pagination.summary.total, 30); // full mock dataset
assert.equal(m.pagination.summary.from, 1);
assert.equal(rowCount(m), 12); // default page size
assert.equal(m.pagination.prev.href, undefined); // first page → prev disabled
assert.ok(m.pagination.next.href); // more pages → next enabled
});
test("dashboard search filters rows, shrinks the total and shows a pill", () => {
const first = nameOf(buildDashboardModel(new URL("http://x/")), 0); // e.g. "Avery Kline"
const m = buildDashboardModel(new URL(`http://x/?q=${encodeURIComponent(first)}`));
assert.equal(m.pagination.summary.total, 1);
assert.equal(rowCount(m), 1);
assert.equal(nameOf(m, 0), first);
assert.deepEqual(m.filterBar.pills.map((p) => p.label), ["Search"]);
// A no-match query yields an empty table, not an error.
const none = buildDashboardModel(new URL("http://x/?q=zzz-no-such-person"));
assert.equal(none.pagination.summary.total, 0);
assert.equal(rowCount(none), 0);
});
test("dashboard sorts by a column, reflects direction, and the header toggles", () => {
const asc = buildDashboardModel(new URL("http://x/?sort=name"));
const desc = buildDashboardModel(new URL("http://x/?sort=-name"));
// asc first name ≤ desc first name (reverse order).
assert.ok(nameOf(asc, 0) <= nameOf(asc, 1));
assert.ok(nameOf(desc, 0) >= nameOf(desc, 1));
// The Name column carries the current direction and its header links to the opposite.
assert.equal(col0(asc).sort, "asc");
assert.match(col0(asc).href ?? "", /sort=-name/); // asc → click flips to desc
assert.equal(col0(desc).sort, "desc");
assert.match(col0(desc).href ?? "", /sort=name(?!\w)/); // desc → click flips to asc
// An unknown sort field is ignored (no crash, no sort indicator).
const bad = buildDashboardModel(new URL("http://x/?sort=bogus"));
assert.equal(col0(bad).sort, undefined);
});
test("dashboard paginates: page 2 slices the next rows and preserves state in links", () => {
const p2 = buildDashboardModel(new URL("http://x/?sort=-name&page=2"));
assert.equal(p2.pagination.summary.from, 13); // 30 rows / 12 per page → page 2 starts at 13
assert.equal(rowCount(p2), 12);
// prev/next present on the middle page; both preserve the active sort.
assert.match(p2.pagination.prev.href ?? "", /sort=-name/);
assert.match(p2.pagination.next.href ?? "", /sort=-name/);
// Team filter actually narrows the set and adds a pill.
const eng = buildDashboardModel(new URL("http://x/?team=Engineering"));
assert.ok(eng.pagination.summary.total < 30);
assert.deepEqual(eng.filterBar.pills.map((p) => p.label), ["Team"]);
});

205
src/dashboard.ts Normal file
View File

@@ -0,0 +1,205 @@
// Dashboard view model (todo §1): the app-shell "People" list that replaces the placeholder
// index. Pure — turns a request URL into the data the building-block partials render, wiring
// the §1 helpers end-to-end: parseListQuery → filter/sort/paginate the mock dataset →
// composeNav. The dataset stands in for upstream data until plugins/§4 land; everything below
// is real, so the filter form, sortable headers and pager round-trip through the URL (zero-JS).
import { composeNav, type NavNode } from "./nav.ts";
import { parseListQuery } from "./list-query.ts";
import { paginate } from "./paginate.ts";
interface Person {
id: string;
email: string;
initials: string;
lastActive: string;
name: string;
role: string;
status: string;
team: string;
}
const FIRST = ["Avery", "Blair", "Casey", "Devon", "Emerson", "Finley", "Gray", "Harper", "Iris", "Jordan", "Kai", "Logan", "Morgan", "Noor", "Oakley", "Parker", "Quinn", "Riley", "Sage", "Tatum", "Uma", "Vance", "Wren", "Yuki", "Zarah", "Aria", "Beau", "Cleo", "Dane", "Esme"];
const LAST = ["Kline", "Mora", "Nguyen", "Patel", "Rossi", "Stone", "Vega", "Wu", "Ahmed", "Boyd", "Cruz", "Diaz", "Engel", "Frost", "Gomez", "Hale", "Ito", "Jain", "Khan", "Lund", "Marsh", "Novak", "Ortiz", "Pace", "Reed", "Sato", "Tran", "Udall", "Voss", "Webb"];
const ROLES = ["Admin", "Member", "Viewer"];
const TEAMS = ["Engineering", "Design", "Sales", "Support"];
const STATUSES = ["active", "invited", "suspended"];
const ACTIVE = ["2m ago", "1h ago", "3h ago", "Yesterday", "2d ago", "Last week"];
const TONE: Record<string, string> = { active: "pos", invited: "info", suspended: "warn" };
// Cycle a fixed, non-empty list by index (parallel mock arrays — always in range).
const at = <T>(arr: T[], i: number): T => arr[i % arr.length] as T;
const PEOPLE: Person[] = FIRST.map((first, i) => {
const last = LAST[i] as string;
return {
id: `${first}-${last}`.toLowerCase(),
email: `${first}.${last}`.toLowerCase() + "@example.com",
initials: first.charAt(0) + last.charAt(0),
lastActive: at(ACTIVE, i),
name: `${first} ${last}`,
role: at(ROLES, i),
status: at(STATUSES, i),
team: at(TEAMS, i),
};
});
const DEFAULT_PAGE_SIZE = 12;
const PAGE_SIZES = [12, 25, 50];
// Sortable columns → the value to compare on (also gates `?sort=` to known fields).
const SORT: Record<string, (p: Person) => string> = {
email: (p) => p.email, name: (p) => p.name, role: (p) => p.role, status: (p) => p.status, team: (p) => p.team,
};
const COLUMNS: { key: string; label: string; sortable?: boolean }[] = [
{ key: "name", label: "Name", sortable: true },
{ key: "email", label: "Email", sortable: true },
{ key: "role", label: "Role", sortable: true },
{ key: "team", label: "Team", sortable: true },
{ key: "status", label: "Status", sortable: true },
{ key: "lastActive", label: "Last active" },
];
interface State { page: number; pageSize: number; q: string; sort: string | null; status: string; team: string; }
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
// Canonical list URL from the current state plus per-link overrides; omits defaults so links stay tidy.
function href(state: State, overrides: Partial<State> = {}): string {
const s = { ...state, ...overrides };
const p = new URLSearchParams();
if (s.q) p.set("q", s.q);
if (s.status && s.status !== "all") p.set("status", s.status);
if (s.team) p.set("team", s.team);
if (s.sort) p.set("sort", s.sort);
if (s.page > 1) p.set("page", String(s.page));
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
const qs = p.toString();
return qs ? `?${qs}` : "?";
}
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = []) {
const query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const status = query.filters.status?.[0] ?? "all";
const team = query.filters.team?.[0] ?? "";
const sort = query.sort && SORT[query.sort.field] ? query.sort : null; // ignore unknown fields
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
const needle = query.q.toLowerCase();
let list = PEOPLE.filter((p) =>
(!needle || p.name.toLowerCase().includes(needle) || p.email.toLowerCase().includes(needle)) &&
(status === "all" || p.status === status) &&
(!team || p.team === team));
if (sort) {
const get = SORT[sort.field] as (p: Person) => string; // gated to known fields above
const dir = sort.dir === "desc" ? -1 : 1;
list = [...list].sort((a, b) => get(a).localeCompare(get(b)) * dir);
}
const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 });
const start = (page.page - 1) * page.pageSize;
const rows = list.slice(start, start + page.pageSize);
const state: State = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status, team };
return {
filterBar: filterBar(state),
nav: nav(roles),
pagination: pagination(state, page),
shell: {
brand: { name: "Plainpages", sub: "Console" },
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
title: "People",
user: { email: "sam.rivers@example.com", initials: "SR", name: "Sam Rivers" }, // demo until §4
},
table: table(rows, state, sort),
};
}
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
function nav(roles: string[]): NavNode[] {
return composeNav([[
{ count: PEOPLE.length, current: true, href: "/", icon: "i-users", id: "people", label: "People" },
{ href: "#teams", icon: "i-grid", id: "teams", label: "Teams" },
{ children: [
{ href: "#activity", id: "activity", label: "Activity" },
{ href: "#exports", id: "exports", label: "Exports" },
], icon: "i-chart", id: "reports", label: "Reports", open: true },
{ href: "#settings", icon: "i-gear", id: "settings", label: "Settings", permission: "admin" },
]], {}, roles);
}
function table(rows: Person[], state: State, sort: { dir: "asc" | "desc"; field: string } | null) {
return {
actions: true,
caption: "People",
columns: COLUMNS.map((c) => {
if (!c.sortable) return { label: c.label };
const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key; // asc→desc, else→asc
return { href: href(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
}),
rows: rows.map((p) => ({
cells: [
{ user: { initials: p.initials, name: p.name } },
p.email,
p.role,
p.team,
{ badge: { label: cap(p.status), tone: TONE[p.status] } },
p.lastActive,
],
name: p.name,
actions: [
{ href: "#", icon: "i-user", label: "View" },
{ href: "#", icon: "i-edit", label: "Edit" },
{ danger: true, icon: "i-trash", label: "Deactivate", separatorBefore: true },
],
})),
selectable: true,
};
}
function filterBar(state: State) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: "Search", remove: href(state, { page: 1, q: "" }), value: state.q });
if (state.status !== "all") pills.push({ label: "Status", remove: href(state, { page: 1, status: "all" }), value: cap(state.status) });
if (state.team) pills.push({ label: "Team", remove: href(state, { page: 1, team: "" }), value: state.team });
return {
applyLabel: "Apply filters",
clearHref: "?",
label: "Filter people",
pills,
rows: [[
{ label: "Search people", name: "q", placeholder: "Search people…", type: "search", value: state.q },
{ legend: "Status", name: "status", options: [
{ count: PEOPLE.length, label: "All", value: "all" },
{ label: "Active", value: "active" },
{ label: "Invited", value: "invited" },
{ label: "Suspended", value: "suspended" },
], type: "segmented", value: state.status },
{ label: "Team", name: "team", options: [{ label: "All teams", value: "" }, ...TEAMS.map((t) => ({ label: t, value: t }))], type: "select", value: state.team },
{ type: "spacer" },
]],
};
}
function pagination(state: State, page: ReturnType<typeof paginate>) {
// Hidden inputs carry the list state through the rows-per-page GET form (page resets on change).
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
if (state.team) hidden.push({ name: "team", value: state.team });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: "People pagination",
next: { href: page.next ? href(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: href(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? href(state, { page: page.prev }) : undefined },
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}

View File

@@ -37,7 +37,7 @@ everything via Docker.
- [x] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree. → `src/nav.ts`: pure, I/O-free. Flattens plugin fragments, applies the central override (rename → group → order → hide, all keyed by node `id`), then role-filters — a node shows iff it has no `permission` or `roles` includes it; a gated header drops its whole subtree, an emptied pure header is dropped. Emits clean nodes (no `id`/`permission`, absent fields omitted) ready for `nav-tree.ejs`. Filter runs last so everything above is per-deployment. `NavNode`/`NavOverride`/`NavGroupSpec` types exported; `nav.test.ts` covers merge/filter/empties/override matrix. - [x] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree. → `src/nav.ts`: pure, I/O-free. Flattens plugin fragments, applies the central override (rename → group → order → hide, all keyed by node `id`), then role-filters — a node shows iff it has no `permission` or `roles` includes it; a gated header drops its whole subtree, an emptied pure header is dropped. Emits clean nodes (no `id`/`permission`, absent fields omitted) ready for `nav-tree.ejs`. Filter runs last so everything above is per-deployment. `NavNode`/`NavOverride`/`NavGroupSpec` types exported; `nav.test.ts` covers merge/filter/empties/override matrix.
- [x] Helper `parseListQuery(url)``{ q, filters, sort, page, pageSize }`. → `src/list-query.ts`: pure, never throws; inverse of the filter-bar GET form + sort/pagination links. Accepts `URL`/`URLSearchParams`/string. `q` trimmed; `filters` = every non-reserved param as `string[]` (multi-value chips kept, empties dropped); `sort` = `{field,dir}` with `-field` ⇒ desc (lone `-`/empty ⇒ null); `page` a positive int (else 1); `pageSize` defaults 25, clamped to [1, max 100]. Reserved names + page-size bounds overridable via options. `list-query.test.ts` covers the full/default/clamp/custom-name matrix. - [x] Helper `parseListQuery(url)``{ q, filters, sort, page, pageSize }`. → `src/list-query.ts`: pure, never throws; inverse of the filter-bar GET form + sort/pagination links. Accepts `URL`/`URLSearchParams`/string. `q` trimmed; `filters` = every non-reserved param as `string[]` (multi-value chips kept, empties dropped); `sort` = `{field,dir}` with `-field` ⇒ desc (lone `-`/empty ⇒ null); `page` a positive int (else 1); `pageSize` defaults 25, clamped to [1, max 100]. Reserved names + page-size bounds overridable via options. `list-query.test.ts` covers the full/default/clamp/custom-name matrix.
- [x] Helper `paginate(total, page, pageSize)` → page model. → `src/paginate.ts`: pure, URL-free math feeding `pagination.ejs`; caller maps page numbers → hrefs. Returns `{ from, to, page, pageCount, pageSize, prev, next, total, pages }`. Inputs clamped/guarded (page pinned to [1,pageCount], total/pageSize coerced to sane ints, empty list ⇒ 1 page / 00). `pages` = first/last `boundaries` + `siblings`-wide window around current, sorted/deduped, with ellipsis for gaps >1 (a lone hole is shown, not collapsed); `siblings`/`boundaries` overridable. `paginate.test.ts` covers model/clamp/empty/windowing. - [x] Helper `paginate(total, page, pageSize)` → page model. → `src/paginate.ts`: pure, URL-free math feeding `pagination.ejs`; caller maps page numbers → hrefs. Returns `{ from, to, page, pageCount, pageSize, prev, next, total, pages }`. Inputs clamped/guarded (page pinned to [1,pageCount], total/pageSize coerced to sane ints, empty list ⇒ 1 page / 00). `pages` = first/last `boundaries` + `siblings`-wide window around current, sorted/deduped, with ellipsis for gaps >1 (a lone hole is shown, not collapsed); `siblings`/`boundaries` overridable. `paginate.test.ts` covers model/clamp/empty/windowing.
- [ ] Replace placeholder `index` with the app-shell dashboard. - [x] Replace placeholder `index` with the app-shell dashboard.`/` now renders a real app-shell "People" list. `src/dashboard.ts` (pure `buildDashboardModel(url, roles)`) wires the §1 helpers end-to-end: `parseListQuery` → filter (q/status/team) + sort + `paginate` over a 30-row mock dataset → `composeNav`; builds the filter-bar/data-table/pagination/shell configs with canonical, state-preserving links. `views/index.ejs` composes the partials around the shell by capturing each `include()` (EJS returns the string) into a slot. Filtering/sorting/paging all round-trip the URL, zero-JS. Removed the dead `partials/header.ejs`. `dashboard.test.ts` covers default/search/sort/paginate; `app.test.ts` asserts the live page + URL filtering. Mock data + demo profile stand in until §2/§4.
- [ ] Check the full system in Playwright and make screenshots and compare to the static original design in html-css-foundation to make sure we're showing the correct graphics. - [ ] Check the full system in Playwright and make screenshots and compare to the static original design in html-css-foundation to make sure we're showing the correct graphics.
- [ ] Go over all HTML and CSS and make adjust it to be as sematic as we can, css classes, ids html elements and all, then add semantic DOM as a priority in this project. - [ ] Go over all HTML and CSS and make adjust it to be as sematic as we can, css classes, ids html elements and all, then add semantic DOM as a priority in this project.
- [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues.

View File

@@ -1,20 +1,23 @@
<!doctype html> <%#
<html lang="en"> Dashboard (todo §1): the app-shell "People" list. Composes the building-block partials around
<head> the shell using the view model from src/dashboard.ts. EJS `include()` returns the rendered
<meta charset="utf-8" /> string, so each partial is captured and handed to the shell as a slot. The filter form,
<meta name="viewport" content="width=device-width, initial-scale=1" /> sortable headers and pager are real GET links — q/sort/page round-trip the URL, zero-JS.
<title><%= title %></title> %><%
<link rel="stylesheet" href="/public/css/styles.css" /> const nav = include("partials/nav-tree", { nodes: model.nav });
<link rel="icon" href="/public/favicon.svg" /> const filters = include("partials/filter-bar", model.filterBar);
</head> const table = include("partials/data-table", model.table);
<body> const pager = include("partials/pagination", model.pagination);
<%- include("partials/header", { title }) %> const actions =
<main> '<button class="btn"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-download"/></svg>Export</button>' +
<h1>Welcome to <%= title %></h1> '<button class="btn btn-primary"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add person</button>';
<p>A plain, server-rendered page. Edit <code>views/index.ejs</code> to change it.</p> -%>
</main> <%- include("partials/shell", {
<footer> actions,
<p>Served with Node.js + EJS.</p> body: filters + table + pager,
</footer> brand: model.shell.brand,
</body> breadcrumbs: model.shell.breadcrumbs,
</html> nav,
title: model.shell.title,
user: model.shell.user,
}) %>

View File

@@ -1,6 +0,0 @@
<header class="site-header">
<a class="brand" href="/"><%= title %></a>
<nav>
<a href="/">Home</a>
</nav>
</header>