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,96 @@
|
||||
// Board-level dialogs: activity log, CSV import, column settings.
|
||||
import { html, useState, useEffect } from '../vendor/preact-htm.mjs';
|
||||
import { api } from './api.js';
|
||||
import { state, useStore, loadBoard, navigate, boardPath, toast } from './store.js';
|
||||
import { Icon, Avatar, ModalHeader, closeModal, timeAgo, Spinner, LABEL_COLORS, textOn } from './ui.js';
|
||||
import { COLUMN_TYPES } from './cells.js';
|
||||
import { activityText } from './item.js';
|
||||
import * as A from './actions.js';
|
||||
|
||||
export function ActivityModal({ boardId }) {
|
||||
const [rows, setRows] = useState(null);
|
||||
const [who, setWho] = useState('');
|
||||
useEffect(() => { api.get(`/api/boards/${boardId}/activity`).then(setRows); }, [boardId]);
|
||||
const shown = (rows || []).filter((a) => !who || a.user_id === who);
|
||||
const people = [...new Set((rows || []).map((a) => a.user_id).filter(Boolean))];
|
||||
return html`<div class="dialog"><${ModalHeader} title="Activity log" sub=${state.boardData[boardId]?.board.name} />
|
||||
<div class="row gap wrap">
|
||||
<button class="chip-btn ${!who ? 'on' : ''}" onClick=${() => setWho('')}>Everyone</button>
|
||||
${people.map((u) => html`<button key=${u} class="chip-btn ${who === u ? 'on' : ''}" onClick=${() => setWho(u)}><${Avatar} user=${state.usersById[u]} size=${20} /> ${state.usersById[u]?.name}</button>`)}
|
||||
</div>
|
||||
<div class="activity scroll-y">
|
||||
${!rows ? html`<${Spinner} />` : shown.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} />
|
||||
${a.item_name ? html`<button class="link act-item" onClick=${() => { closeModal(); navigate(boardPath(boardId, { itemId: a.item_id })); }}>${a.item_name}</button>` : html`<span class="act-who">${state.usersById[a.user_id]?.name || 'Automation'}</span>`}
|
||||
<span class="act-what">${a.action === 'board_created' ? 'created the board' : a.action === 'item_deleted' ? html`deleted <b>${a.data?.name}</b>` : a.action.startsWith('column_') || a.action === 'group_deleted' ? html`${a.action.replace('_', ' ')} <b>${a.data?.title}</b>` : activityText(a)}</span>
|
||||
</div>`)}
|
||||
${rows && !shown.length ? html`<p class="muted pad">Nothing yet.</p>` : null}
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
export function ImportCsvModal({ boardId }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pick = async (file) => {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const r = await api.post(`/api/boards/${boardId}/import.csv`, text, { contentType: 'text/csv' });
|
||||
toast(`Imported ${r.created} items`); closeModal(); loadBoard(boardId);
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
setBusy(false);
|
||||
};
|
||||
return html`<div class="dialog"><${ModalHeader} title="Import items from CSV" />
|
||||
<p class="muted">The first row must be column headers. A <b>Name</b> (or first) column becomes the item name, a <b>Group</b> column picks the group,
|
||||
and other headers fill columns with the same title — new columns are created as text. Export from Excel or Google Sheets with "Save as CSV".</p>
|
||||
<label class="dropzone"><${Icon} name="upload" size=${24} /><span>${busy ? 'Importing…' : 'Choose a .csv file'}</span>
|
||||
<input type="file" accept=".csv,text/csv" hidden onChange=${(e) => pick(e.target.files[0])} /></label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function ColumnSettingsModal({ boardId, colId }) {
|
||||
const s = useStore();
|
||||
const col = s.boardData[boardId]?.columns.find((c) => c.id === colId);
|
||||
const [title, setTitle] = useState(col?.title || '');
|
||||
const [type, setType] = useState(col?.type);
|
||||
const [settings, setSettings] = useState(() => JSON.parse(JSON.stringify(col?.settings || {})));
|
||||
if (!col) return null;
|
||||
const labels = settings.labels || [];
|
||||
const setLabels = (l) => setSettings({ ...settings, labels: l });
|
||||
const save = async () => {
|
||||
const patch = {};
|
||||
if (title.trim() && title !== col.title) patch.title = title.trim();
|
||||
if (type !== col.type) patch.type = type;
|
||||
else patch.settings = settings;
|
||||
await A.patchColumn(boardId, colId, patch);
|
||||
if (patch.type) await loadBoard(boardId);
|
||||
closeModal();
|
||||
};
|
||||
return html`<div class="dialog"><${ModalHeader} title="Column settings" />
|
||||
<label class="field-label">Title</label><input class="input" value=${title} onInput=${(e) => setTitle(e.target.value)} />
|
||||
<label class="field-label">Type</label>
|
||||
<select class="input" value=${type} onChange=${(e) => setType(e.target.value)}>
|
||||
${Object.entries(COLUMN_TYPES).filter(([k]) => k !== 'priority').map(([k, t]) => html`<option value=${k}>${t.label}</option>`)}
|
||||
</select>
|
||||
${type !== col.type ? html`<p class="warn-note">Changing the type keeps values that convert to text cleanly and clears the rest.</p>` : null}
|
||||
${type === col.type && (col.type === 'status' || col.type === 'dropdown') ? html`
|
||||
<label class="field-label">Labels</label>
|
||||
<div class="label-editor">
|
||||
${labels.map((l, i) => html`<div key=${l.id} class="label-row">
|
||||
<input type="color" class="swatch-input" value=${l.color} onInput=${(e) => setLabels(labels.map((x, j) => (j === i ? { ...x, color: e.target.value } : x)))} />
|
||||
<input class="input sm" value=${l.name} onInput=${(e) => setLabels(labels.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))} />
|
||||
${col.type === 'status' ? html`<label class="small muted done-toggle"><input type="checkbox" checked=${!!l.done} onChange=${(e) => setLabels(labels.map((x, j) => (j === i ? { ...x, done: e.target.checked } : x)))} /> Done</label>` : null}
|
||||
<button class="icon-btn" disabled=${i === 0} onClick=${() => { const n = [...labels]; [n[i - 1], n[i]] = [n[i], n[i - 1]]; setLabels(n); }}><${Icon} name="chevUp" /></button>
|
||||
<button class="icon-btn" onClick=${() => setLabels(labels.filter((_, j) => j !== i))}><${Icon} name="trash" /></button>
|
||||
</div>`)}
|
||||
<button class="btn ghost sm" onClick=${() => setLabels([...labels, { id: Math.random().toString(36).slice(2, 9), name: 'New label', color: LABEL_COLORS[labels.length % LABEL_COLORS.length] }])}><${Icon} name="plus" /> Add label</button>
|
||||
</div>
|
||||
<div class="label-preview">${labels.map((l) => html`<span key=${l.id} style=${{ background: l.color, color: textOn(l.color) }}>${l.name}</span>`)}</div>` : null}
|
||||
${type === col.type && col.type === 'number' ? html`<label class="field-label">Unit</label>
|
||||
<input class="input" value=${settings.unit || ''} placeholder="$, hrs, %" onInput=${(e) => setSettings({ ...settings, unit: e.target.value })} />` : null}
|
||||
${type === col.type && col.type === 'rating' ? html`<label class="field-label">Maximum</label>
|
||||
<select class="input" value=${settings.max || 5} onChange=${(e) => setSettings({ ...settings, max: Number(e.target.value) })}>${[3, 4, 5, 6, 7, 8, 9, 10].map((n) => html`<option value=${n}>${n}</option>`)}</select>` : null}
|
||||
<div class="modal-actions"><button class="btn ghost" onClick=${closeModal}>Cancel</button><button class="btn primary" onClick=${save}>Save</button></div>
|
||||
</div>`;
|
||||
}
|
||||
Reference in New Issue
Block a user