Files
paliad/frontend/src/client/views.ts
m fdde9eb754 feat(t-paliad-144 A2): frontend Custom Views UI
Phase A2 of the data-display-model rethink. Builds on A1's API contract
(merged as cda4b40). User-visible.

What lands:

- TSX shells for /views (the view runner) and /views/new + /views/{slug}/edit
  (the editor). One TSX per page; client/views.ts + views-editor.ts
  hydrate.

- Three render-shape components in client/views/: shape-list.ts (table
  for density=comfortable, compact one-line stream for density=compact —
  the activity-feed look without a separate "activity" shape per Q4 lock-
  in 2026-05-07), shape-cards.ts (day-grouped chronological), and
  shape-calendar.ts (month grid with day-pills, mobile cards-fallback
  notice on viewports <600px per design §9 trade-off 8).

- Generic view shell that resolves a slug to a system view (via
  /api/views/system) or a user view (via /api/user-views), runs it via
  POST /api/views/{slug}/run, dispatches to the matching shape, exposes
  a 3-button shape switcher that swaps the live render without re-fetching,
  and surfaces the inaccessible-projects toast when the substrate flags
  some IDs (Q17 fail-open attribution).

- View editor with widgets for name/slug/icon, sources (4 checkboxes),
  scope mode (all_visible / my_subtree / personal_only), time horizon
  (six fixed options), shape, and list density. Slug regex enforced
  client-side mirroring the server validator. Save → POST/PATCH; delete
  → simple yes/no confirm (Q25 lock-in).

- Sidebar "Meine Sichten" group between Arbeit and Werkzeuge. Renders
  empty server-side; client/sidebar.ts.initUserViewsGroup() hydrates from
  GET /api/user-views on mount, injecting one nav item per saved view
  + an always-present "+ Neue Sicht" trailing entry. show_count=true
  views get a sidebar badge updated by a fire-and-forget run query.

- Page handlers /views (most-recently-used redirect or onboarding shell),
  /views/{slug}, /views/new, /views/{slug}/edit. All gateOnboarded.

- 91 new i18n keys (DE+EN) covering nav.group.user_views, view shell,
  shape labels, source/kind/horizon/scope vocabulary, editor form,
  empty/error/onboarding states.

- ~250 lines of CSS for the views shell, list/cards/calendar shapes,
  Meine Sichten sidebar group.

- build.ts registers views.tsx + views-editor.tsx page renderers and
  the two client bundles.

Frontend builds clean (i18n codegen 1700→1791 keys), backend builds +
vets clean, all tests pass, IIFE wrap intact on the new bundles.
2026-05-07 13:15:55 +02:00

252 lines
8.3 KiB
TypeScript

