Files
christianandClaude Opus 5.5 fcf3805626
Deploy Waters Boards to NAS / deploy (push) Canceled after 0s
Waters Boards: self-hosted work management to replace monday.com
Boards with groups, items, subitems and 18 column types; table, kanban,
calendar, timeline, chart and files views; updates with @mentions,
replies and attachments; inbox, My work, search, automations; Google
sign-in restricted to company domains; live updates over SSE; a
monday.com API importer; phone layout and installable PWA; Docker +
Gitea Actions deploy to the NAS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:34:47 -04:00

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());