Waters Boards: self-hosted work management to replace monday.com
Deploy Waters Boards to NAS / deploy (push) Canceled after 0s

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>
This commit is contained in:
2026-10-13 15:34:47 -04:00
co-authored by Claude Opus 5.5
commit fcf3805626
44 changed files with 7443 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
// Left navigation: global nav rail + workspace tree (folders, boards, favorites).
import { html, useState } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, update, useStore, navigate, boardPath, loadTree, localSet, localGet } from './store.js';
import { Icon, Avatar, menu, openModal, closeModal, ModalHeader, promptDialog, confirmDialog, openPopover, ColorPicker, GROUP_COLORS } from './ui.js';
import * as A from './actions.js';
import { UserMenu } from './pages.js';
export function Sidebar() {
const s = useStore();
const [q, setQ] = useState('');
const [openFolders, setOpenFolders] = useState(() => { try { return JSON.parse(localGet('wb.openFolders') || '{}'); } catch { return {}; } });
const ws = s.workspaces.find((w) => w.id === s.currentWorkspace) || s.workspaces[0];
const r = s.route;
const toggleFolder = (id) => { const n = { ...openFolders, [id]: !openFolders[id] }; setOpenFolders(n); localSet('wb.openFolders', JSON.stringify(n)); };
const pickWs = (id) => { localSet('wb.workspace', id); update({ currentWorkspace: id }); };
const favs = s.boards.filter((b) => s.favorites.includes(b.id));
const filter = q.trim().toLowerCase();
const wsBoards = s.boards.filter((b) => b.workspace_id === ws?.id && (!filter || b.name.toLowerCase().includes(filter)));
const wsFolders = s.folders.filter((f) => f.workspace_id === ws?.id);
const wsMenu = (e) => menu(e, [
...s.workspaces.map((w) => ({ label: w.name, icon: 'workspace', active: w.id === ws?.id, onClick: () => pickWs(w.id) })),
{ divider: true },
{ label: 'Add workspace', icon: 'plus', onClick: async () => {
const name = await promptDialog({ title: 'New workspace', placeholder: 'e.g. Production, Creative, Facilities', confirm: 'Create' });
if (name) { const w = await api.post('/api/workspaces', { name, color: GROUP_COLORS[s.workspaces.length % GROUP_COLORS.length] }); await loadTree(); pickWs(w.id); }
} },
ws && { label: 'Workspace overview', icon: 'info', onClick: () => navigate(`/workspaces/${ws.id}`) },
ws && { label: 'Rename workspace', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Rename workspace', value: ws.name }); if (n) { await api.patch(`/api/workspaces/${ws.id}`, { name: n }); loadTree(); } } },
ws && s.me.role === 'admin' && s.workspaces.length > 1 && { label: 'Delete workspace', icon: 'trash', danger: true, onClick: async () => {
if (await confirmDialog({ title: `Delete workspace "${ws.name}"?`, body: 'Every board, item and file in it is deleted permanently.' })) { await api.del(`/api/workspaces/${ws.id}`); await loadTree(); pickWs(s.workspaces[0]?.id); }
} },
]);
const addMenu = (e) => menu(e, [
{ label: 'New board', icon: 'board', onClick: () => openModal(NewBoardModal, { workspaceId: ws.id }) },
{ label: 'New folder', icon: 'folder', onClick: () => A.newFolder(ws.id) },
]);
const BoardLink = ({ b, depth = 0 }) => html`<div class="nav-board ${r.boardId === b.id ? 'active' : ''}" style=${{ paddingLeft: 12 + depth * 14 }}
draggable="true" onDragStart=${(e) => { e.dataTransfer.setData('wb/board', b.id); }}
onClick=${() => navigate(boardPath(b.id))}
onContextMenu=${(e) => boardMenu(e, b)}>
<${Icon} name="board" size=${15} /><span class="nav-label">${b.name}</span>
<button class="icon-btn nav-more" onClick=${(e) => boardMenu(e, b)}><${Icon} name="dots" size=${14} /></button>
</div>`;
const boardMenu = (e, b) => menu(e, [
{ label: 'Open in new tab', icon: 'expand', onClick: () => window.open(boardPath(b.id), '_blank') },
{ label: 'Rename', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Rename board', value: b.name }); if (n) { await A.renameBoard(b.id, n); loadTree(); } } },
{ label: s.favorites.includes(b.id) ? 'Remove from favorites' : 'Add to favorites', icon: 'star', onClick: () => A.toggleFavorite(b.id).then(loadTree) },
{ label: 'Duplicate', icon: 'copy', onClick: () => A.duplicateBoard(b.id) },
{ label: 'Move to folder', icon: 'folder', keepOpen: true, onClick: (ev) => menu(ev, [{ label: '(no folder)', onClick: () => moveBoard(b, null) },
...wsFolders.map((f) => ({ label: f.name, onClick: () => moveBoard(b, f.id) }))]) },
{ divider: true },
{ label: 'Delete', icon: 'trash', danger: true, onClick: () => A.deleteBoard(b.id) },
]);
const moveBoard = async (b, folderId) => { await api.patch(`/api/boards/${b.id}`, { folder_id: folderId }); loadTree(); };
const Folder = ({ f, depth = 0 }) => {
const open = openFolders[f.id] || !!filter;
const kids = wsFolders.filter((x) => x.parent_id === f.id);
const boards = wsBoards.filter((b) => b.folder_id === f.id);
if (filter && !boards.length && !kids.length) return null;
const fMenu = (e) => menu(e, [
{ label: 'New board here', icon: 'board', onClick: () => openModal(NewBoardModal, { workspaceId: ws.id, folderId: f.id }) },
{ label: 'New subfolder', icon: 'folder', onClick: () => A.newFolder(ws.id, f.id) },
{ label: 'Rename', icon: 'edit', onClick: () => A.renameFolder(f) },
{ label: 'Color', icon: 'gear', keepOpen: true, onClick: (ev) => openPopover(ev.currentTarget, ColorPicker, { value: f.color, onPick: async (c) => { await api.patch(`/api/folders/${f.id}`, { color: c }); loadTree(); } }) },
{ divider: true },
{ label: 'Delete folder', icon: 'trash', danger: true, onClick: () => A.deleteFolder(f) },
]);
return html`<div class="nav-folder">
<div class="nav-folder-row" style=${{ paddingLeft: 8 + depth * 14 }} onClick=${() => toggleFolder(f.id)} onContextMenu=${fMenu}
onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { const id = e.dataTransfer.getData('wb/board'); if (id) moveBoard({ id }, f.id); }}>
<${Icon} name=${open ? 'chevDown' : 'chevRight'} size=${13} />
<span style=${{ color: f.color || undefined }}><${Icon} name=${open ? 'folderOpen' : 'folder'} size=${15} /></span>
<span class="nav-label">${f.name}</span>
<button class="icon-btn nav-more" onClick=${fMenu}><${Icon} name="dots" size=${14} /></button>
</div>
${open ? html`${kids.map((k) => html`<${Folder} key=${k.id} f=${k} depth=${depth + 1} />`)}${boards.map((b) => html`<${BoardLink} key=${b.id} b=${b} depth=${depth + 1} />`)}` : null}
</div>`;
};
return html`<aside class="sidebar ${s.sidebarOpen ? 'open' : ''} ${s.sidebarCollapsed ? 'collapsed' : ''}">
<div class="rail">
<button class="logo" onClick=${() => navigate('/')} title="Home"><span class="logo-mark"><i /><i /><i /></span></button>
<${RailBtn} icon="home" label="Home" active=${r.name === 'home'} onClick=${() => navigate('/')} />
<${RailBtn} icon="work" label="My work" active=${r.name === 'my-work'} onClick=${() => navigate('/my-work')} />
<${RailBtn} icon="bell" label="Inbox" active=${r.name === 'inbox'} onClick=${() => navigate('/inbox')} badge=${s.unread} />
<${RailBtn} icon="search" label="Search" active=${r.name === 'search'} onClick=${() => navigate('/search')} />
<${RailBtn} icon="people" label="Team" active=${r.name === 'people'} onClick=${() => navigate('/people')} />
<span class="spacer" />
${s.me?.role === 'admin' ? html`<${RailBtn} icon="shield" label="Admin" active=${r.name === 'admin'} onClick=${() => navigate('/admin')} />` : null}
<button class="rail-me" onClick=${(e) => openPopover(e.currentTarget, UserMenu, {}, {})} title=${s.me?.name}><${Avatar} user=${s.me} size=${32} /></button>
</div>
<div class="nav-panel">
<div class="nav-head">
<button class="ws-picker" onClick=${wsMenu}>
<span class="ws-icon" style=${{ background: ws?.color || '#579bfc' }}>${(ws?.name || '?')[0]}</span>
<span class="nav-label">${ws?.name || 'Workspace'}</span><${Icon} name="chevDown" size=${14} />
</button>
<button class="icon-btn" onClick=${addMenu} title="Add"><${Icon} name="plus" /></button>
<button class="icon-btn collapse-btn" onClick=${() => { localSet('wb.sidebarCollapsed', '1'); update({ sidebarCollapsed: true }); }} title="Collapse"><${Icon} name="chevLeft" /></button>
</div>
<div class="nav-search"><${Icon} name="search" size=${14} /><input placeholder="Search boards" value=${q} onInput=${(e) => setQ(e.target.value)} /></div>
<div class="nav-scroll">
${favs.length && !filter ? html`<div class="nav-section"><div class="nav-section-title"><${Icon} name="star" size=${13} /> Favorites</div>
${favs.map((b) => html`<${BoardLink} key=${'f' + b.id} b=${b} />`)}</div>` : null}
<div class="nav-section" onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { const id = e.dataTransfer.getData('wb/board'); if (id && e.target.classList.contains('nav-section-title')) moveBoard({ id }, null); }}>
<div class="nav-section-title">Boards</div>
${wsFolders.filter((f) => !f.parent_id).map((f) => html`<${Folder} key=${f.id} f=${f} />`)}
${wsBoards.filter((b) => !b.folder_id || !wsFolders.some((f) => f.id === b.folder_id)).map((b) => html`<${BoardLink} key=${b.id} b=${b} />`)}
${!wsBoards.length && !filter ? html`<button class="nav-empty" onClick=${() => openModal(NewBoardModal, { workspaceId: ws.id })}><${Icon} name="plus" /> Create your first board</button>` : null}
</div>
</div>
</div>
${s.sidebarCollapsed ? html`<button class="expand-btn" onClick=${() => { localSet('wb.sidebarCollapsed', null); update({ sidebarCollapsed: false }); }} title="Expand"><${Icon} name="chevRight" /></button>` : null}
</aside>
${s.sidebarOpen ? html`<div class="drawer-backdrop" onClick=${() => update({ sidebarOpen: false })} />` : null}`;
}
function RailBtn({ icon, label, active, onClick, badge }) {
return html`<button class="rail-btn ${active ? 'active' : ''}" onClick=${onClick} title=${label}>
<${Icon} name=${icon} size=${20} />${badge ? html`<span class="rail-badge">${badge > 99 ? '99+' : badge}</span>` : null}<span class="rail-label">${label}</span></button>`;
}
const TEMPLATES = [
['blank', 'Blank board', 'Owner, Status and Due date to start', 'board'],
['project', 'Project tracker', 'Owner, status, priority, timeline', 'timeline'],
['inventory', 'Equipment inventory', 'Model, serial #, location, condition, files', 'archive'],
['content', 'Content calendar', 'Owner, status, publish date, channel, caption', 'calendar'],
['requests', 'Requests', 'Intake for requests with priority and due date', 'inbox'],
];
export function NewBoardModal({ workspaceId, folderId }) {
const s = useStore();
const [name, setName] = useState('');
const [tpl, setTpl] = useState('blank');
const [ws, setWs] = useState(workspaceId);
const [folder, setFolder] = useState(folderId || '');
const [busy, setBusy] = useState(false);
const create = async (e) => {
e.preventDefault();
setBusy(true);
try {
const r = await api.post('/api/boards', { workspace_id: ws, folder_id: folder || null, name: name.trim() || 'New board', template: tpl });
await loadTree(); closeModal(); navigate(boardPath(r.id));
} finally { setBusy(false); }
};
return html`<form class="dialog" onSubmit=${create}><${ModalHeader} title="Create board" />
<label class="field-label">Board name</label>
<input class="input" value=${name} onInput=${(e) => setName(e.target.value)} placeholder="New board" autofocus />
<label class="field-label">Start from</label>
<div class="tpl-grid">${TEMPLATES.map(([k, l, d, i]) => html`<button type="button" key=${k} class="tpl ${tpl === k ? 'on' : ''}" onClick=${() => setTpl(k)}>
<${Icon} name=${i} size=${20} /><strong>${l}</strong><span class="muted small">${d}</span></button>`)}</div>
<div class="row gap">
<div class="grow"><label class="field-label">Workspace</label>
<select class="input" value=${ws} onChange=${(e) => { setWs(e.target.value); setFolder(''); }}>${s.workspaces.map((w) => html`<option value=${w.id}>${w.name}</option>`)}</select></div>
<div class="grow"><label class="field-label">Folder</label>
<select class="input" value=${folder} onChange=${(e) => setFolder(e.target.value)}><option value="">(none)</option>
${s.folders.filter((f) => f.workspace_id === ws).map((f) => html`<option value=${f.id}>${f.name}</option>`)}</select></div>
</div>
<div class="modal-actions"><button type="button" class="btn ghost" onClick=${closeModal}>Cancel</button><button class="btn primary" disabled=${busy}>Create board</button></div>
</form>`;
}