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
+272
View File
@@ -0,0 +1,272 @@
// Item panel: slides in from the right (full screen on phones). Updates, info, files, activity.
import { html, useState, useEffect, useRef } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, useStore, navigate, boardPath, loadBoard, toast } from './store.js';
import { Icon, Avatar, InlineEdit, menu, timeAgo, RichText, fileUrl, isImage, fmtSize, confirmDialog, Spinner, fmtDate, ymd } from './ui.js';
import { Cell, COLUMN_TYPES } from './cells.js';
import * as A from './actions.js';
export function ItemPanel({ board, itemId, viewId }) {
const s = useStore();
const boardId = board.board.id;
const item = board.items.find((i) => i.id === itemId);
const [detail, setDetail] = useState(null);
const [tab, setTab] = useState(window.innerWidth < 769 ? 'info' : 'updates');
const close = () => navigate(boardPath(boardId, { viewId }));
const reload = () => api.get(`/api/items/${itemId}`).then(setDetail).catch((e) => { if (e.status === 404) close(); });
useEffect(() => { reload(); }, [itemId]);
useEffect(() => {
const fn = (e) => { if (e.detail === boardId) reload(); };
window.addEventListener('wb:board-changed', fn);
const key = (e) => { if (e.key === 'Escape' && !s.popover && !s.modal && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName)) close(); };
document.addEventListener('keydown', key);
return () => { window.removeEventListener('wb:board-changed', fn); document.removeEventListener('keydown', key); };
}, [itemId]);
if (!item) {
return html`<aside class="item-panel"><div class="panel-head"><button class="icon-btn" onClick=${close}><${Icon} name="x" /></button></div>
${detail === null ? html`<${Spinner} />` : html`<p class="pad muted">This item was deleted or moved.</p>`}</aside>`;
}
const parent = item.parent_id ? board.items.find((i) => i.id === item.parent_id) : null;
const group = board.groups.find((g) => g.id === (item.group_id || parent?.group_id));
const subs = board.items.filter((i) => i.parent_id === item.id).sort((a, b) => a.position - b.position);
const nUpdates = detail?.updates.filter((u) => !u.parent_id).length ?? board.updateCounts?.[item.id] ?? 0;
const nFiles = detail?.files.length ?? 0;
const itemMenu = (e) => menu(e, [
{ label: 'Duplicate', icon: 'copy', onClick: () => A.duplicateItems(boardId, [item.id]) },
{ label: 'Copy link', icon: 'link', onClick: () => { navigator.clipboard?.writeText(location.origin + boardPath(boardId, { itemId: item.id })); toast('Link copied'); } },
!item.parent_id && { label: 'Move to board', icon: 'board', keepOpen: true, onClick: (ev) => menu(ev, state.boards.filter((b) => b.id !== boardId).map((b) => ({ label: b.name, onClick: () => { A.moveItemToBoard(boardId, item.id, b.id); close(); } }))) },
{ label: 'Archive', icon: 'archive', onClick: () => { A.archiveItems(boardId, [item.id]); close(); } },
{ label: 'Delete', icon: 'trash', danger: true, onClick: () => A.deleteItems(boardId, [item.id]) },
], { align: 'right' });
return html`<div class="panel-backdrop" onClick=${close} />
<aside class="item-panel" role="dialog" aria-label=${item.name}>
<div class="panel-head">
<button class="icon-btn" onClick=${close} aria-label="Close"><${Icon} name="x" size=${20} /></button>
<div class="crumbs muted small">
<span>${board.board.name}</span>
${group ? html`<${Icon} name="chevRight" size=${12} /><span style=${{ color: group.color }}>${group.title}</span>` : null}
${parent ? html`<${Icon} name="chevRight" size=${12} /><button class="link" onClick=${() => navigate(boardPath(boardId, { viewId, itemId: parent.id }))}>${parent.name}</button>` : null}
</div>
<button class="icon-btn" onClick=${itemMenu}><${Icon} name="dots" size=${20} /></button>
</div>
<h2 class="panel-title"><${InlineEdit} value=${item.name} onSave=${(n) => n && A.renameItem(boardId, item.id, n)} /></h2>
<nav class="panel-tabs">
<button class=${tab === 'updates' ? 'active' : ''} onClick=${() => setTab('updates')}>Updates${nUpdates ? ` / ${nUpdates}` : ''}</button>
<button class=${tab === 'info' ? 'active' : ''} onClick=${() => setTab('info')}>Info${subs.length ? ` / ${subs.length}` : ''}</button>
<button class=${tab === 'files' ? 'active' : ''} onClick=${() => setTab('files')}>Files${nFiles ? ` / ${nFiles}` : ''}</button>
<button class=${tab === 'activity' ? 'active' : ''} onClick=${() => setTab('activity')}>Activity</button>
</nav>
<div class="panel-body">
${!detail ? html`<${Spinner} />`
: tab === 'updates' ? html`<${Updates} item=${item} detail=${detail} reload=${reload} board=${board} />`
: tab === 'info' ? html`<${Info} board=${board} item=${item} subs=${subs} viewId=${viewId} detail=${detail} />`
: tab === 'files' ? html`<${Files} item=${item} detail=${detail} reload=${reload} boardId=${boardId} />`
: html`<${Activity} detail=${detail} />`}
</div>
</aside>`;
}
// ─── Updates ─────────────────────────────────────────────
function Updates({ item, detail, reload, board }) {
const top = detail.updates.filter((u) => !u.parent_id);
const replies = {};
for (const u of detail.updates) if (u.parent_id) (replies[u.parent_id] ||= []).unshift(u);
return html`<div class="updates">
<${Composer} itemId=${item.id} onPosted=${reload} boardId=${board.board.id} />
${top.map((u) => html`<${UpdateCard} key=${u.id} u=${u} replies=${replies[u.id] || []} reload=${reload} itemId=${item.id} boardId=${board.board.id} />`)}
${!top.length ? html`<div class="empty-updates"><${Icon} name="message" size=${40} /><h3>No updates yet</h3>
<p class="muted">Share progress, mention a teammate with @, or attach files.</p></div>` : null}
</div>`;
}
function Composer({ itemId, parentId, onPosted, boardId, compact, autoFocus }) {
const [text, setText] = useState('');
const [files, setFiles] = useState([]);
const [mentions, setMentions] = useState([]);
const [suggest, setSuggest] = useState(null);
const [busy, setBusy] = useState(false);
const [open, setOpen] = useState(!compact || autoFocus);
const ta = useRef();
const fileIn = useRef();
const onInput = (e) => {
setText(e.target.value);
const upto = e.target.value.slice(0, e.target.selectionStart);
const m = upto.match(/(^|\s)@([\w .'-]{0,30})$/);
setSuggest(m ? m[2] : null);
};
const pick = (u) => {
const pos = ta.current.selectionStart;
const before = text.slice(0, pos).replace(/@([\w .'-]{0,30})$/, `@${u.name} `);
setText(before + text.slice(pos));
setMentions([...new Set([...mentions, u.id])]);
setSuggest(null);
setTimeout(() => { ta.current.focus(); ta.current.selectionStart = ta.current.selectionEnd = before.length; }, 0);
};
const submit = async () => {
if (!text.trim() && !files.length) return;
setBusy(true);
let body = text;
const used = mentions.filter((id) => text.includes('@' + state.usersById[id]?.name));
for (const id of used) body = body.split('@' + state.usersById[id].name).join(`@[${state.usersById[id].name}](${id})`);
const fd = new FormData();
fd.append('body', body);
fd.append('mentions', JSON.stringify(used));
if (parentId) fd.append('parent_id', parentId);
for (const f of files) fd.append('files', f);
try {
await api.post(`/api/items/${itemId}/updates`, fd);
setText(''); setFiles([]); setMentions([]); if (compact) setOpen(false);
onPosted(); loadBoard(boardId);
} catch (e) { toast(e.message, 'error'); }
setBusy(false);
};
if (!open) return html`<button class="reply-open" onClick=${() => setOpen(true)}>Write a reply…</button>`;
const sugg = suggest !== null ? state.users.filter((u) => u.active && u.name.toLowerCase().includes(suggest.toLowerCase())).slice(0, 6) : [];
return html`<div class="composer ${compact ? 'compact' : ''}"
onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { e.preventDefault(); setFiles([...files, ...e.dataTransfer.files]); }}>
<textarea ref=${ta} rows=${compact ? 2 : 3} placeholder=${parentId ? 'Write a reply… (@ to mention)' : 'Write an update and mention others with @'} value=${text}
onInput=${onInput} autofocus=${autoFocus}
onKeyDown=${(e) => {
if (sugg.length && (e.key === 'Enter' || e.key === 'Tab')) { e.preventDefault(); pick(sugg[0]); return; }
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit();
if (e.key === 'Escape') setSuggest(null);
}}
onPaste=${(e) => { const fs = [...(e.clipboardData?.files || [])]; if (fs.length) { e.preventDefault(); setFiles([...files, ...fs]); } }} />
${sugg.length ? html`<div class="mention-pop">${sugg.map((u) => html`<button key=${u.id} onMouseDown=${(e) => { e.preventDefault(); pick(u); }}><${Avatar} user=${u} size=${22} />${u.name}</button>`)}</div>` : null}
${files.length ? html`<div class="composer-files">${files.map((f, i) => html`<span key=${i} class="chip">${f.name}<button onClick=${() => setFiles(files.filter((_, j) => j !== i))}><${Icon} name="x" size=${10} /></button></span>`)}</div>` : null}
<div class="composer-actions">
<button class="icon-btn" title="Attach files" onClick=${() => fileIn.current.click()}><${Icon} name="paperclip" /></button>
<button class="icon-btn" title="Mention someone" onClick=${() => { setText(text + (text && !text.endsWith(' ') ? ' @' : '@')); setSuggest(''); ta.current.focus(); }}>@</button>
<input type="file" multiple hidden ref=${fileIn} onChange=${(e) => setFiles([...files, ...e.target.files])} />
<span class="spacer" />
${compact ? html`<button class="btn ghost sm" onClick=${() => setOpen(false)}>Cancel</button>` : null}
<button class="btn primary sm" disabled=${busy || (!text.trim() && !files.length)} onClick=${submit}>${busy ? 'Posting…' : parentId ? 'Reply' : 'Update'}</button>
</div>
</div>`;
}
function UpdateCard({ u, replies, reload, itemId, boardId, isReply }) {
const me = state.me;
const author = state.usersById[u.user_id];
const [editing, setEditing] = useState(false);
const [replying, setReplying] = useState(false);
const liked = u.likes?.includes(me.id);
const mine = u.user_id === me.id || me.role === 'admin';
const cardMenu = (e) => menu(e, [
{ label: 'Edit', icon: 'edit', onClick: () => setEditing(true) },
{ label: 'Copy text', icon: 'copy', onClick: () => navigator.clipboard?.writeText(u.body.replace(/@\[([^\]]+)\]\([\w-]+\)/g, '@$1')) },
{ label: 'Delete', icon: 'trash', danger: true, onClick: async () => { if (await confirmDialog({ title: 'Delete this update?' })) { await api.del(`/api/updates/${u.id}`); reload(); loadBoard(boardId); } } },
], { align: 'right' });
return html`<article class="update ${isReply ? 'reply' : ''}">
<header class="update-head">
<${Avatar} user=${author || { name: u.author_name || 'Imported', email: u.author_name || '' }} size=${isReply ? 28 : 34} />
<div class="update-meta"><strong>${author?.name || u.author_name || 'Imported'}</strong>
<span class="muted small" title=${new Date(u.created_at).toLocaleString()}>${timeAgo(u.created_at)}${u.edited_at ? ' · edited' : ''}</span></div>
${mine ? html`<button class="icon-btn" onClick=${cardMenu}><${Icon} name="dots" /></button>` : null}
</header>
${editing ? html`<${EditUpdate} u=${u} done=${() => { setEditing(false); reload(); }} />`
: html`<div class="update-body"><${RichText} text=${u.body} /></div>`}
${u.files?.length ? html`<div class="update-files">${u.files.map((f) => isImage(f)
? html`<a key=${f.id} href=${fileUrl(f)} target="_blank" rel="noopener"><img src=${fileUrl(f)} alt=${f.name} loading="lazy" /></a>`
: html`<a key=${f.id} class="file-chip" href=${fileUrl(f)} target="_blank" rel="noopener"><${Icon} name="file" /> ${f.name}</a>`)}</div>` : null}
<footer class="update-actions">
<button class=${liked ? 'on' : ''} onClick=${async () => { await api.post(`/api/updates/${u.id}/like`); reload(); }}>
<${Icon} name="thumb" /> ${liked ? 'Liked' : 'Like'}${u.likes?.length ? ` · ${u.likes.length}` : ''}</button>
${!isReply ? html`<button onClick=${() => setReplying(true)}><${Icon} name="reply" /> Reply</button>` : null}
</footer>
${replies.length || replying ? html`<div class="replies">
${replies.map((r) => html`<${UpdateCard} key=${r.id} u=${r} replies=${[]} reload=${reload} itemId=${itemId} boardId=${boardId} isReply=${true} />`)}
${replying ? html`<${Composer} itemId=${itemId} parentId=${u.id} onPosted=${() => { setReplying(false); reload(); }} boardId=${boardId} compact=${true} autoFocus=${true} />` : null}
</div>` : null}
</article>`;
}
function EditUpdate({ u, done }) {
const [v, setV] = useState(u.body);
return html`<div class="composer"><textarea rows="4" value=${v} onInput=${(e) => setV(e.target.value)} autofocus />
<div class="composer-actions"><span class="spacer" /><button class="btn ghost sm" onClick=${done}>Cancel</button>
<button class="btn primary sm" onClick=${async () => { await api.patch(`/api/updates/${u.id}`, { body: v }); done(); }}>Save</button></div></div>`;
}
// ─── Info (all columns + subitems) ───────────────────────
function Info({ board, item, subs, viewId, detail }) {
const boardId = board.board.id;
const [newSub, setNewSub] = useState('');
const creator = state.usersById[item.created_by];
return html`<div class="info">
<div class="info-grid">
${board.columns.map((c) => html`<div key=${c.id} class="info-row">
<div class="info-label"><span class="type-dot" style=${{ background: COLUMN_TYPES[c.type]?.color }}><${Icon} name=${COLUMN_TYPES[c.type]?.icon || 'edit'} size=${11} /></span>${c.title}</div>
<div class="info-value tc-${c.type}"><${Cell} board=${board} col=${c} item=${item} /></div>
</div>`)}
</div>
${!item.parent_id ? html`<div class="info-subitems">
<h4>Subitems <span class="muted">${subs.length}</span></h4>
${subs.map((s) => {
const status = board.columns.find((c) => c.type === 'status');
return html`<div key=${s.id} class="sub-line">
<button class="link" onClick=${() => navigate(boardPath(boardId, { viewId, itemId: s.id }))}>${s.name}</button>
${status ? html`<div class="sub-status cell-status"><${Cell} board=${board} col=${status} item=${s} /></div>` : null}
</div>`;
})}
<input class="input sm" placeholder="+ Add subitem" value=${newSub} onInput=${(e) => setNewSub(e.target.value)}
onKeyDown=${async (e) => { if (e.key === 'Enter' && newSub.trim()) { const n = newSub.trim(); setNewSub(''); await A.createItem(boardId, { parent_id: item.id, name: n }); } }} />
</div>` : null}
<p class="muted small info-foot">Created ${creator ? `by ${creator.name} ` : ''}on ${fmtDate(ymd(new Date(item.created_at)), { year: true })} · Updated ${timeAgo(item.updated_at)} ago</p>
</div>`;
}
// ─── Files ───────────────────────────────────────────────
function Files({ item, detail, reload, boardId }) {
const input = useRef();
const [busy, setBusy] = useState(false);
const upload = async (list) => {
if (!list?.length) return;
setBusy(true);
const fd = new FormData();
for (const f of list) fd.append('files', f);
try { await api.post(`/api/items/${item.id}/files`, fd); reload(); loadBoard(boardId); } catch (e) { toast(e.message, 'error'); }
setBusy(false);
};
return html`<div class="files-tab" onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { e.preventDefault(); upload(e.dataTransfer.files); }}>
<button class="dropzone" onClick=${() => input.current.click()}><${Icon} name="upload" size=${24} /><span>${busy ? 'Uploading…' : 'Drag files here or click to upload'}</span></button>
<input type="file" multiple hidden ref=${input} onChange=${(e) => upload(e.target.files)} />
<div class="file-grid">
${detail.files.map((f) => html`<div key=${f.id} class="file-card">
<a href=${fileUrl(f)} target="_blank" rel="noopener" class="file-thumb">${isImage(f) ? html`<img src=${fileUrl(f)} alt="" loading="lazy" />` : html`<span class="file-ext big">${(f.name.split('.').pop() || '').slice(0, 4).toUpperCase()}</span>`}</a>
<div class="file-info"><span class="file-name" title=${f.name}>${f.name}</span><span class="muted small">${fmtSize(f.size)} · ${timeAgo(f.created_at)}</span></div>
<div class="file-actions">
<a class="icon-btn" href=${fileUrl(f, true)} title="Download"><${Icon} name="download" /></a>
<button class="icon-btn" title="Delete" onClick=${async () => { if (await confirmDialog({ title: `Delete ${f.name}?` })) { await api.del(`/api/files/${f.id}`); reload(); loadBoard(boardId); } }}><${Icon} name="trash" /></button>
</div>
</div>`)}
</div>
</div>`;
}
// ─── Activity ────────────────────────────────────────────
export function activityText(a) {
const d = a.data || {};
switch (a.action) {
case 'item_created': return 'created this item';
case 'renamed': return html`renamed from <s>${d.from}</s> to <b>${d.to}</b>`;
case 'moved_group': return html`moved to group <b>${d.to}</b>`;
case 'moved_board': return 'moved from another board';
case 'value': return html`<span class="act-col">${d.column}</span> ${d.from ? html`<span class="act-from">${d.from}</span> → ` : ''}<span class="act-to">${d.to || '(cleared)'}</span>`;
case 'update_posted': return 'posted an update';
case 'file_added': return html`added ${(d.names || []).join(', ')}`;
case 'archived': return 'archived this item';
default: return a.action.replace(/_/g, ' ');
}
}
function Activity({ detail }) {
return html`<div class="activity">
${detail.activity.map((a) => html`<div key=${a.id} class="act-row">
<span class="muted small act-time">${timeAgo(a.created_at)}</span>
<${Avatar} user=${state.usersById[a.user_id] || (a.user_id ? null : { name: 'Automation', email: 'auto' })} size=${24} />
<span class="act-who">${state.usersById[a.user_id]?.name || 'Automation'}</span>
<span class="act-what">${activityText(a)}</span>
</div>`)}
${!detail.activity.length ? html`<p class="muted pad">No activity yet.</p>` : null}
</div>`;
}