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:
@@ -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/context.ts RequestContext handed to handlers + buildContext()
|
||||
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/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/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)
|
||||
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)
|
||||
config/menu.ts Central menu override + branding (planned)
|
||||
plugins/ Drop-in plugin folders, auto-discovered (planned)
|
||||
|
||||
@@ -21,11 +21,22 @@ before(async () => {
|
||||
|
||||
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 + "/");
|
||||
assert.equal(res.status, 200);
|
||||
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 () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import { buildContext } from "./context.ts";
|
||||
import { buildDashboardModel } from "./dashboard.ts";
|
||||
import { serveStatic } from "./static.ts";
|
||||
|
||||
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
|
||||
// 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/")) {
|
||||
await serveStatic(publicDir, pathname.slice("/public/".length), res, req.method === "HEAD");
|
||||
@@ -45,7 +47,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
73
src/dashboard.test.ts
Normal file
73
src/dashboard.test.ts
Normal 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
205
src/dashboard.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
2
todo.md
2
todo.md
@@ -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 `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 / 0–0). `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.
|
||||
- [ ] 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.
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= title %></title>
|
||||
<link rel="stylesheet" href="/public/css/styles.css" />
|
||||
<link rel="icon" href="/public/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<%- include("partials/header", { title }) %>
|
||||
<main>
|
||||
<h1>Welcome to <%= title %></h1>
|
||||
<p>A plain, server-rendered page. Edit <code>views/index.ejs</code> to change it.</p>
|
||||
</main>
|
||||
<footer>
|
||||
<p>Served with Node.js + EJS.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
<%#
|
||||
Dashboard (todo §1): the app-shell "People" list. Composes the building-block partials around
|
||||
the shell using the view model from src/dashboard.ts. EJS `include()` returns the rendered
|
||||
string, so each partial is captured and handed to the shell as a slot. The filter form,
|
||||
sortable headers and pager are real GET links — q/sort/page round-trip the URL, zero-JS.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: model.nav });
|
||||
const filters = include("partials/filter-bar", model.filterBar);
|
||||
const table = include("partials/data-table", model.table);
|
||||
const pager = include("partials/pagination", model.pagination);
|
||||
const actions =
|
||||
'<button class="btn"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-download"/></svg>Export</button>' +
|
||||
'<button class="btn btn-primary"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add person</button>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
body: filters + table + pager,
|
||||
brand: model.shell.brand,
|
||||
breadcrumbs: model.shell.breadcrumbs,
|
||||
nav,
|
||||
title: model.shell.title,
|
||||
user: model.shell.user,
|
||||
}) %>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/"><%= title %></a>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
</nav>
|
||||
</header>
|
||||
Reference in New Issue
Block a user