// Tiny global store: plain object + subscribe. Components call useStore() to re-render on change. import { useEffect, useState } from '../vendor/preact-htm.mjs'; import { api, onEvent, clientId } from './api.js'; export const state = { me: null, users: [], usersById: {}, workspaces: [], folders: [], boards: [], favorites: [], unread: 0, online: [], boardData: {}, // boardId → full board payload route: parseRoute(), toasts: [], sidebarOpen: false, // mobile drawer sidebarCollapsed: localGet('wb.sidebarCollapsed') === '1', currentWorkspace: localGet('wb.workspace'), theme: localGet('wb.theme') || 'system', }; const subs = new Set(); let scheduled = false; export function update(patch) { if (patch) Object.assign(state, typeof patch === 'function' ? patch(state) : patch); if (scheduled) return; scheduled = true; queueMicrotask(() => { scheduled = false; for (const fn of subs) fn(); }); } export function useStore() { const [, set] = useState(0); useEffect(() => { const fn = () => set((n) => n + 1); subs.add(fn); return () => subs.delete(fn); }, []); return state; } export function localGet(k) { try { return localStorage.getItem(k); } catch { return null; } } export function localSet(k, v) { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch { /* private mode */ } } // ─── Routing ───────────────────────────────────────────── export function parseRoute(path = location.pathname, search = location.search) { const p = path.split('/').filter(Boolean); const q = Object.fromEntries(new URLSearchParams(search)); if (p[0] === 'boards' && p[1]) { return { name: 'board', boardId: p[1], viewId: p[2] === 'views' ? p[3] : null, itemId: p.includes('items') ? p[p.indexOf('items') + 1] : null, q }; } if (p[0] === 'workspaces' && p[1]) return { name: 'workspace', workspaceId: p[1], q }; if (['my-work', 'inbox', 'search', 'admin', 'people', 'favorites', 'profile'].includes(p[0])) return { name: p[0], q, tab: p[1] }; return { name: 'home', q }; } export function navigate(path, { replace = false } = {}) { if (path === location.pathname + location.search) return; history[replace ? 'replaceState' : 'pushState']({}, '', path); update({ route: parseRoute(), sidebarOpen: false }); } window.addEventListener('popstate', () => update({ route: parseRoute() })); export function boardPath(boardId, { viewId, itemId } = {}) { let p = `/boards/${boardId}`; if (viewId) p += `/views/${viewId}`; if (itemId) p += `/items/${itemId}`; return p; } // ─── Data loading ──────────────────────────────────────── export async function loadBootstrap() { const d = await api.get('/api/bootstrap'); setUsers(d.users); update({ me: d.me, workspaces: d.workspaces, folders: d.folders, boards: d.boards, favorites: d.favorites, unread: d.unread, online: d.online }); if (!state.currentWorkspace || !d.workspaces.some((w) => w.id === state.currentWorkspace)) { update({ currentWorkspace: d.workspaces[0]?.id || null }); } } function setUsers(users) { update({ users, usersById: Object.fromEntries(users.map((u) => [u.id, u])) }); } export async function loadTree() { const d = await api.get('/api/tree'); update({ workspaces: d.workspaces, folders: d.folders, boards: d.boards, favorites: d.favorites }); } export async function loadUsers() { setUsers(await api.get('/api/users')); } const inflight = {}; export async function loadBoard(boardId) { if (inflight[boardId]) return inflight[boardId]; inflight[boardId] = api.get(`/api/boards/${boardId}`) .then((d) => { state.boardData[boardId] = d; update(); return d; }) .catch((e) => { if (e.status === 404) { state.boardData[boardId] = { missing: true }; update(); } throw e; }) .finally(() => { delete inflight[boardId]; }); return inflight[boardId]; } // Optimistic local edit of a loaded board, then server call; on failure, reload. export function mutateBoard(boardId, fn) { const b = state.boardData[boardId]; if (b && !b.missing) { fn(b); update(); } } export async function serverCall(boardId, promise) { try { return await promise; } catch (e) { toast(e.message, 'error'); if (boardId) loadBoard(boardId); throw e; } } // Live refresh: refetch what's on screen when someone else changes it. const refetchTimers = {}; onEvent((ev) => { if (ev.type === 'reconnected') { if (state.me) { loadTree(); for (const id of Object.keys(state.boardData)) if (isVisible(id)) loadBoard(id); } return; } if (ev.type === 'notification') { api.get('/api/bootstrap').then((d) => update({ unread: d.unread, online: d.online })); window.dispatchEvent(new CustomEvent('wb:notifications')); return; } if (ev.origin === clientId) return; if (ev.type === 'tree') loadTree(); if (ev.type === 'board' && state.boardData[ev.boardId]) { clearTimeout(refetchTimers[ev.boardId]); refetchTimers[ev.boardId] = setTimeout(() => { if (isVisible(ev.boardId)) loadBoard(ev.boardId); else delete state.boardData[ev.boardId]; window.dispatchEvent(new CustomEvent('wb:board-changed', { detail: ev.boardId })); }, 150); } }); function isVisible(boardId) { return state.route.boardId === boardId; } // ─── Toasts ────────────────────────────────────────────── let toastId = 0; export function toast(text, kind = 'info', action) { const t = { id: ++toastId, text, kind, action }; update({ toasts: [...state.toasts, t] }); setTimeout(() => update({ toasts: state.toasts.filter((x) => x.id !== t.id) }), action ? 6000 : 3500); } // ─── Theme ─────────────────────────────────────────────── export function applyTheme(theme = state.theme) { const dark = theme === 'dark' || theme === 'night' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches); document.documentElement.dataset.theme = theme === 'night' ? 'night' : dark ? 'dark' : 'light'; document.querySelector('meta[name=theme-color]')?.setAttribute('content', dark ? (theme === 'night' ? '#111111' : '#181b34') : '#ffffff'); } export function setTheme(theme) { localSet('wb.theme', theme); update({ theme }); applyTheme(theme); } matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => applyTheme());