144 lines
6.5 KiB
JavaScript
144 lines
6.5 KiB
JavaScript
// 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());
|