11<script server>
2- // macOS-style web sidebar (@stacksjs/components' <Sidebar theme="macos">).
3- // Sections come from the discovered-models manifest + config/dashboard.ts
4- // toggles — the same data that used to drive the native NSOutlineView.
2+ // Web sidebar for the dashboard. Framework-owned nav chrome: the markup is
3+ // built server-side in resources/functions/dashboard/sidebar.ts and the look
4+ // lives in the @apply <style> block at the bottom of this file. Sections come
5+ // from the discovered-models manifest + config/dashboard.ts toggles.
56// Project-root-anchored (`~/`), not relative. The stx client bundler resolves a
67// relative import against the PAGE that extends this layout, not against this
78// file, so `../../..` silently pointed somewhere different for every page depth
89// and the bundle failed for any page more than one level under views/dashboard.
9- import { buildWebSidebarSections } from '~/storage/framework/defaults/resources/functions/dashboard/sidebar'
10+ import { buildSidebarChunks } from '~/storage/framework/defaults/resources/functions/dashboard/sidebar'
1011
11- export const sidebarSections = buildWebSidebarSections()
12-
13- // itemId → roles, for the client-side role filter below.
14- export const sidebarRoleAttrs = JSON.stringify(
15- sidebarSections.flatMap(section => section.items
16- .filter(item => item.roles && item. roles.length > 0)
17- .map(item => ({ id: item.id, roles: item.roles }))),
18- )
12+ // Pre-rendered, self-contained sidebar HTML (home link, section nav, settings
13+ // link), built server-side and interpolated raw below. This intentionally does
14+ // NOT depend on `@stacksjs/components`' <Sidebar>: 0.2.9x stopped shipping
15+ // tag-resolvable .stx sources (only hashed dist/ output), so the component
16+ // fatal-errored the whole dashboard with an ENOENT (stacksjs/stacks#1989). Each
17+ // rendered <a> already carries data-required- roles, which the applyRoleFilter
18+ // effect below reads directly for role gating.
19+ export const sidebarChunks = buildSidebarChunks( )
1920</script>
2021
2122<style>
@@ -31,6 +32,155 @@ canvas[id$="_chart"] {
3132 width: 100% !important;
3233 height: 100% !important;
3334}
35+
36+ /*
37+ * Dashboard body + sidebar. Plain CSS on purpose: stx serves a layout's raw
38+ * style rules verbatim, but it does NOT expand Crosswind @apply here, so these
39+ * are hand-written. Class names match the markup built in
40+ * resources/functions/dashboard/sidebar.ts. Fixed 250px pane: Home pinned top,
41+ * sections scroll in the middle, Settings pinned bottom.
42+ */
43+ body {
44+ background: #f4f4f5;
45+ }
46+ .dark body {
47+ background: #0a0a0a;
48+ }
49+ .dashboard-sidebar {
50+ position: fixed;
51+ inset: 0 auto 0 0;
52+ width: 250px;
53+ display: flex;
54+ flex-direction: column;
55+ padding: 0 12px 10px;
56+ overflow: hidden;
57+ background: #fff;
58+ border-right: 1px solid rgba(0, 0, 0, 0.08);
59+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif;
60+ -webkit-font-smoothing: antialiased;
61+ z-index: 40;
62+ }
63+ .sidebar-header {
64+ flex-shrink: 0;
65+ height: 38px;
66+ }
67+ .sidebar-nav {
68+ flex: 1 1 auto;
69+ min-height: 0;
70+ overflow-y: auto;
71+ scrollbar-width: none;
72+ }
73+ .sidebar-nav::-webkit-scrollbar {
74+ width: 0;
75+ }
76+ .sidebar-link {
77+ display: flex;
78+ align-items: center;
79+ gap: 10px;
80+ flex-shrink: 0;
81+ height: 30px;
82+ padding: 0 10px;
83+ border-radius: 6px;
84+ color: #3a3a3c;
85+ font-size: 13px;
86+ font-weight: 450;
87+ text-decoration: none;
88+ white-space: nowrap;
89+ overflow: hidden;
90+ text-overflow: ellipsis;
91+ transition: background-color 0.12s ease, color 0.12s ease;
92+ }
93+ .sidebar-link:hover {
94+ background: rgba(0, 0, 0, 0.05);
95+ }
96+ .sidebar-link[data-active="true"] {
97+ background: #007aff;
98+ color: #fff;
99+ }
100+ .sidebar-link-home {
101+ font-weight: 550;
102+ }
103+ .sidebar-nav .sidebar-link {
104+ margin-top: 1px;
105+ }
106+ .sidebar-icon {
107+ display: inline-flex;
108+ align-items: center;
109+ justify-content: center;
110+ flex-shrink: 0;
111+ width: 18px;
112+ height: 18px;
113+ opacity: 0.82;
114+ }
115+ .sidebar-icon svg {
116+ width: 18px;
117+ height: 18px;
118+ }
119+ .sidebar-link[data-active="true"] .sidebar-icon {
120+ opacity: 1;
121+ }
122+ .sidebar-section {
123+ margin-top: 12px;
124+ }
125+ .sidebar-section-label {
126+ display: flex;
127+ align-items: center;
128+ justify-content: space-between;
129+ width: 100%;
130+ height: 22px;
131+ padding: 0 10px;
132+ background: none;
133+ border: 0;
134+ cursor: pointer;
135+ color: #8a8a8e;
136+ font-size: 11px;
137+ font-weight: 600;
138+ letter-spacing: 0.03em;
139+ text-transform: uppercase;
140+ }
141+ .sidebar-section-label:hover {
142+ color: #6a6a6e;
143+ }
144+ .sidebar-section-list {
145+ list-style: none;
146+ margin: 2px 0 0;
147+ padding: 0;
148+ }
149+ .sidebar-section.collapsed .sidebar-section-list {
150+ display: none;
151+ }
152+ .sidebar-chevron {
153+ width: 7px;
154+ height: 7px;
155+ border-right: 1.5px solid currentColor;
156+ border-bottom: 1.5px solid currentColor;
157+ transform: rotate(45deg);
158+ transition: transform 0.15s ease;
159+ opacity: 0.7;
160+ }
161+ .sidebar-section.collapsed .sidebar-chevron {
162+ transform: rotate(-45deg);
163+ }
164+ .dark .dashboard-sidebar {
165+ background: #1c1c1e;
166+ border-right-color: rgba(255, 255, 255, 0.08);
167+ }
168+ .dark .sidebar-link {
169+ color: #d1d1d6;
170+ }
171+ .dark .sidebar-link:hover {
172+ background: rgba(255, 255, 255, 0.06);
173+ }
174+ .dark .sidebar-link[data-active="true"] {
175+ background: #0a84ff;
176+ color: #fff;
177+ }
178+ .dark .sidebar-section-label {
179+ color: #8d8d92;
180+ }
181+ .dark .sidebar-section-label:hover {
182+ color: #aeaeb2;
183+ }
34184</style>
35185
36186<script>
@@ -258,66 +408,52 @@ effect(() => {
258408})
259409</script>
260410
261- <Sidebar
262- :sections="sidebarSections"
263- theme="macos"
264- width="250"
265- collapseMode="hidden"
266- shellSelector="[data-stx-shell]"
267- persistKey="dashboard-sidebar-collapsed"
268- >
269- <template #header>
270- <!-- Traffic lights are native (titlebarHidden window) — this strip only
271- reserves their space and acts as the window drag region. -->
272- <SidebarHeader :showWindowControls="false" />
273- </template>
274- </Sidebar>
275-
276- <!-- Role map for the wiring script below; interpolated server-side. The div is
277- hidden and holds JSON as text, because a JSON-typed script tag would get
278- rewritten by the layout script processor (see the importmap comment above).
279-
280- Do not name that tag literally here. The script processor rewrites script
281- tags inside comments too, and eating this comment's closing marker turns
282- everything below into comment text, blanking the page. -->
283- <div data-sidebar-roles hidden>{{ sidebarRoleAttrs }}</div>
411+ <aside data-stx-sidebar class="dashboard-sidebar" role="navigation" aria-label="Sidebar">
412+ <!-- Traffic lights are native (titlebarHidden window) — this strip reserves
413+ their space at the top of the pane so the first nav row clears them. -->
414+ <div class="sidebar-header"></div>
415+ {{{ sidebarChunks.top }}}
416+ <nav class="sidebar-nav">{{{ sidebarChunks.nav }}}</nav>
417+ {{{ sidebarChunks.bottom }}}
418+ </aside>
284419
285420<script client>
286- // Web sidebar wiring: SPA navigation, initial selection, role gating.
421+ // Web sidebar wiring: collapse restore, active highlight, SPA navigation.
422+ // Role gating is handled by the applyRoleFilter effect above, which reads the
423+ // data-required-roles attributes the server emits directly on each link.
287424onMount(() => {
288- const roleNode = document.querySelector('div[data-sidebar-roles]')
289- let sidebarRoleList = []
290- try { sidebarRoleList = JSON.parse(roleNode?.textContent || '[]') } catch {}
291-
292425 const pane = document.querySelector('[data-stx-sidebar]')
293426 if (!pane) return
294427
295- // Role-gate rows (picked up by the applyRoleFilter effect above).
296- for (const entry of sidebarRoleList) {
297- const row = pane.querySelector(`[data-sidebar-row][data-item-id="${entry.id}"]`)
298- if (row) row.setAttribute('data-required-roles', entry.roles.join(','))
299- }
428+ // Restore per-section collapsed state persisted by the section toggle
429+ // (the inline onclick in renderSection writes localStorage['sidebar-collapsed']).
430+ try {
431+ const saved = JSON.parse(localStorage.getItem('sidebar-collapsed') || '{}')
432+ for (const section of pane.querySelectorAll('.sidebar-section')) {
433+ if (saved[section.getAttribute('data-section')]) section.classList.add('collapsed')
434+ }
435+ } catch {}
300436
301- // Highlight the row for the current route on hard loads.
302- const activeClasses = (pane.dataset.activeClass || '').split(' ').filter(Boolean)
303- const current = pane.querySelector(`[data-sidebar-item][href="${window.location.pathname}"]`)
304- if (current && !pane.querySelector('[data-sidebar-item][data-active="true"]')) {
305- current.setAttribute('data-active', 'true')
306- current.classList.add(...activeClasses)
307- }
437+ // Highlight the link for the current route on hard loads.
438+ const current = pane.querySelector(`a.sidebar-link[href="${window.location.pathname}"]`)
439+ if (current) current.setAttribute('data-active', 'true')
308440
309441 // SPA-navigate sidebar links; fall back to full loads when the router
310- // isn't available.
442+ // isn't available or the target is external .
311443 pane.addEventListener('click', (event) => {
312- const link = event.target.closest('a[data-sidebar-item]')
313- if (!link || typeof navigate !== 'function') return
444+ const link = event.target.closest('a.sidebar-link')
445+ if (!link) return
446+ const href = link.getAttribute('href')
447+ if (!href || href.startsWith('http') || typeof navigate !== 'function') return
314448 event.preventDefault()
315- navigate(link.getAttribute('href'))
449+ for (const a of pane.querySelectorAll('a.sidebar-link[data-active]')) a.removeAttribute('data-active')
450+ link.setAttribute('data-active', 'true')
451+ navigate(href)
316452 })
317453})
318454</script>
319455
320- <main data-stx-content data-stx-shell style="margin-left: var(--stx-sidebar-width, 250px); transition: margin-left 0.3s ease-out ">
456+ <main data-stx-content style="margin-left: 250px">
321457 @slot('content')
322458</main>
323459
@@ -344,9 +480,3 @@ onMount(() => {
344480 </div>
345481 @endforeach
346482</div>
347-
348- <style>
349- body {
350- @apply bg-neutral-100 dark:bg-neutral-950;
351- }
352- </style>
0 commit comments