import { initI18n, t, type I18nKey } from "./i18n";
import { initSidebar } from "./sidebar";
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } from "./views/types";
import { renderListShape } from "./views/shape-list";
import { renderCardsShape } from "./views/shape-cards";
import { renderCalendarShape } from "./views/shape-calendar";
// /views and /views/{slug} client. Loads the saved or system view, runs
// it via /api/views/{slug}/run, and dispatches to the matching render-
// shape component. Shape-switcher chips toggle the live render without
// re-fetching (the rows are already in memory).
initI18n();
initSidebar();
interface ViewMeta {
// For saved views: identifies the row for touch/edit/delete.
user_view_id?: string;
// Display name + slug.
name: string;
slug: string;
// Filter + render specs (may be overridden by slug detection).
filter: FilterSpec;
render: RenderSpec;
// Whether this is a code-resident SystemView.
is_system: boolean;
}
let currentMeta: ViewMeta | null = null;
let currentRows: ViewRunResult | null = null;
document.addEventListener("DOMContentLoaded", () => {
bindShapeChips();
bindToastClose();
void hydrate();
});
async function hydrate(): Promise<void> {
const slug = pathSlug();
if (!slug) {
// /views with no slug → empty / onboarding state.
const onboarding = document.getElementById("views-onboarding");
const loading = document.getElementById("views-loading");
if (loading) loading.hidden = true;
if (onboarding) onboarding.hidden = false;
return;
}
// Resolve the view: try system first, then user.
const meta = await resolveMeta(slug);
if (!meta) {
showError(t("views.error.not_found"));
return;
}
currentMeta = meta;
document.title = `${meta.name} — Paliad`;
updateHeader(meta);
await runAndRender(meta);
if (meta.user_view_id) {
fireAndForget(`/api/user-views/${meta.user_view_id}/touch`, "POST");
}
}
async function resolveMeta(slug: string): Promise<ViewMeta | null> {
// Try the system view list first — cheap, code-resident.
try {
const r = await fetch("/api/views/system", { credentials: "include" });
if (r.ok) {
const list = (await r.json()) as Array<{ Slug: string; Name: string; Filter: FilterSpec; Render: RenderSpec }>;
const sys = list.find((sv) => sv.Slug === slug);
if (sys) {
return { name: sys.Name, slug: sys.Slug, filter: sys.Filter, render: sys.Render, is_system: true };
}
}
} catch (_e) {
// fall through to user lookup
}
// Try a saved user view.
try {
const r = await fetch("/api/user-views", { credentials: "include" });
if (r.ok) {
const list = (await r.json()) as UserView[];
const v = list.find((uv) => uv.slug === slug);
if (v) {
return {
user_view_id: v.id,
name: v.name,
slug: v.slug,
filter: v.filter_spec,
render: v.render_spec,
is_system: false,
};
}
}
} catch (_e) { /* noop */ }
return null;
}
async function runAndRender(meta: ViewMeta): Promise<void> {
const loading = document.getElementById("views-loading");
const empty = document.getElementById("views-empty");
const errorEl = document.getElementById("views-error");
const toolbar = document.getElementById("views-toolbar");
if (loading) loading.hidden = false;
if (empty) empty.hidden = true;
if (errorEl) errorEl.hidden = true;
if (toolbar) toolbar.hidden = false;
let result: ViewRunResult;
try {
const r = await fetch(`/api/views/${encodeURIComponent(meta.slug)}/run`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!r.ok) {
showError(`${r.status}: ${r.statusText}`);
return;
}
result = (await r.json()) as ViewRunResult;
} catch (e) {
showError(t("views.error.network"));
return;
}
if (loading) loading.hidden = true;
currentRows = result;
if (result.inaccessible_project_ids && result.inaccessible_project_ids.length > 0) {
showInaccessibleToast(result.inaccessible_project_ids.length);
}
if (result.rows.length === 0) {
if (empty) {
empty.hidden = false;
const hint = document.getElementById("views-empty-hint");
if (hint) hint.textContent = filterSummary(meta.filter);
}
return;
}
setActiveShape(meta.render.shape);
renderShape(meta.render.shape, meta.render, result.rows);
}
function setActiveShape(shape: RenderShape): void {
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar"]) {
const el = document.getElementById(host);
if (el) el.hidden = !host.endsWith("-" + shape);
}
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.shape === shape);
});
}
function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult["rows"]): void {
const host = document.getElementById(`views-shape-${shape}`);
if (!host) return;
switch (shape) {
case "list":
renderListShape(host, rows, render);
break;
case "cards":
renderCardsShape(host, rows, render);
break;
case "calendar":
renderCalendarShape(host, rows, render);
break;
}
}
function bindShapeChips(): void {
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.addEventListener("click", () => {
const shape = (btn.dataset.shape ?? "list") as RenderShape;
if (!currentMeta || !currentRows) return;
// Override the shape transiently — doesn't mutate the saved spec.
const overrideRender = { ...currentMeta.render, shape };
setActiveShape(shape);
renderShape(shape, overrideRender, currentRows.rows);
});
});
}
function updateHeader(meta: ViewMeta): void {
const heading = document.getElementById("views-heading");
if (heading) heading.textContent = meta.name;
const subtitle = document.getElementById("views-subtitle");
if (subtitle) subtitle.textContent = filterSummary(meta.filter);
const actions = document.getElementById("views-header-actions");
if (actions) {
actions.innerHTML = "";
if (!meta.is_system && meta.user_view_id) {
const editLink = document.createElement("a");
editLink.href = `/views/${encodeURIComponent(meta.slug)}/edit`;
editLink.className = "btn-secondary btn-small";
editLink.textContent = t("views.action.edit");
actions.appendChild(editLink);
}
}
}
function filterSummary(filter: FilterSpec): string {
const parts: string[] = [];
// Sources
parts.push(filter.sources.map((s) => t(("views.source." + s) as I18nKey)).join(" + "));
// Time
parts.push(t(("views.horizon." + filter.time.horizon) as I18nKey));
// Scope
if (filter.scope.personal_only) {
parts.push(t("views.scope.personal_only"));
} else if (filter.scope.projects.mode !== "all_visible") {
parts.push(t(("views.scope." + filter.scope.projects.mode) as I18nKey));
}
return parts.join(" · ");
}
function showError(message: string): void {
const loading = document.getElementById("views-loading");
const errorEl = document.getElementById("views-error");
const msg = document.getElementById("views-error-message");
if (loading) loading.hidden = true;
if (errorEl) errorEl.hidden = false;
if (msg) msg.textContent = message;
}
function showInaccessibleToast(count: number): void {
const toast = document.getElementById("views-toast");
const text = document.getElementById("views-toast-text");
if (!toast || !text) return;
text.textContent = count === 1
? t("views.toast.inaccessible_one")
: t("views.toast.inaccessible_n").replace("{n}", String(count));
toast.hidden = false;
}
function bindToastClose(): void {
const close = document.getElementById("views-toast-close");
const toast = document.getElementById("views-toast");
if (!close || !toast) return;
close.addEventListener("click", () => { toast.hidden = true; });
}
function pathSlug(): string | null {
const m = window.location.pathname.match(/^\/views\/([^\/]+)$/);
if (!m) return null;
return decodeURIComponent(m[1]);
}
function fireAndForget(url: string, method: string): void {
fetch(url, { method, credentials: "include" }).catch(() => { /* noop */ });
}