Changeset 0.37.11 (#223)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' }) {
|
||||
|
||||
90
src/js/sw/surfaces/notes/index.js
Normal file
90
src/js/sw/surfaces/notes/index.js
Normal 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 };
|
||||
53
src/js/sw/surfaces/notes/notes-workspace.js
Normal file
53
src/js/sw/surfaces/notes/notes-workspace.js
Normal 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>`;
|
||||
}
|
||||
71
src/js/sw/surfaces/notes/sidebar-folders.js
Normal file
71
src/js/sw/surfaces/notes/sidebar-folders.js
Normal 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>`;
|
||||
}
|
||||
52
src/js/sw/surfaces/notes/sidebar-tags.js
Normal file
52
src/js/sw/surfaces/notes/sidebar-tags.js
Normal 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>`;
|
||||
}
|
||||
121
src/js/sw/surfaces/notes/sidebar.js
Normal file
121
src/js/sw/surfaces/notes/sidebar.js
Normal 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>`;
|
||||
}
|
||||
122
src/js/sw/surfaces/notes/use-sidebar.js
Normal file
122
src/js/sw/surfaces/notes/use-sidebar.js
Normal 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 {}; }
|
||||
}
|
||||
59
src/js/sw/surfaces/notes/use-workspace.js
Normal file
59
src/js/sw/surfaces/notes/use-workspace.js
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user