Per m's Q1 pick (b) (2026-05-29): legacy `/`, `/dashboard`, `/calendar`,
`/timeline`, `/graph` become `/views/{system-slug}`. Old routes
301-redirect to the new ones with chip params preserved; the legacy
?view=<uuid> param from 5i is resolved through the uuid → slug map
when present so old bookmarks land on the right user view.
System views (web/system_views.go):
- SystemView struct (Slug / Name / Icon / URL) — code-resident, never
rows in projax.views.
- AllSystemViews() returns the canonical five: tree, dashboard,
calendar, timeline, graph. Display order matches the existing
sidebar.
- LookupSystemView(slug) returns the matching entry or nil; the
reserved-slug list in store.IsReservedViewSlug (slice A) is kept
in sync.
- legacyRedirect(systemSlug) handler 301s with chip-param preservation
+ uuid → slug resolution for any leftover ?view=<uuid>.
Routes (web/server.go):
- GET /views/tree → handleTree (was GET /)
- GET /views/dashboard → handleDashboard
- GET /views/timeline → handleTimeline
- GET /views/calendar → handleCalendar
- GET /views/graph → handleGraph
- GET / → 301 → /views/tree
- GET /dashboard → 301 → /views/dashboard
- GET /timeline → 301 → /views/timeline
- GET /calendar → 301 → /views/calendar
- GET /graph → 301 → /views/graph
- POST action endpoints (/dashboard/task/*, /dashboard/pin, /admin/*)
stay where they are — those are RPC-ish, not page renders.
handleTree: dropped the `r.URL.Path != "/"` guard — the only entry
point now is /views/tree, mounted via the new route. Slice F removes
any residual references; this slice keeps the handler reachable.
computeChipCounts grew a `base string` arg so chip URLs anchor on the
caller's route (/views/tree for the system tree, /views/{slug} for
saved views). PageViewTypes recognises both legacy and /views/ keys
during the transition.
Template hrefs / hx-gets bulk-updated to the new URLs:
- layout.tmpl: every sidebar + bottom-nav entry points at
/views/{system-slug}. Active-state checks updated alongside.
- tree_section.tmpl, tree_card.tmpl, tree_kanban.tmpl: clear-filter
/ clear-all hrefs → /views/tree.
- calendar*.tmpl, timeline_section.tmpl, graph.tmpl,
dashboard_section.tmpl: every internal nav + filter link points at
the /views/{slug} surface.
- detail.tmpl, error.tmpl: cancel / back-to-tree → /views/tree.
Test-source updates (per the 5c sharpened rule):
- ~100 test paths bulk-rewritten from /dashboard /calendar /timeline
/graph (and `/`) to their /views/{slug} counterparts. The
behaviour-preservation contract holds: status codes + body shapes
for the rendered pages stay the same; only the URL anchoring the
test changes.
- layout_test.go: sidebar href assertions updated to /views/{slug}.
- view_type_test.go (Q2 + Q3 follow-up): PageViewTypes lookup table
updated to use the new route keys.
- 2 deliberate behaviour-change assertions land: TestLegacyRedirects
expects 301 on the old URLs (was 200); TestTreeRenders fetches
/views/tree (the new home) instead of /.
Internal go-source URL emissions (dashboard.go, calendar.go,
timeline.go) updated to the new BasePath so chip + refresh URLs round
through /views/{slug} correctly.
New tests:
- TestSystemViewLookup — AllSystemViews shape + LookupSystemView
round-trip + unknown-slug nil.
- TestLegacyRedirects — every legacy URL 301s to its new home with
chip params preserved.
- TestLegacyViewUUIDRedirect — old `?view=<uuid>` URLs land on the
resolved slug per m's Q3 pick.
257 lines
16 KiB
Cheetah
257 lines
16 KiB
Cheetah
{{define "layout"}}<!doctype html>
|
|
<html lang="en" data-theme="{{.Theme}}">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<meta name="theme-color" content="{{.ThemeColor}}">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<meta name="apple-mobile-web-app-title" content="projax">
|
|
<title>{{.Title}} — projax</title>
|
|
<link rel="manifest" href="/static/manifest.webmanifest">
|
|
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
|
<link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png">
|
|
<link rel="icon" type="image/png" sizes="512x512" href="/static/icon-512.png">
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
<script>
|
|
// Phase 5g — restore sidebar collapsed state BEFORE first paint so the
|
|
// main-content margin doesn't flash from 220px→56px on every navigation.
|
|
// Mirrors layout.tmpl's existing theme-cookie approach (server-rendered
|
|
// first paint, no FOUC) but localStorage-backed because collapse state
|
|
// doesn't need to round-trip the server.
|
|
(function() {
|
|
try {
|
|
if (localStorage.getItem('projax.sidebar.collapsed') === 'true') {
|
|
document.documentElement.setAttribute('data-sidebar-collapsed', 'true');
|
|
}
|
|
} catch (e) { /* private mode / disabled storage — fall through */ }
|
|
})();
|
|
</script>
|
|
<script>
|
|
// Phase 3j — register the service worker post-load so the install
|
|
// affordance fires and shell assets warm into cache. Failures are silent
|
|
// (older browsers, http context, etc.) — projax must work without SW.
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', function() {
|
|
navigator.serviceWorker.register('/static/sw.js').catch(function(){});
|
|
});
|
|
}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
{{$path := .Path}}
|
|
<aside class="projax-sidebar" aria-label="Primary navigation">
|
|
<div class="sidebar-top">
|
|
<a href="/views/tree" class="brand" title="projax">
|
|
<span class="brand-icon" aria-hidden="true">▦</span>
|
|
<strong class="brand-label">projax</strong>
|
|
</a>
|
|
</div>
|
|
<nav class="sidebar-nav">
|
|
<a href="/views/tree" class="nav-item{{if eq $path "/views/tree"}} active{{end}}" title="Tree">
|
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
|
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
|
</svg>
|
|
<span class="nav-label">Tree</span>
|
|
</a>
|
|
<a href="/views/dashboard" class="nav-item{{if eq $path "/views/dashboard"}} active{{end}}" title="Dashboard">
|
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
|
|
</svg>
|
|
<span class="nav-label">Dashboard</span>
|
|
</a>
|
|
<a href="/views/calendar" class="nav-item{{if eq $path "/views/calendar"}} active{{end}}" title="Calendar">
|
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
|
</svg>
|
|
<span class="nav-label">Calendar</span>
|
|
</a>
|
|
<a href="/views/timeline" class="nav-item{{if eq $path "/views/timeline"}} active{{end}}" title="Timeline">
|
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
|
</svg>
|
|
<span class="nav-label">Timeline</span>
|
|
</a>
|
|
<a href="/views/graph" class="nav-item{{if eq $path "/views/graph"}} active{{end}}" title="Graph">
|
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
|
</svg>
|
|
<span class="nav-label">Graph</span>
|
|
</a>
|
|
<a href="/views" class="nav-item{{if eq $path "/views"}} active{{end}}" title="Views">
|
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
|
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
|
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
|
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
|
</svg>
|
|
<span class="nav-label">Views</span>
|
|
</a>
|
|
<a href="/admin" class="nav-item{{if eq $path "/admin"}} active{{end}}" title="Admin">
|
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="3"/>
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
</svg>
|
|
<span class="nav-label">Admin</span>
|
|
</a>
|
|
</nav>
|
|
<div class="sidebar-bottom">
|
|
<button type="button" id="theme-toggle" class="nav-item theme-toggle"
|
|
title="Toggle dark / light" aria-label="Toggle theme" data-theme="{{.Theme}}">
|
|
<span class="nav-icon theme-icon" aria-hidden="true">{{if eq .Theme "light"}}☾{{else}}☀{{end}}</span>
|
|
<span class="nav-label">Theme</span>
|
|
</button>
|
|
<form method="post" action="/logout" class="logout-form">
|
|
<button type="submit" class="nav-item logout-item" title="Sign out">
|
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
|
|
</svg>
|
|
<span class="nav-label">Sign out</span>
|
|
</button>
|
|
</form>
|
|
<button type="button" id="sidebar-collapse" class="nav-item collapse-btn"
|
|
title="Collapse sidebar" aria-label="Collapse sidebar" aria-expanded="true">
|
|
<svg class="nav-icon collapse-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<polyline points="15 18 9 12 15 6"/>
|
|
</svg>
|
|
<span class="nav-label">Collapse</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
<main class="projax-main">
|
|
{{template "content" .}}
|
|
</main>
|
|
<nav class="projax-bottom-nav" aria-label="Mobile navigation">
|
|
<a href="/views/tree" class="bottom-nav-item{{if eq $path "/views/tree"}} active{{end}}" aria-label="Tree">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
|
|
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
|
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
|
</svg>
|
|
<span>Tree</span>
|
|
</a>
|
|
<a href="/views/dashboard" class="bottom-nav-item{{if eq $path "/views/dashboard"}} active{{end}}" aria-label="Dashboard">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
|
|
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
|
|
</svg>
|
|
<span>Dash</span>
|
|
</a>
|
|
<a href="/new" class="bottom-nav-item capture-btn" aria-label="New item">
|
|
<span class="capture-circle">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" width="22" height="22" aria-hidden="true">
|
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
</svg>
|
|
</span>
|
|
</a>
|
|
<a href="/views/calendar" class="bottom-nav-item{{if eq $path "/views/calendar"}} active{{end}}" aria-label="Calendar">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
|
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
|
</svg>
|
|
<span>Cal</span>
|
|
</a>
|
|
<details class="projax-mobile-drawer">
|
|
<summary class="bottom-nav-item drawer-toggle" aria-label="More — Timeline, Graph, Admin">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
|
|
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
|
|
</svg>
|
|
<span>Menu</span>
|
|
</summary>
|
|
<div class="drawer-sheet" role="menu">
|
|
<a href="/views/timeline" class="drawer-item{{if eq $path "/views/timeline"}} active{{end}}" role="menuitem">
|
|
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
|
</svg>
|
|
<span>Timeline</span>
|
|
</a>
|
|
<a href="/views/graph" class="drawer-item{{if eq $path "/views/graph"}} active{{end}}" role="menuitem">
|
|
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
|
</svg>
|
|
<span>Graph</span>
|
|
</a>
|
|
<a href="/admin" class="drawer-item{{if eq $path "/admin"}} active{{end}}" role="menuitem">
|
|
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="3"/>
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
</svg>
|
|
<span>Admin</span>
|
|
</a>
|
|
<button type="button" id="theme-toggle-drawer" class="drawer-item" aria-label="Toggle theme" data-theme="{{.Theme}}">
|
|
<span class="drawer-icon theme-icon" aria-hidden="true">{{if eq .Theme "light"}}☾{{else}}☀{{end}}</span>
|
|
<span>Theme</span>
|
|
</button>
|
|
<form method="post" action="/logout" class="drawer-form">
|
|
<button type="submit" class="drawer-item drawer-logout">
|
|
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
|
|
</svg>
|
|
<span>Sign out</span>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</details>
|
|
</nav>
|
|
<script>
|
|
// Phase 4b — theme toggle. Writes the projax_theme cookie (1-year Max-Age,
|
|
// Path=/, SameSite=Lax) and flips data-theme + theme-color in place so the
|
|
// user sees the swap without a reload. Server already injected the initial
|
|
// value from the cookie, so first paint never flashes the wrong theme.
|
|
// Phase 5g — same handler binds to BOTH the sidebar button (desktop) and
|
|
// the drawer button (mobile menu) so either surface flips the theme
|
|
// identically.
|
|
(function() {
|
|
var btns = [
|
|
document.getElementById('theme-toggle'),
|
|
document.getElementById('theme-toggle-drawer'),
|
|
].filter(Boolean);
|
|
if (btns.length === 0) return;
|
|
var root = document.documentElement;
|
|
var meta = document.querySelector('meta[name="theme-color"]');
|
|
var themeColors = { dark: '#161616', light: '#f0efe8' };
|
|
function flip() {
|
|
var current = root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
|
var next = current === 'light' ? 'dark' : 'light';
|
|
root.setAttribute('data-theme', next);
|
|
btns.forEach(function(b) {
|
|
b.setAttribute('data-theme', next);
|
|
var ic = b.querySelector('.theme-icon');
|
|
if (ic) ic.textContent = next === 'light' ? '☾' : '☀';
|
|
});
|
|
if (meta) meta.setAttribute('content', themeColors[next]);
|
|
var oneYear = 60 * 60 * 24 * 365;
|
|
document.cookie = 'projax_theme=' + next + '; Max-Age=' + oneYear + '; Path=/; SameSite=Lax';
|
|
}
|
|
btns.forEach(function(b) { b.addEventListener('click', flip); });
|
|
})();
|
|
</script>
|
|
<script>
|
|
// Phase 5g — sidebar collapse toggle. State persists in localStorage; the
|
|
// pre-paint script in <head> already restored data-sidebar-collapsed on
|
|
// <html>, so this handler only writes the new state. Aria-expanded
|
|
// tracks the open/closed state for screen readers.
|
|
(function() {
|
|
var btn = document.getElementById('sidebar-collapse');
|
|
if (!btn) return;
|
|
var root = document.documentElement;
|
|
function sync() {
|
|
var collapsed = root.getAttribute('data-sidebar-collapsed') === 'true';
|
|
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
|
btn.setAttribute('title', collapsed ? 'Expand sidebar' : 'Collapse sidebar');
|
|
btn.setAttribute('aria-label', collapsed ? 'Expand sidebar' : 'Collapse sidebar');
|
|
}
|
|
sync();
|
|
btn.addEventListener('click', function() {
|
|
var collapsed = root.getAttribute('data-sidebar-collapsed') === 'true';
|
|
var next = !collapsed;
|
|
if (next) root.setAttribute('data-sidebar-collapsed', 'true');
|
|
else root.removeAttribute('data-sidebar-collapsed');
|
|
try { localStorage.setItem('projax.sidebar.collapsed', next ? 'true' : 'false'); } catch (e) {}
|
|
sync();
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>{{end}}
|