Changeset 0.37.11 (#223)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-22 00:57:49 +00:00
committed by xcaliber
parent 8d8a118232
commit c687002015
18 changed files with 1142 additions and 87 deletions

View File

@@ -761,11 +761,4 @@
.chat-item.indented { padding-left: 28px; }
/* ── Notes Surface ─────────────────────── */
.surface-notes { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.surface-notes-body { flex: 1; display: flex; min-height: 0; overflow: hidden; }
.surface-notes-main { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
.surface-notes-assist {
width: 400px; border-left: 1px solid var(--border);
display: flex; flex-direction: column; overflow: hidden;
}
@media (max-width: 768px) { .surface-notes-assist { display: none; } }
/* v0.37.11: Moved to sw-notes-surface.css (Preact surface) */

View File

@@ -0,0 +1,366 @@
/* ==========================================
Chat Switchboard — Notes Surface CSS
==========================================
v0.37.11: Preact notes surface layout.
sw-notes-surface__ prefixed, no legacy conflicts.
========================================== */
/* ── Root Layout ──────────────────────────── */
.sw-notes-surface {
display: flex;
width: 100%;
height: 100%;
flex: 1 1 0%;
overflow: hidden;
background: var(--bg, #0e0e10);
}
/* ── Sidebar ──────────────────────────────── */
.sw-notes-surface__sidebar {
width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--bg-secondary, #151517);
border-right: 1px solid var(--border, #2a2a2e);
overflow: hidden;
z-index: 20;
}
.sw-notes-surface__sidebar-header {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px 6px;
flex-shrink: 0;
}
.sw-notes-surface__new-note-btn {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
padding: 8px 12px;
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
border: 1px solid var(--accent, #b38a4e);
border-radius: 8px;
color: var(--accent, #b38a4e);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.sw-notes-surface__new-note-btn:hover {
background: rgba(179, 138, 78, 0.2);
}
.sw-notes-surface__collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-3, #555);
cursor: pointer;
flex-shrink: 0;
transition: color 0.15s, background 0.15s;
}
.sw-notes-surface__collapse-btn:hover {
color: var(--text, #eee);
background: var(--bg, #0e0e10);
}
/* ── Search ───────────────────────────────── */
.sw-notes-surface__search-wrap {
position: relative;
padding: 4px 12px 8px;
flex-shrink: 0;
}
.sw-notes-surface__search-icon {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%);
color: var(--text-3, #555);
pointer-events: none;
display: flex;
}
.sw-notes-surface__search {
width: 100%;
padding: 6px 8px 6px 30px;
background: var(--bg, #0e0e10);
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
color: var(--text, #eee);
font-size: 12px;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.sw-notes-surface__search:focus {
border-color: var(--accent, #b38a4e);
}
/* ── Sidebar Body (scrollable) ────────────── */
.sw-notes-surface__sidebar-body {
flex: 1;
overflow-y: auto;
min-height: 0;
padding-bottom: 8px;
}
/* ── Section ──────────────────────────────── */
.sw-notes-surface__section {
margin-top: 4px;
}
.sw-notes-surface__section-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 12px;
background: none;
border: none;
color: var(--text-2, #999);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: color 0.15s;
}
.sw-notes-surface__section-header:hover {
color: var(--text, #eee);
}
.sw-notes-surface__chevron {
display: flex;
transition: transform 0.15s;
}
.sw-notes-surface__chevron--open {
transform: rotate(90deg);
}
.sw-notes-surface__section-count {
margin-left: auto;
font-size: 10px;
color: var(--text-3, #555);
font-weight: 400;
}
.sw-notes-surface__section-body {
padding: 0 4px;
}
.sw-notes-surface__empty {
padding: 8px 16px;
font-size: 12px;
color: var(--text-3, #555);
}
/* ── Sidebar Item ─────────────────────────── */
.sw-notes-surface__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-2, #999);
font-size: 13px;
cursor: pointer;
text-align: left;
transition: background 0.1s, color 0.1s;
position: relative;
}
.sw-notes-surface__item:hover {
background: var(--bg, #0e0e10);
color: var(--text, #eee);
}
.sw-notes-surface__item--active {
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
color: var(--text, #eee);
}
.sw-notes-surface__item-icon {
display: flex;
flex-shrink: 0;
color: var(--text-3, #555);
}
.sw-notes-surface__item-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sw-notes-surface__item-count {
font-size: 10px;
color: var(--text-3, #555);
flex-shrink: 0;
}
/* ── Tag Cloud ───────────────────────────── */
.sw-notes-surface__tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 4px 8px;
}
.sw-notes-surface__tag-chip {
display: inline-block;
padding: 2px 8px;
background: var(--bg, #0e0e10);
border: 1px solid var(--border, #2a2a2e);
border-radius: 12px;
color: var(--text-2, #999);
font-size: 11px;
cursor: pointer;
transition: background 0.1s, color 0.1s, border-color 0.1s;
user-select: none;
}
.sw-notes-surface__tag-chip:hover {
color: var(--text, #eee);
border-color: var(--text-3, #555);
}
.sw-notes-surface__tag-chip--active {
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
border-color: var(--accent, #b38a4e);
color: var(--accent, #b38a4e);
}
/* ── Sidebar Footer ──────────────────────── */
.sw-notes-surface__sidebar-footer {
display: flex;
align-items: center;
padding: 8px 12px;
border-top: 1px solid var(--border, #2a2a2e);
flex-shrink: 0;
position: relative;
}
/* ── Main Area ───────────────────────────── */
.sw-notes-surface__main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
}
/* ── Workspace ───────────────────────────── */
.sw-notes-surface__workspace {
flex: 1 1 0%;
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
position: relative;
overflow: hidden;
}
.sw-notes-surface__workspace-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 10px;
border-bottom: 1px solid var(--border, #2a2a2e);
background: var(--bg-secondary, #151517);
flex-shrink: 0;
min-height: 40px;
}
.sw-notes-surface__workspace-header-left,
.sw-notes-surface__workspace-header-right {
display: flex;
align-items: center;
gap: 6px;
}
.sw-notes-surface__sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-3, #555);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.sw-notes-surface__sidebar-toggle:hover {
color: var(--text, #eee);
background: var(--bg, #0e0e10);
}
/* ── NotesPane Container ─────────────────── */
.sw-notes-surface__notes-pane {
flex: 1 1 0%;
min-height: 0;
min-width: 0;
}
/* ── Sidebar Overlay (mobile) ────────────── */
.sw-notes-surface__sidebar-overlay {
display: none; /* Hidden on desktop */
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 15;
}
/* ── Responsive: Mobile ──────────────────── */
@media (max-width: 768px) {
.sw-notes-surface__sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 280px;
z-index: 20;
}
.sw-notes-surface__sidebar-overlay {
display: block;
}
.sw-notes-surface__collapse-btn {
display: none;
}
}

View File

@@ -84,6 +84,12 @@ export function NotesPane(props) {
refreshList() { notes.refreshList(); },
getEditingId() { return notes.editingId; },
openQuickSwitcher() { notes.setQuickSwitcherOpen(true); },
// Surface-driven filter controls (v0.37.11)
setFolder(f) { notes.setFolder(f); },
setTagFilter(t) { notes.setTagFilter(t); },
setSearchQuery(q) { notes.setSearchQuery(q); },
getFolder() { return notes.folder; },
getTagFilter() { return notes.tagFilter; },
};
}
});
@@ -115,7 +121,8 @@ export function NotesPane(props) {
onNewNote=${notes.newNote}
onDailyNote=${notes.openDailyNote}
onGraphClick=${() => notes.setView('graph')}
pinnedIds=${notes.pinnedIds} togglePin=${notes.togglePin} />`;
pinnedIds=${notes.pinnedIds} togglePin=${notes.togglePin}
standalone=${standalone} />`;
case 'editor':
return html`

View File

@@ -37,8 +37,12 @@ export function NoteList(props) {
toggleSelect, toggleSelectAll, bulkDelete,
onNoteClick, onNewNote, onDailyNote, onGraphClick,
pinnedIds, togglePin,
standalone = true,
} = props;
// When standalone=false, the surface sidebar owns search/filter/tag controls
const showFilters = standalone !== false;
const searchTimerRef = useRef(null);
const searchInputRef = useRef(null);
@@ -81,29 +85,31 @@ export function NoteList(props) {
</button>
</div>
<div class="sw-notes-pane__search-row">
<input class="sw-notes-pane__search-input" type="text"
placeholder="Search notes\u2026" autocomplete="off"
ref=${searchInputRef} onInput=${onSearchInput}
value=${searchQuery} />
</div>
${showFilters && html`
<div class="sw-notes-pane__search-row">
<input class="sw-notes-pane__search-input" type="text"
placeholder="Search notes\u2026" autocomplete="off"
ref=${searchInputRef} onInput=${onSearchInput}
value=${searchQuery} />
</div>`}
<div class="sw-notes-pane__filter-row">
<select class="sw-notes-pane__filter-select"
value=${folder} onChange=${e => { setFolder(e.target.value); }}>
<option value="">All folders</option>
${folders.map(f => html`
<option key=${f.path} value=${f.path}>${f.path} (${f.count})</option>`)}
</select>
<select class="sw-notes-pane__filter-select"
value=${sort} onChange=${e => setSort(e.target.value)}>
<option value="updated_desc">Last updated</option>
<option value="created_desc">Created</option>
<option value="alpha">Alphabetical</option>
</select>
</div>
${showFilters && html`
<div class="sw-notes-pane__filter-row">
<select class="sw-notes-pane__filter-select"
value=${folder} onChange=${e => { setFolder(e.target.value); }}>
<option value="">All folders</option>
${folders.map(f => html`
<option key=${f.path} value=${f.path}>${f.path} (${f.count})</option>`)}
</select>
<select class="sw-notes-pane__filter-select"
value=${sort} onChange=${e => setSort(e.target.value)}>
<option value="updated_desc">Last updated</option>
<option value="created_desc">Created</option>
<option value="alpha">Alphabetical</option>
</select>
</div>`}
${topTags.length > 0 && html`
${showFilters && topTags.length > 0 && html`
<div class="sw-notes-pane__tag-cloud">
${topTags.map(([tag, count]) => html`
<span key=${tag}

View File

@@ -8,9 +8,12 @@ const { useState } = hooks;
function initials(name) {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
return parts.length > 1
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
: name.slice(0, 2).toUpperCase();
if (parts.length > 1) {
// Multi-word: first + last initials ("John Doe" → "JD")
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
// Single word: first letter only ("admin" → "A")
return name[0].toUpperCase();
}
export function Avatar({ src, name = '', size = 'md' }) {

View File

@@ -0,0 +1,90 @@
// ==========================================
// Notes Surface — Root Component
// ==========================================
// Main notes surface: sidebar + workspace.
// Follows the chat surface mount pattern.
//
// v0.37.11: New Preact surface replacing the legacy template mount.
import { ToastContainer } from '../../primitives/toast.js';
import { DialogStack } from '../../shell/dialog-stack.js';
import { useWorkspace } from './use-workspace.js';
import { useSidebar } from './use-sidebar.js';
import { Sidebar } from './sidebar.js';
import { NotesWorkspace } from './notes-workspace.js';
const { render } = window.preact;
const html = window.html;
const { useRef, useCallback } = window.hooks;
function NotesSurface() {
const workspace = useWorkspace();
const sidebar = useSidebar();
const notesRef = useRef(null);
const _onNewNote = useCallback(() => {
if (notesRef.current) notesRef.current.newNote();
}, []);
const _toggleSidebar = useCallback(() => {
workspace.setSidebarOpen(!workspace.sidebarOpen);
}, [workspace.sidebarOpen, workspace.setSidebarOpen]);
const _onFolderSelect = useCallback((path) => {
if (notesRef.current?.setFolder) notesRef.current.setFolder(path);
}, []);
const _onTagSelect = useCallback((tag) => {
if (notesRef.current?.setTagFilter) notesRef.current.setTagFilter(tag);
}, []);
const _onSearch = useCallback((query) => {
if (notesRef.current?.setSearchQuery) notesRef.current.setSearchQuery(query);
}, []);
const _onNoteChange = useCallback((note) => {
if (note?.id) {
workspace.selectNote(note.id);
}
}, [workspace.selectNote]);
return html`
<div class="sw-notes-surface">
${/* Mobile sidebar overlay */''}
${workspace.sidebarOpen && html`
<div class="sw-notes-surface__sidebar-overlay"
onClick=${_toggleSidebar} />`}
${/* Sidebar */''}
${workspace.sidebarOpen && html`
<${Sidebar}
sidebar=${sidebar}
onNewNote=${_onNewNote}
onCollapse=${_toggleSidebar}
onFolderSelect=${_onFolderSelect}
onTagSelect=${_onTagSelect}
onSearch=${_onSearch} />`}
${/* Main workspace */''}
<div class="sw-notes-surface__main">
<${NotesWorkspace}
sidebarOpen=${workspace.sidebarOpen}
onToggleSidebar=${_toggleSidebar}
initialNoteId=${workspace.activeNoteId}
onNoteChange=${_onNoteChange}
handleRef=${notesRef} />
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
// ── Mount ────────────────────────────────────
const mount = document.getElementById('notes-mount');
if (mount) {
render(html`<${NotesSurface} />`, mount);
console.log('[notes] Surface mounted (v0.37.11)');
}
export { NotesSurface };

View File

@@ -0,0 +1,53 @@
// ==========================================
// Notes Surface — NotesWorkspace Component
// ==========================================
// Header bar (sidebar toggle) + NotesPane.
// NotesPane runs standalone=false — the surface manages filters.
//
// v0.37.11
import { NotesPane } from '../../components/notes-pane/index.js';
const html = window.html;
const PANEL_SVG = html`
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>`;
/**
* @param {{
* sidebarOpen: boolean,
* onToggleSidebar: () => void,
* initialNoteId?: string,
* onNoteChange?: (note: object|null) => void,
* handleRef?: { current: any },
* }} props
*/
export function NotesWorkspace({
sidebarOpen, onToggleSidebar, initialNoteId, onNoteChange, handleRef,
}) {
return html`
<div class="sw-notes-surface__workspace">
<div class="sw-notes-surface__workspace-header">
<div class="sw-notes-surface__workspace-header-left">
${!sidebarOpen && html`
<button class="sw-notes-surface__sidebar-toggle"
onClick=${onToggleSidebar}
title="Show sidebar"
aria-label="Show sidebar">
${PANEL_SVG}
</button>`}
</div>
<div class="sw-notes-surface__workspace-header-right" />
</div>
<${NotesPane}
standalone=${false}
initialNoteId=${initialNoteId}
handleRef=${handleRef}
onNoteChange=${onNoteChange}
className="sw-notes-surface__notes-pane" />
</div>`;
}

View File

@@ -0,0 +1,71 @@
// ==========================================
// Notes Surface — SidebarFolders Component
// ==========================================
// Collapsible folder tree for the notes sidebar.
// v0.37.11
const html = window.html;
const CHEVRON_SVG = html`
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>`;
const FOLDER_SVG = html`
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>`;
/**
* @param {{
* folders: Array<{ path: string, count: number }>,
* activeFolder: string,
* collapsed: boolean,
* onToggle: () => void,
* onSelect: (path: string) => void,
* }} props
*/
export function SidebarFolders({ folders, activeFolder, collapsed, onToggle, onSelect }) {
const totalCount = folders.reduce((sum, f) => sum + (f.count || 0), 0);
return html`
<div class="sw-notes-surface__section">
<button class="sw-notes-surface__section-header"
onClick=${onToggle}
aria-expanded=${!collapsed}>
<span class=${'sw-notes-surface__chevron' + (collapsed ? '' : ' sw-notes-surface__chevron--open')}>
${CHEVRON_SVG}
</span>
<span class="sw-notes-surface__section-label">Folders</span>
${totalCount > 0 && html`
<span class="sw-notes-surface__section-count">${totalCount}</span>`}
</button>
${!collapsed && html`
<div class="sw-notes-surface__section-body" role="listbox">
${/* "All Notes" clears folder filter */''}
<button class=${'sw-notes-surface__item' + (!activeFolder ? ' sw-notes-surface__item--active' : '')}
onClick=${() => onSelect('')}
role="option"
aria-selected=${!activeFolder}>
<span class="sw-notes-surface__item-icon">${FOLDER_SVG}</span>
<span class="sw-notes-surface__item-title">All Notes</span>
</button>
${folders.map(f => html`
<button key=${f.path}
class=${'sw-notes-surface__item' + (activeFolder === f.path ? ' sw-notes-surface__item--active' : '')}
onClick=${() => onSelect(f.path)}
role="option"
aria-selected=${activeFolder === f.path}
title=${f.path}>
<span class="sw-notes-surface__item-icon">${FOLDER_SVG}</span>
<span class="sw-notes-surface__item-title">${f.path}</span>
${f.count > 0 && html`
<span class="sw-notes-surface__item-count">${f.count}</span>`}
</button>`)}
${folders.length === 0 && html`
<div class="sw-notes-surface__empty">No folders</div>`}
</div>`}
</div>`;
}

View File

@@ -0,0 +1,52 @@
// ==========================================
// Notes Surface — SidebarTags Component
// ==========================================
// Collapsible tag cloud for the notes sidebar.
// v0.37.11
const html = window.html;
const CHEVRON_SVG = html`
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>`;
/**
* @param {{
* tags: Array<[string, number]>,
* activeTag: string,
* collapsed: boolean,
* onToggle: () => void,
* onSelect: (tag: string) => void,
* }} props
*/
export function SidebarTags({ tags, activeTag, collapsed, onToggle, onSelect }) {
return html`
<div class="sw-notes-surface__section">
<button class="sw-notes-surface__section-header"
onClick=${onToggle}
aria-expanded=${!collapsed}>
<span class=${'sw-notes-surface__chevron' + (collapsed ? '' : ' sw-notes-surface__chevron--open')}>
${CHEVRON_SVG}
</span>
<span class="sw-notes-surface__section-label">Tags</span>
${tags.length > 0 && html`
<span class="sw-notes-surface__section-count">${tags.length}</span>`}
</button>
${!collapsed && html`
<div class="sw-notes-surface__section-body">
<div class="sw-notes-surface__tag-cloud">
${tags.length === 0 && html`
<div class="sw-notes-surface__empty">No tags</div>`}
${tags.map(([tag, count]) => html`
<span key=${tag}
class=${'sw-notes-surface__tag-chip' + (activeTag === tag ? ' sw-notes-surface__tag-chip--active' : '')}
onClick=${() => onSelect(activeTag === tag ? '' : tag)}
title=${tag + ' (' + count + ')'}>
${tag}
</span>`)}
</div>
</div>`}
</div>`;
}

View File

@@ -0,0 +1,121 @@
// ==========================================
// Notes Surface — Sidebar Component
// ==========================================
// Search bar, new note, folder tree, tag cloud.
// Footer has UserMenu — same pattern as chat sidebar.
//
// v0.37.11
import { SidebarFolders } from './sidebar-folders.js';
import { SidebarTags } from './sidebar-tags.js';
import { UserMenu } from '../../shell/user-menu.js';
const html = window.html;
const { useCallback, useRef } = window.hooks;
const PLUS_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>`;
const SEARCH_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>`;
const COLLAPSE_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>`;
/**
* @param {{
* sidebar: ReturnType<import('./use-sidebar.js').useSidebar>,
* onNewNote: () => void,
* onCollapse: () => void,
* onFolderSelect: (path: string) => void,
* onTagSelect: (tag: string) => void,
* onSearch: (query: string) => void,
* }} props
*/
export function Sidebar({ sidebar, onNewNote, onCollapse, onFolderSelect, onTagSelect, onSearch }) {
const searchTimerRef = useRef(null);
const _onSearchInput = useCallback((e) => {
const val = e.target.value;
sidebar.setSearch(val);
clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => {
onSearch(val.trim());
}, 300);
}, [sidebar.setSearch, onSearch]);
const _onFolderSelect = useCallback((path) => {
sidebar.setActiveFolder(path);
onFolderSelect(path);
}, [sidebar.setActiveFolder, onFolderSelect]);
const _onTagSelect = useCallback((tag) => {
sidebar.setActiveTag(tag);
onTagSelect(tag);
}, [sidebar.setActiveTag, onTagSelect]);
return html`
<div class="sw-notes-surface__sidebar" role="navigation" aria-label="Notes sidebar">
${/* Header: new note + collapse */''}
<div class="sw-notes-surface__sidebar-header">
<button class="sw-notes-surface__new-note-btn"
onClick=${onNewNote}
title="New note"
aria-label="Create new note">
${PLUS_SVG}
<span>New Note</span>
</button>
<button class="sw-notes-surface__collapse-btn"
onClick=${onCollapse}
title="Collapse sidebar"
aria-label="Collapse sidebar">
${COLLAPSE_SVG}
</button>
</div>
${/* Search */''}
<div class="sw-notes-surface__search-wrap">
<span class="sw-notes-surface__search-icon">${SEARCH_SVG}</span>
<input class="sw-notes-surface__search"
type="text"
placeholder="Search notes\u2026"
value=${sidebar.search}
onInput=${_onSearchInput}
aria-label="Search notes" />
</div>
${/* Scrollable content */''}
<div class="sw-notes-surface__sidebar-body">
<${SidebarFolders}
folders=${sidebar.folders}
activeFolder=${sidebar.activeFolder}
collapsed=${sidebar.collapsed.folders}
onToggle=${() => sidebar.toggleCollapsed('folders')}
onSelect=${_onFolderSelect} />
<${SidebarTags}
tags=${sidebar.tags}
activeTag=${sidebar.activeTag}
collapsed=${sidebar.collapsed.tags}
onToggle=${() => sidebar.toggleCollapsed('tags')}
onSelect=${_onTagSelect} />
</div>
${/* Footer: UserMenu only */''}
<div class="sw-notes-surface__sidebar-footer">
<${UserMenu} placement="up-right" />
</div>
</div>`;
}

View File

@@ -0,0 +1,122 @@
// ==========================================
// Notes Surface — useSidebar Hook
// ==========================================
// Loads folders and tags for the sidebar.
// Subscribes to WS events for live updates.
//
// v0.37.11: New Preact surface.
const { useState, useEffect, useCallback, useMemo } = window.hooks;
const COLLAPSE_KEY = 'sw-notes-sidebar-collapsed';
/**
* @returns {{
* folders: Array<{ path: string, count: number }>,
* tags: Array<[string, number]>,
* activeFolder: string,
* activeTag: string,
* search: string,
* setActiveFolder: (f: string) => void,
* setActiveTag: (t: string) => void,
* setSearch: (q: string) => void,
* collapsed: { folders: boolean, tags: boolean },
* toggleCollapsed: (key: string) => void,
* loading: boolean,
* }}
*/
export function useSidebar() {
const [folders, setFolders] = useState([]);
const [allNotes, setAllNotes] = useState([]);
const [activeFolder, setActiveFolder] = useState('');
const [activeTag, setActiveTag] = useState('');
const [search, setSearch] = useState('');
const [collapsed, setCollapsed] = useState(_loadCollapsed);
const [loading, setLoading] = useState(true);
// ── Load data ───────────────────────────
const reload = useCallback(async () => {
if (!window.sw?.api?.notes) return;
try {
const [fResp, nResp] = await Promise.all([
window.sw.api.notes.folders().catch(() => []),
window.sw.api.notes.list({ per_page: 200 }).catch(() => []),
]);
setFolders(_extract(fResp));
setAllNotes(_extract(nResp));
} catch (_) {}
setLoading(false);
}, []);
useEffect(() => { reload(); }, [reload]);
// ── WebSocket live updates ──────────────
useEffect(() => {
if (!window.sw?.on) return;
const events = [
'note.created', 'note.updated', 'note.deleted',
];
events.forEach(ev => window.sw.on(ev, reload));
return () => {
events.forEach(ev => window.sw.off?.(ev, reload));
};
}, [reload]);
// ── Aggregate tags from notes ───────────
const tags = useMemo(() => {
const counts = {};
for (const n of allNotes) {
for (const t of (n.tags || [])) {
counts[t] = (counts[t] || 0) + 1;
}
}
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 30);
}, [allNotes]);
// ── Collapse sections ───────────────────
const toggleCollapsed = useCallback((key) => {
setCollapsed(prev => {
const next = { ...prev, [key]: !prev[key] };
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next)); } catch (_) {}
return next;
});
}, []);
return {
folders,
tags,
activeFolder,
activeTag,
search,
setActiveFolder,
setActiveTag,
setSearch,
collapsed,
toggleCollapsed,
loading,
};
}
function _extract(resp) {
if (!resp) return [];
if (Array.isArray(resp)) return resp;
if (Array.isArray(resp.data)) return resp.data;
const vals = Object.values(resp);
for (const v of vals) {
if (Array.isArray(v)) return v;
}
return [];
}
function _loadCollapsed() {
try {
return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {};
} catch (_) { return {}; }
}

View File

@@ -0,0 +1,59 @@
// ==========================================
// Notes Surface — useWorkspace Hook
// ==========================================
// Coordinator hook: manages sidebar state and active note.
// Persists active selection to sessionStorage for reload restore.
//
// v0.37.11: New Preact surface.
const { useState, useCallback, useEffect } = window.hooks;
const STORAGE_KEY = 'sw-notes-active';
/**
* @returns {{
* activeNoteId: string|null,
* sidebarOpen: boolean,
* setSidebarOpen: (v: boolean) => void,
* selectNote: (id: string) => void,
* clearSelection: () => void,
* }}
*/
export function useWorkspace() {
const stored = _loadStored();
const [activeNoteId, setActiveNoteId] = useState(stored);
const [sidebarOpen, setSidebarOpen] = useState(true);
// Persist on change
useEffect(() => {
if (activeNoteId) {
try { sessionStorage.setItem(STORAGE_KEY, activeNoteId); } catch (_) {}
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
}, [activeNoteId]);
const selectNote = useCallback((id) => {
setActiveNoteId(id);
// Auto-close sidebar on mobile
if (window.innerWidth < 768) setSidebarOpen(false);
}, []);
const clearSelection = useCallback(() => {
setActiveNoteId(null);
}, []);
return {
activeNoteId,
sidebarOpen,
setSidebarOpen,
selectNote,
clearSelection,
};
}
function _loadStored() {
try {
return sessionStorage.getItem(STORAGE_KEY) || null;
} catch (_) { return null; }
}