Waters Boards: self-hosted work management to replace monday.com
Deploy Waters Boards to NAS / deploy (push) Canceled after 0s
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:
@@ -0,0 +1,312 @@
|
||||
// Main table view: colored groups, inline-editable cells, drag & drop, column menus,
|
||||
// subitems, footers, multi-select bulk actions. Becomes a card list on phones.
|
||||
import { html, useState, useRef, useEffect } from '../vendor/preact-htm.mjs';
|
||||
import { api } from './api.js';
|
||||
import { state, update, navigate, boardPath, mutateBoard, loadBoard } from './store.js';
|
||||
import { Icon, InlineEdit, menu, openPopover, closePopover, ColorPicker, promptDialog, AvatarStack, textOn, fmtDate, fmtRange, openModal } from './ui.js';
|
||||
import { Cell, Summary, COLUMN_TYPES, colWidth, labelOf } from './cells.js';
|
||||
import * as A from './actions.js';
|
||||
import { ColumnSettingsModal } from './boardModals.js';
|
||||
|
||||
const NAME_W = 340;
|
||||
const selection = new Set();
|
||||
let dragItem = null;
|
||||
let dragGroup = null;
|
||||
|
||||
function setSel(fn) { fn(selection); update(); }
|
||||
|
||||
export function TableView({ board, view, items, subs, matches, hidden, filter, mobile }) {
|
||||
const boardId = board.board.id;
|
||||
const cols = board.columns.filter((c) => !hidden.includes(c.id));
|
||||
const groups = [...board.groups].sort((a, b) => a.position - b.position);
|
||||
const byGroup = {};
|
||||
for (const it of items) (byGroup[it.group_id] ||= []).push(it);
|
||||
const [expanded, setExpanded] = useState({});
|
||||
const filtering = !!matches || !!filter.sort;
|
||||
useEffect(() => () => selection.clear(), [boardId]);
|
||||
|
||||
if (mobile) return html`<${MobileList} board=${board} groups=${groups} byGroup=${byGroup} subs=${subs} view=${view} />`;
|
||||
|
||||
const totalW = NAME_W + cols.reduce((a, c) => a + colWidth(c), 0) + 60;
|
||||
return html`<div class="table-scroll">
|
||||
<div class="table" style=${{ minWidth: totalW }}>
|
||||
${groups.map((g) => {
|
||||
const gItems = byGroup[g.id] || [];
|
||||
if (filtering && !gItems.length) return null;
|
||||
return html`<${Group} key=${g.id} board=${board} group=${g} items=${gItems} cols=${cols} subs=${subs} view=${view}
|
||||
expanded=${expanded} setExpanded=${setExpanded} totalW=${totalW} filtering=${filtering} />`;
|
||||
})}
|
||||
<button class="add-group-btn" onClick=${() => A.addGroup(boardId, { top: false })}><${Icon} name="plus" /> Add new group</button>
|
||||
</div>
|
||||
${selection.size ? html`<${BulkBar} board=${board} />` : null}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function Group({ board, group: g, items, cols, subs, view, expanded, setExpanded, totalW, filtering }) {
|
||||
const boardId = board.board.id;
|
||||
const [dropOver, setDropOver] = useState(false);
|
||||
const allSel = items.length && items.every((i) => selection.has(i.id));
|
||||
const groupMenu = (e) => menu(e, [
|
||||
{ label: g.collapsed ? 'Expand group' : 'Collapse group', icon: g.collapsed ? 'chevDown' : 'chevRight', onClick: () => A.patchGroup(boardId, g.id, { collapsed: !g.collapsed }) },
|
||||
{ label: 'Collapse all groups', icon: 'chevUp', onClick: () => board.groups.forEach((x) => A.patchGroup(boardId, x.id, { collapsed: true })) },
|
||||
{ label: 'Expand all groups', icon: 'chevDown', onClick: () => board.groups.forEach((x) => A.patchGroup(boardId, x.id, { collapsed: false })) },
|
||||
{ divider: true },
|
||||
{ label: 'Add group', icon: 'plus', onClick: () => A.addGroup(boardId, { top: false }) },
|
||||
{ label: 'Rename group', icon: 'edit', onClick: async () => { const t = await promptDialog({ title: 'Rename group', value: g.title }); if (t) A.patchGroup(boardId, g.id, { title: t }); } },
|
||||
{ label: 'Change group color', icon: 'gear', keepOpen: true, onClick: (ev) => openPopover(ev.currentTarget, ColorPicker, { value: g.color, onPick: (c) => A.patchGroup(boardId, g.id, { color: c }) }) },
|
||||
{ label: 'Duplicate group', icon: 'copy', onClick: async () => { await api.post(`/api/groups/${g.id}/duplicate`); loadBoard(boardId); } },
|
||||
{ label: 'Move group up', icon: 'chevUp', onClick: () => moveGroup(board, g, -1) },
|
||||
{ label: 'Move group down', icon: 'chevDown', onClick: () => moveGroup(board, g, 1) },
|
||||
{ label: 'Select all items', icon: 'check', onClick: () => setSel((s) => items.forEach((i) => s.add(i.id))) },
|
||||
{ divider: true },
|
||||
{ label: 'Delete group', icon: 'trash', danger: true, onClick: () => A.deleteGroup(boardId, g.id) },
|
||||
]);
|
||||
const onGroupDragStart = (e) => { dragGroup = g.id; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', g.id); };
|
||||
const onDrop = (e) => {
|
||||
e.preventDefault(); setDropOver(false);
|
||||
if (dragGroup && dragGroup !== g.id) {
|
||||
const sorted = [...board.groups].sort((a, b) => a.position - b.position).filter((x) => x.id !== dragGroup);
|
||||
const idx = sorted.findIndex((x) => x.id === g.id);
|
||||
A.patchGroup(boardId, dragGroup, { position: A.between(sorted[idx - 1]?.position, g.position) });
|
||||
dragGroup = null;
|
||||
} else if (dragItem && !dragItem.parent_id && dragItem.group_id !== g.id) {
|
||||
const last = items[items.length - 1];
|
||||
A.moveItem(boardId, dragItem.id, { group_id: g.id, position: (last?.position ?? 0) + 1 });
|
||||
dragItem = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (g.collapsed) {
|
||||
return html`<div class="group collapsed" style=${{ '--g': g.color }} onDragOver=${(e) => { e.preventDefault(); setDropOver(true); }} onDragLeave=${() => setDropOver(false)} onDrop=${onDrop}>
|
||||
<div class="group-collapsed-row ${dropOver ? 'drop' : ''}" style=${{ width: totalW }}>
|
||||
<div class="gc-name" style=${{ width: NAME_W }} draggable="true" onDragStart=${onGroupDragStart}>
|
||||
<button class="icon-btn" onClick=${() => A.patchGroup(boardId, g.id, { collapsed: false })}><${Icon} name="chevRight" /></button>
|
||||
<div><div class="group-title" style=${{ color: g.color }}>${g.title}</div><div class="muted small">${items.length} ${items.length === 1 ? board.board.item_label : 'items'}</div></div>
|
||||
<button class="icon-btn group-menu-btn" onClick=${groupMenu}><${Icon} name="dots" /></button>
|
||||
</div>
|
||||
${cols.map((c) => html`<div key=${c.id} class="gc-cell" style=${{ width: colWidth(c) }}><div class="gc-label muted small">${c.title}</div><${Summary} board=${board} col=${c} items=${items} /></div>`)}
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
return html`<section class="group" style=${{ '--g': g.color }}>
|
||||
<div class="group-head">
|
||||
<button class="icon-btn group-menu-btn" onClick=${groupMenu}><${Icon} name="dots" /></button>
|
||||
<button class="icon-btn" onClick=${() => A.patchGroup(boardId, g.id, { collapsed: true })} style=${{ color: g.color }}><${Icon} name="chevDown" /></button>
|
||||
<span class="group-title" style=${{ color: g.color }} draggable="true" onDragStart=${onGroupDragStart}>
|
||||
<${InlineEdit} value=${g.title} onSave=${(t) => t && A.patchGroup(boardId, g.id, { title: t })} /></span>
|
||||
<span class="muted small group-count">${items.length} ${items.length === 1 ? board.board.item_label : 'items'}</span>
|
||||
</div>
|
||||
<div class="group-table ${dropOver ? 'drop' : ''}" onDragOver=${(e) => { if (dragGroup || (dragItem && !dragItem.parent_id)) { e.preventDefault(); setDropOver(true); } }}
|
||||
onDragLeave=${(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDropOver(false); }} onDrop=${onDrop}>
|
||||
<div class="trow thead">
|
||||
<div class="tcell name-cell sticky" style=${{ width: NAME_W }}>
|
||||
<span class="row-bar first" />
|
||||
<label class="row-check"><input type="checkbox" checked=${allSel} onChange=${(e) => setSel((s) => items.forEach((i) => e.target.checked ? s.add(i.id) : s.delete(i.id)))} /></label>
|
||||
<span class="th-title">${board.board.item_label}</span>
|
||||
</div>
|
||||
${cols.map((c) => html`<${ColumnHeader} key=${c.id} board=${board} col=${c} />`)}
|
||||
<div class="tcell add-col"><button class="icon-btn" onClick=${(e) => addColumnMenu(e, boardId)} title="Add column"><${Icon} name="plus" /></button></div>
|
||||
</div>
|
||||
${items.map((it) => html`<${Row} key=${it.id} board=${board} item=${it} cols=${cols} view=${view} group=${g}
|
||||
subs=${subs[it.id] || []} expanded=${!!expanded[it.id]} toggle=${() => setExpanded({ ...expanded, [it.id]: !expanded[it.id] })} siblings=${items} />`)}
|
||||
<${AddRow} board=${board} group=${g} cols=${cols} />
|
||||
<div class="trow tfoot">
|
||||
<div class="tcell name-cell sticky" style=${{ width: NAME_W }}><span class="row-bar last" /></div>
|
||||
${cols.map((c) => html`<div key=${c.id} class="tcell" style=${{ width: colWidth(c) }}><${Summary} board=${board} col=${c} items=${items} /></div>`)}
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function moveGroup(board, g, dir) {
|
||||
const sorted = [...board.groups].sort((a, b) => a.position - b.position);
|
||||
const i = sorted.findIndex((x) => x.id === g.id);
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= sorted.length) return;
|
||||
const other = sorted[j];
|
||||
const pos = dir < 0 ? A.between(sorted[j - 1]?.position, other.position) : A.between(other.position, sorted[j + 1]?.position);
|
||||
A.patchGroup(board.board.id, g.id, { position: pos });
|
||||
}
|
||||
|
||||
export function addColumnMenu(e, boardId, after) {
|
||||
menu(e, Object.entries(COLUMN_TYPES).map(([type, t]) => ({
|
||||
label: t.label, icon: t.icon, onClick: () => A.addColumn(boardId, type, { after }),
|
||||
})), { class: 'col-type-menu' });
|
||||
}
|
||||
|
||||
function ColumnHeader({ board, col }) {
|
||||
const boardId = board.board.id;
|
||||
const w = colWidth(col);
|
||||
const startResize = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const x0 = e.clientX;
|
||||
const move = (ev) => mutateBoard(boardId, () => { col.width = Math.max(70, Math.min(600, w + ev.clientX - x0)); });
|
||||
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); A.patchColumn(boardId, col.id, { width: col.width }); };
|
||||
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
|
||||
};
|
||||
const colMenu = (e) => menu(e, [
|
||||
{ label: 'Settings', icon: 'gear', onClick: () => openModal(ColumnSettingsModal, { boardId, colId: col.id }) },
|
||||
{ label: 'Rename', icon: 'edit', onClick: async () => { const t = await promptDialog({ title: 'Rename column', value: col.title }); if (t) A.patchColumn(boardId, col.id, { title: t }); } },
|
||||
{ label: 'Sort ascending', icon: 'sort', onClick: async () => { const { getFilter } = await import('./board.js'); Object.assign(getFilter(boardId), { sort: { colId: col.id, dir: 'asc' } }); update(); } },
|
||||
{ label: 'Sort descending', icon: 'sort', onClick: async () => { const { getFilter } = await import('./board.js'); Object.assign(getFilter(boardId), { sort: { colId: col.id, dir: 'desc' } }); update(); } },
|
||||
{ label: 'Add column to the right', icon: 'plus', keepOpen: true, onClick: (ev) => addColumnMenu(ev, boardId, col.id) },
|
||||
{ label: 'Duplicate column', icon: 'copy', onClick: async () => {
|
||||
const r = await api.post(`/api/boards/${boardId}/columns`, { type: col.type, title: `${col.title} copy`, settings: col.settings, after: col.id });
|
||||
for (const it of board.items) if (it.vals[col.id] != null && col.type !== 'files') await api.patch(`/api/items/${it.id}`, { vals: { [r.id]: it.vals[col.id] } });
|
||||
loadBoard(boardId);
|
||||
} },
|
||||
{ label: 'Move left', icon: 'chevLeft', onClick: () => moveCol(board, col, -1) },
|
||||
{ label: 'Move right', icon: 'chevRight', onClick: () => moveCol(board, col, 1) },
|
||||
{ divider: true },
|
||||
{ label: 'Delete column', icon: 'trash', danger: true, onClick: () => A.deleteColumn(boardId, col.id) },
|
||||
]);
|
||||
return html`<div class="tcell th" style=${{ width: w }} onDblClick=${async () => { const t = await promptDialog({ title: 'Rename column', value: col.title }); if (t) A.patchColumn(boardId, col.id, { title: t }); }}>
|
||||
<span class="th-title" title=${col.title}>${col.title}</span>
|
||||
<button class="icon-btn th-menu" onClick=${colMenu}><${Icon} name="dots" size=${14} /></button>
|
||||
<span class="col-resize" onPointerDown=${startResize} />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function moveCol(board, col, dir) {
|
||||
const sorted = [...board.columns].sort((a, b) => a.position - b.position);
|
||||
const i = sorted.findIndex((c) => c.id === col.id);
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= sorted.length) return;
|
||||
const pos = dir < 0 ? A.between(sorted[j - 1]?.position, sorted[j].position) : A.between(sorted[j].position, sorted[j + 1]?.position);
|
||||
mutateBoard(board.board.id, (b) => { col.position = pos; b.columns.sort((a, z) => a.position - z.position); });
|
||||
A.patchColumn(board.board.id, col.id, { position: pos });
|
||||
}
|
||||
|
||||
function Row({ board, item, cols, view, group, subs, expanded, toggle, siblings, isSub }) {
|
||||
const boardId = board.board.id;
|
||||
const [dropPos, setDropPos] = useState(null);
|
||||
const nUpdates = board.updateCounts?.[item.id] || 0;
|
||||
const open = () => navigate(boardPath(boardId, { viewId: view?.id, itemId: item.id }));
|
||||
const sel = selection.has(item.id);
|
||||
const active = state.route.itemId === item.id;
|
||||
const rowMenu = (e) => menu(e, [
|
||||
{ label: 'Open', icon: 'expand', onClick: open },
|
||||
!isSub && { label: 'Add subitem', icon: 'subitem', onClick: async () => { if (!expanded) toggle(); await A.createItem(boardId, { parent_id: item.id, name: 'Subitem' }); } },
|
||||
{ label: 'Duplicate', icon: 'copy', onClick: () => A.duplicateItems(boardId, [item.id]) },
|
||||
!isSub && { label: 'Move to group', icon: 'move', keepOpen: true, onClick: (ev) => menu(ev, board.groups.map((g) => ({ label: g.title, onClick: () => A.moveItemsToGroup(boardId, [item.id], g.id) }))) },
|
||||
!isSub && { 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) }))) },
|
||||
{ label: 'Copy link', icon: 'link', onClick: () => navigator.clipboard?.writeText(location.origin + boardPath(boardId, { itemId: item.id })) },
|
||||
{ divider: true },
|
||||
{ label: 'Archive', icon: 'archive', onClick: () => A.archiveItems(boardId, [item.id]) },
|
||||
{ label: 'Delete', icon: 'trash', danger: true, onClick: () => A.deleteItems(boardId, [item.id]) },
|
||||
]);
|
||||
const onDrop = (e) => {
|
||||
if (!dragItem || dragItem.id === item.id) return;
|
||||
if (!!dragItem.parent_id !== !!item.parent_id || (isSub && dragItem.parent_id !== item.parent_id)) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const idx = siblings.findIndex((s) => s.id === item.id);
|
||||
const pos = dropPos === 'above' ? A.between(siblings[idx - 1]?.position, item.position) : A.between(item.position, siblings[idx + 1]?.position);
|
||||
A.moveItem(boardId, dragItem.id, isSub ? { position: pos } : { group_id: group.id, position: pos });
|
||||
dragItem = null; setDropPos(null);
|
||||
};
|
||||
return html`
|
||||
<div class="trow item-row ${sel ? 'selected' : ''} ${active ? 'active' : ''} ${isSub ? 'sub' : ''} ${dropPos ? 'drop-' + dropPos : ''}"
|
||||
onContextMenu=${rowMenu}
|
||||
onDragOver=${(e) => { if (dragItem && dragItem.id !== item.id) { e.preventDefault(); const r = e.currentTarget.getBoundingClientRect(); setDropPos(e.clientY < r.top + r.height / 2 ? 'above' : 'below'); } }}
|
||||
onDragLeave=${() => setDropPos(null)} onDrop=${onDrop}>
|
||||
<div class="tcell name-cell sticky" style=${{ width: NAME_W }} draggable="true"
|
||||
onDragStart=${(e) => { dragItem = item; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', item.id); }}
|
||||
onDragEnd=${() => { dragItem = null; }}>
|
||||
<button class="icon-btn row-menu" onClick=${rowMenu}><${Icon} name="dots" size=${14} /></button>
|
||||
<span class="row-bar" />
|
||||
<label class="row-check" onClick=${(e) => e.stopPropagation()}><input type="checkbox" checked=${sel} onChange=${(e) => setSel((s) => e.target.checked ? s.add(item.id) : s.delete(item.id))} /></label>
|
||||
${isSub ? html`<span class="sub-indent" />` : html`<button class="icon-btn sub-toggle ${subs.length ? 'has' : ''} ${expanded ? 'open' : ''}" onClick=${toggle} title=${subs.length ? `${subs.length} subitems` : 'Add subitem'}>
|
||||
<${Icon} name=${expanded ? 'chevDown' : 'chevRight'} size=${14} />${subs.length ? html`<span class="sub-count">${subs.length}</span>` : null}</button>`}
|
||||
<span class="item-name"><${InlineEdit} value=${item.name} onSave=${(n) => n && A.renameItem(boardId, item.id, n)} /></span>
|
||||
<button class="icon-btn open-item" onClick=${open} title="Open"><${Icon} name="expand" size=${13} /></button>
|
||||
<button class="icon-btn updates-btn ${nUpdates ? 'has' : ''}" onClick=${open} title="Updates">
|
||||
<${Icon} name=${nUpdates ? 'message' : 'messagePlus'} size=${18} />${nUpdates ? html`<span class="badge">${nUpdates}</span>` : null}</button>
|
||||
</div>
|
||||
${cols.map((c) => html`<div key=${c.id} class="tcell tc-${c.type}" style=${{ width: colWidth(c) }}><${Cell} board=${board} col=${c} item=${item} /></div>`)}
|
||||
<div class="tcell add-col" />
|
||||
</div>
|
||||
${expanded && !isSub ? html`<div class="subitems">
|
||||
${subs.sort((a, b) => a.position - b.position).map((s) => html`<${Row} key=${s.id} board=${board} item=${s} cols=${cols} view=${view} group=${group} subs=${[]} isSub=${true} siblings=${subs} />`)}
|
||||
<${AddRow} board=${board} group=${group} cols=${cols} parent=${item} />
|
||||
</div>` : null}`;
|
||||
}
|
||||
|
||||
function AddRow({ board, group, cols, parent }) {
|
||||
const [v, setV] = useState('');
|
||||
const ref = useRef();
|
||||
const add = async () => {
|
||||
const name = v.trim();
|
||||
if (!name) return;
|
||||
setV('');
|
||||
await A.createItem(board.board.id, parent ? { parent_id: parent.id, name } : { group_id: group.id, name });
|
||||
ref.current?.focus();
|
||||
};
|
||||
return html`<div class="trow add-row ${parent ? 'sub' : ''}">
|
||||
<div class="tcell name-cell sticky" style=${{ width: NAME_W }}>
|
||||
<span class="row-bar faded" />
|
||||
<span class="row-check-spacer" />
|
||||
${parent ? html`<span class="sub-indent" />` : null}
|
||||
<input ref=${ref} class="add-input" placeholder=${parent ? '+ Add subitem' : `+ Add ${board.board.item_label.toLowerCase()}`} value=${v}
|
||||
onInput=${(e) => setV(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') add(); if (e.key === 'Escape') { setV(''); e.target.blur(); } }} onBlur=${add} />
|
||||
</div>
|
||||
<div class="tcell filler" style=${{ width: cols.reduce((a, c) => a + colWidth(c), 0) }} />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function BulkBar({ board }) {
|
||||
const boardId = board.board.id;
|
||||
const ids = [...selection].filter((id) => board.items.some((i) => i.id === id));
|
||||
if (!ids.length) { selection.clear(); return null; }
|
||||
const done = () => setSel((s) => s.clear());
|
||||
const statusCols = board.columns.filter((c) => c.type === 'status');
|
||||
return html`<div class="bulk-bar">
|
||||
<span class="bulk-count">${ids.length}</span><span class="bulk-label">${ids.length === 1 ? 'Item' : 'Items'} selected</span>
|
||||
<button class="bulk-act" onClick=${() => { A.duplicateItems(boardId, ids); done(); }}><${Icon} name="copy" /><span>Duplicate</span></button>
|
||||
${statusCols.length ? html`<button class="bulk-act" onClick=${(e) => menu(e, statusCols.flatMap((c) => [{ label: c.title, disabled: true },
|
||||
...(c.settings.labels || []).map((l) => ({ label: l.name, onClick: async () => { await api.post('/api/items/bulk', { ids, action: 'set', vals: { [c.id]: l.id } }); loadBoard(boardId); } }))]))}>
|
||||
<${Icon} name="check" /><span>Set status</span></button>` : null}
|
||||
<button class="bulk-act" onClick=${(e) => menu(e, board.groups.map((g) => ({ label: g.title, onClick: () => { A.moveItemsToGroup(boardId, ids, g.id); done(); } })))}><${Icon} name="move" /><span>Move to</span></button>
|
||||
<button class="bulk-act" onClick=${() => { A.archiveItems(boardId, ids); done(); }}><${Icon} name="archive" /><span>Archive</span></button>
|
||||
<button class="bulk-act danger" onClick=${async () => { if (await A.deleteItems(boardId, ids)) done(); }}><${Icon} name="trash" /><span>Delete</span></button>
|
||||
<button class="icon-btn bulk-close" onClick=${done}><${Icon} name="x" /></button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ─── Phone layout: groups of cards ───────────────────────
|
||||
function MobileList({ board, groups, byGroup, subs, view }) {
|
||||
const boardId = board.board.id;
|
||||
const status = board.columns.find((c) => c.type === 'status');
|
||||
const people = board.columns.find((c) => c.type === 'people');
|
||||
const date = board.columns.find((c) => c.type === 'date' || c.type === 'timeline');
|
||||
const [adding, setAdding] = useState(null);
|
||||
return html`<div class="mobile-list">
|
||||
${groups.map((g) => {
|
||||
const items = byGroup[g.id] || [];
|
||||
return html`<section key=${g.id} class="m-group" style=${{ '--g': g.color }}>
|
||||
<button class="m-group-head" onClick=${() => A.patchGroup(boardId, g.id, { collapsed: !g.collapsed })}>
|
||||
<${Icon} name=${g.collapsed ? 'chevRight' : 'chevDown'} /><span style=${{ color: g.color }}>${g.title}</span><span class="muted small">${items.length}</span>
|
||||
</button>
|
||||
${!g.collapsed ? html`
|
||||
${items.map((it) => {
|
||||
const l = status && labelOf(status, it.vals[status.id]);
|
||||
const dv = date && it.vals[date.id];
|
||||
const n = (subs[it.id] || []).length;
|
||||
return html`<button key=${it.id} class="m-card" onClick=${() => navigate(boardPath(boardId, { viewId: view?.id, itemId: it.id }))}>
|
||||
<div class="m-card-top"><span class="m-name">${it.name}</span>${people ? html`<${AvatarStack} ids=${it.vals[people.id] || []} size=${24} />` : null}</div>
|
||||
<div class="m-card-meta">
|
||||
${status ? html`<span class="m-status" style=${{ background: l?.color || 'var(--status-empty)', color: l ? textOn(l.color) : 'var(--muted)' }}>${l?.name || status.title}</span>` : null}
|
||||
${dv ? html`<span class="m-date"><${Icon} name="calendar" size=${13} /> ${date.type === 'date' ? fmtDate(dv) : fmtRange(dv)}</span>` : null}
|
||||
${n ? html`<span class="muted small"><${Icon} name="subitem" size=${13} /> ${n}</span>` : null}
|
||||
${board.updateCounts?.[it.id] ? html`<span class="muted small"><${Icon} name="message" size=${13} /> ${board.updateCounts[it.id]}</span>` : null}
|
||||
</div></button>`;
|
||||
})}
|
||||
${adding === g.id ? html`<input class="input m-add-input" placeholder="Name" autofocus
|
||||
onKeyDown=${async (e) => { if (e.key === 'Enter' && e.target.value.trim()) { const name = e.target.value.trim(); e.target.value = ''; await A.createItem(boardId, { group_id: g.id, name }); } if (e.key === 'Escape') setAdding(null); }}
|
||||
onBlur=${() => setAdding(null)} />`
|
||||
: html`<button class="m-add" onClick=${() => setAdding(g.id)}><${Icon} name="plus" /> Add ${board.board.item_label.toLowerCase()}</button>`}
|
||||
` : null}
|
||||
</section>`;
|
||||
})}
|
||||
<button class="btn ghost m-add-group" onClick=${() => A.addGroup(boardId, { top: false })}><${Icon} name="plus" /> Add group</button>
|
||||
</div>`;
|
||||
}
|
||||
Reference in New Issue
Block a user