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 { 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 { // 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 { 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("#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("#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 */ }); }