Files
waters-boards/server/public/js/pages.js
T

292 lines
23 KiB
JavaScript
Raw Normal View History

// Top-level pages: Home, My work, Inbox, Search, Team, Workspace, Admin, and the user menu.
import { html, useState, useEffect, useRef } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, update, useStore, navigate, boardPath, loadUsers, loadTree, toast, setTheme } from './store.js';
import { Icon, Avatar, Spinner, Empty, timeAgo, fmtDate, parseDate, ymd, todayStr, textOn, openModal, closePopover, Menu, RichText, useMobile, confirmDialog, promptDialog } from './ui.js';
import { NewBoardModal } from './sidebar.js';
function PageHead({ title, sub, children }) {
const mobile = useMobile();
return html`<header class="page-head">
${mobile ? html`<button class="icon-btn" onClick=${() => update({ sidebarOpen: true })}><${Icon} name="menu" size=${20} /></button>` : null}
<div class="grow"><h1>${title}</h1>${sub ? html`<p class="muted">${sub}</p>` : null}</div>${children}</header>`;
}
// ─── Home ────────────────────────────────────────────────
export function HomePage() {
const s = useStore();
const [work, setWork] = useState(null);
useEffect(() => { api.get('/api/mywork').then(setWork).catch(() => setWork([])); }, []);
const hour = new Date().getHours();
const greet = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
const favs = s.boards.filter((b) => s.favorites.includes(b.id));
const recent = s.boards.slice().reverse().slice(0, 8);
const open = (work || []).filter((w) => !w.status?.done);
const overdue = open.filter((w) => w.date && w.date.slice(0, 10) < todayStr()).length;
const today = open.filter((w) => w.date && w.date.slice(0, 10) === todayStr()).length;
const wsName = (id) => s.workspaces.find((w) => w.id === id)?.name;
return html`<div class="page home">
<${PageHead} title=${`${greet}, ${s.me.name.split(' ')[0]}!`} sub="Quickly access your recent boards, inbox and work." />
<div class="home-grid">
<section class="card home-stats">
<button class="stat" onClick=${() => navigate('/my-work')}><span class="stat-n">${work ? open.length : '–'}</span><span class="muted">Open items assigned to you</span></button>
<button class="stat" onClick=${() => navigate('/my-work')}><span class="stat-n" style=${{ color: overdue ? 'var(--red)' : undefined }}>${work ? overdue : '–'}</span><span class="muted">Overdue</span></button>
<button class="stat" onClick=${() => navigate('/my-work')}><span class="stat-n">${work ? today : '–'}</span><span class="muted">Due today</span></button>
<button class="stat" onClick=${() => navigate('/inbox')}><span class="stat-n">${s.unread}</span><span class="muted">Unread notifications</span></button>
</section>
${favs.length ? html`<section><h3 class="section-title"><${Icon} name="star" /> Favorites</h3>
<div class="board-cards">${favs.map((b) => html`<${BoardCard} key=${b.id} b=${b} ws=${wsName(b.workspace_id)} />`)}</div></section>` : null}
<section><h3 class="section-title">Boards</h3>
<div class="board-cards">
${recent.map((b) => html`<${BoardCard} key=${b.id} b=${b} ws=${wsName(b.workspace_id)} />`)}
<button class="board-card new" onClick=${() => openModal(NewBoardModal, { workspaceId: s.currentWorkspace || s.workspaces[0]?.id })}><${Icon} name="plus" size=${28} /><span>New board</span></button>
</div></section>
</div>
</div>`;
}
function BoardCard({ b, ws }) {
return html`<button class="board-card" onClick=${() => navigate(boardPath(b.id))}>
<div class="bc-preview"><i /><i /><i /><i /></div>
<div class="bc-name"><${Icon} name="board" size=${14} /> ${b.name}</div>
<div class="muted small">${ws}</div></button>`;
}
// ─── My work ─────────────────────────────────────────────
export function MyWorkPage() {
const s = useStore();
const [items, setItems] = useState(null);
const [showDone, setShowDone] = useState(false);
const [q, setQ] = useState('');
const [who, setWho] = useState(s.route.q?.user || s.me.id);
const load = () => api.get(`/api/mywork${who !== s.me.id ? `?user=${who}` : ''}`).then(setItems);
useEffect(() => { setItems(null); load(); }, [who]);
useEffect(() => { const fn = () => load(); window.addEventListener('wb:board-changed', fn); return () => window.removeEventListener('wb:board-changed', fn); }, [who]);
const t = todayStr();
const d = new Date(); const endWeek = new Date(d); endWeek.setDate(d.getDate() + (7 - d.getDay()));
const endNext = new Date(endWeek); endNext.setDate(endWeek.getDate() + 7);
const buckets = [
['Past dates', (x) => x < t, 'var(--red)'], ['Today', (x) => x === t, 'var(--green)'], ['This week', (x) => x > t && x <= ymd(endWeek), 'var(--primary)'],
['Next week', (x) => x > ymd(endWeek) && x <= ymd(endNext), '#a25ddc'], ['Later', (x) => x > ymd(endNext), '#fdab3d'],
];
const list = (items || []).filter((i) => (showDone || !i.status?.done) && (!q || (i.name + i.board_name).toLowerCase().includes(q.toLowerCase())));
const toggleDone = async (it) => {
// Flip to the first "done" label (or back to empty).
const data = state.boardData[it.board_id] || await api.get(`/api/boards/${it.board_id}`);
const col = data.columns.find((c) => c.id === it.status_column);
if (!col) return toast('That board has no status column', 'error');
const doneLabel = col.settings.labels.find((l) => l.done || /^done$/i.test(l.name));
if (!doneLabel) return toast(`"${col.title}" has no Done label`, 'error');
await api.patch(`/api/items/${it.id}`, { vals: { [col.id]: it.status?.done ? null : doneLabel.id } });
load();
};
const Row = ({ it }) => html`<div class="mw-row ${it.status?.done ? 'done' : ''}">
<button class="mw-check ${it.status?.done ? 'on' : ''}" onClick=${() => toggleDone(it)} title="Mark done"><${Icon} name="check" size=${12} /></button>
<button class="mw-name" onClick=${() => navigate(boardPath(it.board_id, { itemId: it.id }))}>${it.name}${it.parent_name ? html`<span class="muted small"> · in ${it.parent_name}</span>` : null}</button>
<span class="mw-board muted small"><span class="dot" style=${{ background: it.group_color }} />${it.board_name}${it.group ? ` › ${it.group}` : ''}</span>
${it.status ? html`<span class="mw-status" style=${{ background: it.status.color, color: textOn(it.status.color) }}>${it.status.name}</span>` : html`<span class="mw-status empty" />`}
<span class="mw-date ${it.date && it.date.slice(0, 10) < t && !it.status?.done ? 'overdue' : ''}">${it.date ? fmtDate(it.date) : ''}</span>
</div>`;
return html`<div class="page mywork">
<${PageHead} title="My work" sub="Everything assigned to you, across every board.">
<select class="input sm" value=${who} onChange=${(e) => setWho(e.target.value)} title="View someone else's work">
${s.users.filter((u) => u.active).map((u) => html`<option value=${u.id}>${u.id === s.me.id ? 'Me' : u.name}</option>`)}</select>
<//>
<div class="toolbar">
<div class="tb-search"><${Icon} name="search" /><input placeholder="Search" value=${q} onInput=${(e) => setQ(e.target.value)} /></div>
<label class="check-row"><input type="checkbox" checked=${showDone} onChange=${(e) => setShowDone(e.target.checked)} /> Show done items</label>
</div>
${!items ? html`<${Spinner} />` : !list.length ? html`<${Empty} icon="work" title="You're all caught up"><p class="muted">Items show up here when someone assigns you in a People column.</p><//>` : html`
${buckets.map(([title, test, color]) => { const rows = list.filter((i) => i.date && test(i.date.slice(0, 10))).sort((a, b) => a.date.localeCompare(b.date));
return rows.length ? html`<section key=${title} class="mw-group"><h3 style=${{ color }}>${title} <span class="muted small">${rows.length}</span></h3>${rows.map((it) => html`<${Row} key=${it.id} it=${it} />`)}</section>` : null; })}
${(() => { const rows = list.filter((i) => !i.date); return rows.length ? html`<section class="mw-group"><h3 style=${{ color: 'var(--muted)' }}>Without a date <span class="muted small">${rows.length}</span></h3>${rows.map((it) => html`<${Row} key=${it.id} it=${it} />`)}</section>` : null; })()}
`}
</div>`;
}
// ─── Inbox ───────────────────────────────────────────────
export function InboxPage() {
const s = useStore();
const [rows, setRows] = useState(null);
const [filter, setFilter] = useState('all');
const load = () => api.get('/api/notifications').then(setRows);
useEffect(() => { load(); const fn = () => load(); window.addEventListener('wb:notifications', fn); return () => window.removeEventListener('wb:notifications', fn); }, []);
const mark = async (ids, unread = false) => { const r = await api.post('/api/notifications/read', { ids, unread }); update({ unread: r.unread }); load(); };
const markAll = async () => { const r = await api.post('/api/notifications/read', { all: true }); update({ unread: r.unread }); load(); };
const shown = (rows || []).filter((n) => filter === 'all' || (filter === 'unread' ? !n.read : n.kind === filter));
const icon = { mention: 'message', update: 'message', reply: 'reply', assigned: 'person', like: 'thumb', automation: 'bolt' };
return html`<div class="page inbox">
<${PageHead} title="Inbox" sub="Mentions, assignments, replies and automation alerts.">
<button class="btn ghost sm" onClick=${markAll} disabled=${!s.unread}>Mark all as read</button>
<//>
<div class="row gap wrap">${[['all', 'All'], ['unread', 'Unread'], ['mention', 'Mentions'], ['assigned', 'Assigned'], ['automation', 'Automations']].map(([k, l]) => html`
<button key=${k} class="chip-btn ${filter === k ? 'on' : ''}" onClick=${() => setFilter(k)}>${l}</button>`)}</div>
${!rows ? html`<${Spinner} />` : !shown.length ? html`<${Empty} icon="bell" title="Nothing new"><p class="muted">When someone mentions or assigns you, it shows up here.</p><//>` : html`<div class="notif-list">
${shown.map((n) => html`<div key=${n.id} class="notif ${n.read ? '' : 'unread'}" onClick=${() => { if (!n.read) mark([n.id]); if (n.board_id) navigate(boardPath(n.board_id, { itemId: n.item_id || undefined })); }}>
<div class="notif-av"><${Avatar} user=${s.usersById[n.actor_id] || (n.actor_id ? null : { name: '⚡', email: 'auto' })} size=${36} /><span class="notif-kind"><${Icon} name=${icon[n.kind] || 'bell'} size=${11} /></span></div>
<div class="notif-body"><div><${RichText} text=${n.text.replace(/@\[([^\]]+)\]\([\w-]+\)/g, '@$1')} /></div>
<div class="muted small">${n.board_name || ''}${n.item_name ? ` › ${n.item_name}` : ''} · ${timeAgo(n.created_at)}</div></div>
<button class="icon-btn" title=${n.read ? 'Mark unread' : 'Mark read'} onClick=${(e) => { e.stopPropagation(); mark([n.id], !!n.read); }}><span class="read-dot ${n.read ? '' : 'on'}" /></button>
</div>`)}</div>`}
</div>`;
}
// ─── Search ──────────────────────────────────────────────
export function SearchPage() {
const s = useStore();
const [q, setQ] = useState(s.route.q?.q || '');
const [res, setRes] = useState(null);
const t = useRef();
useEffect(() => {
clearTimeout(t.current);
if (q.trim().length < 2) { setRes(null); return; }
t.current = setTimeout(() => { api.get(`/api/search?q=${encodeURIComponent(q)}`).then(setRes); history.replaceState({}, '', `/search?q=${encodeURIComponent(q)}`); }, 200);
}, [q]);
return html`<div class="page search-page">
<${PageHead} title="Search everything" />
<div class="big-search"><${Icon} name="search" size=${20} /><input placeholder="Search items, boards and updates" value=${q} onInput=${(e) => setQ(e.target.value)} autofocus /></div>
${res ? html`
${res.boards.length ? html`<h3 class="section-title">Boards</h3>${res.boards.map((b) => html`<button key=${b.id} class="result" onClick=${() => navigate(boardPath(b.id))}><${Icon} name="board" /> ${b.name}</button>`)}` : null}
${res.items.length ? html`<h3 class="section-title">Items</h3>${res.items.map((i) => html`<button key=${i.id} class="result" onClick=${() => navigate(boardPath(i.board_id, { itemId: i.id }))}>
<span class="dot" style=${{ background: i.group_color || 'var(--muted)' }} /><span class="grow">${i.name}</span><span class="muted small">${i.board_name}${i.group_title ? ` › ${i.group_title}` : ''}</span></button>`)}` : null}
${res.updates.length ? html`<h3 class="section-title">Updates</h3>${res.updates.map((u) => html`<button key=${u.id} class="result update-result" onClick=${() => navigate(boardPath(u.board_id, { itemId: u.item_id }))}>
<${Avatar} user=${s.usersById[u.user_id]} size=${24} /><span class="grow clamp2">${u.body.replace(/@\[([^\]]+)\]\([\w-]+\)/g, '@$1')}</span><span class="muted small">${u.item_name} · ${timeAgo(u.created_at)}</span></button>`)}` : null}
${!res.boards.length && !res.items.length && !res.updates.length ? html`<p class="muted pad">No results for "${q}".</p>` : null}
` : html`<p class="muted pad">Type at least two characters.</p>`}
</div>`;
}
// ─── Team ────────────────────────────────────────────────
export function PeoplePage() {
const s = useStore();
const [q, setQ] = useState('');
useEffect(() => { loadUsers(); }, []);
const users = s.users.filter((u) => (u.name + u.email).toLowerCase().includes(q.toLowerCase())).sort((a, b) => b.active - a.active);
return html`<div class="page people">
<${PageHead} title="Team" sub=${`${s.users.filter((u) => u.active).length} people`}>
${s.me.role === 'admin' ? html`<button class="btn primary sm" onClick=${async () => {
const email = await promptDialog({ title: 'Add a teammate', label: 'Their company email — they sign in with Google the first time they visit.', placeholder: 'name@waterschurch.org', confirm: 'Add' });
if (email) { try { await api.post('/api/users', { email }); loadUsers(); toast('Added'); } catch (e) { toast(e.message, 'error'); } }
}}><${Icon} name="plus" /> Add person</button>` : null}
<//>
<div class="tb-search wide"><${Icon} name="search" /><input placeholder="Search people" value=${q} onInput=${(e) => setQ(e.target.value)} /></div>
<div class="people-grid">${users.map((u) => html`<div key=${u.id} class="person-card ${u.active ? '' : 'inactive'}">
<${Avatar} user=${u} size=${56} />
<strong>${u.name}</strong><span class="muted small">${u.title || (u.role === 'admin' ? 'Admin' : 'Member')}</span>
<a class="muted small" href=${'mailto:' + u.email}>${u.email}</a>
<button class="btn ghost sm" onClick=${() => navigate(`/my-work?user=${u.id}`)}>Assigned work</button>
</div>`)}</div>
</div>`;
}
// ─── Workspace overview ──────────────────────────────────
export function WorkspacePage({ workspaceId }) {
const s = useStore();
const ws = s.workspaces.find((w) => w.id === workspaceId);
if (!ws) return html`<${Empty} title="Workspace not found" />`;
const boards = s.boards.filter((b) => b.workspace_id === ws.id);
return html`<div class="page">
<${PageHead} title=${ws.name} sub=${ws.description || `${boards.length} boards`}>
<button class="btn primary sm" onClick=${() => openModal(NewBoardModal, { workspaceId: ws.id })}><${Icon} name="plus" /> New board</button><//>
<div class="board-cards">${boards.map((b) => html`<${BoardCard} key=${b.id} b=${b} ws=${s.folders.find((f) => f.id === b.folder_id)?.name || ''} />`)}</div>
</div>`;
}
// ─── Admin ───────────────────────────────────────────────
export function AdminPage() {
const s = useStore();
const [tab, setTab] = useState(s.route.tab || 'people');
useEffect(() => { loadUsers(); }, []);
if (s.me.role !== 'admin') return html`<${Empty} icon="shield" title="Admins only" />`;
return html`<div class="page admin">
<${PageHead} title="Administration" />
<nav class="panel-tabs">
<button class=${tab === 'people' ? 'active' : ''} onClick=${() => setTab('people')}>People & access</button>
<button class=${tab === 'import' ? 'active' : ''} onClick=${() => setTab('import')}>Import from monday.com</button>
</nav>
${tab === 'people' ? html`<${AdminPeople} />` : html`<${MondayImport} />`}
</div>`;
}
function AdminPeople() {
const s = useStore();
const patch = async (u, p) => { try { await api.patch(`/api/users/${u.id}`, p); loadUsers(); } catch (e) { toast(e.message, 'error'); } };
return html`<div class="card"><table class="admin-table">
<thead><tr><th>Person</th><th>Email</th><th>Role</th><th>Last seen</th><th>Access</th></tr></thead>
<tbody>${s.users.map((u) => html`<tr key=${u.id} class=${u.active ? '' : 'inactive'}>
<td><span class="row gap"><${Avatar} user=${u} size=${26} />${u.name}</span></td>
<td class="muted">${u.email}</td>
<td><select class="input sm" value=${u.role} disabled=${u.id === s.me.id} onChange=${(e) => patch(u, { role: e.target.value })}><option value="member">Member</option><option value="admin">Admin</option></select></td>
<td class="muted small">${u.last_seen ? timeAgo(u.last_seen) : 'never signed in'}</td>
<td>${u.id === s.me.id ? html`<span class="muted small">you</span>` : html`<button class="btn ghost sm" onClick=${async () => {
if (u.active && !(await confirmDialog({ title: `Remove ${u.name}'s access?`, body: 'They are signed out everywhere and can no longer sign in. Their items and updates stay.', confirm: 'Remove access' }))) return;
patch(u, { active: !u.active });
}}>${u.active ? 'Deactivate' : 'Reactivate'}</button>`}</td>
</tr>`)}</tbody></table>
<p class="muted small pad">Anyone with an allowed company Google account can sign in and becomes a member. Admins can manage people, delete workspaces and run imports.</p>
</div>`;
}
function MondayImport() {
const [token, setToken] = useState('');
const [boards, setBoards] = useState(null);
const [sel, setSel] = useState([]);
const [status, setStatus] = useState(null);
const [busy, setBusy] = useState(false);
const poll = useRef();
const refresh = () => api.get('/api/admin/import/monday').then((st) => { setStatus(st); if (!st.running) { clearInterval(poll.current); if (st.finishedAt) loadTree(); } });
useEffect(() => { refresh(); return () => clearInterval(poll.current); }, []);
const list = async () => {
setBusy(true);
try { const b = await api.post('/api/admin/import/monday/boards', { token }); setBoards(b); setSel(b.filter((x) => !x.imported).map((x) => x.id)); }
catch (e) { toast(e.message, 'error'); }
setBusy(false);
};
const start = async () => {
try { await api.post('/api/admin/import/monday', { token, boardIds: sel }); clearInterval(poll.current); poll.current = setInterval(refresh, 1500); refresh(); }
catch (e) { toast(e.message, 'error'); }
};
return html`<div class="card import-card">
<h3>Bring your boards over from monday.com</h3>
<ol class="steps">
<li>In monday.com, click your avatar → <b>Developers</b> → <b>My access tokens</b> → <b>Show</b>, and copy the token.</li>
<li>Paste it below and load your boards. Pick which ones to bring over.</li>
<li>Everything comes across: workspaces, folders, groups, columns, items, subitems, updates and files. People are matched by email and claim their assignments the first time they sign in.</li>
</ol>
<div class="row gap"><input class="input grow" type="password" placeholder="monday.com API token" value=${token} onInput=${(e) => setToken(e.target.value)} autocomplete="off" />
<button class="btn" disabled=${!token || busy} onClick=${list}>${busy ? 'Loading…' : 'Load boards'}</button></div>
<p class="muted small">The token is only used for this import and is never stored.</p>
${boards ? html`<div class="import-list">
<label class="check-row"><input type="checkbox" checked=${sel.length === boards.filter((b) => !b.imported).length} onChange=${(e) => setSel(e.target.checked ? boards.filter((b) => !b.imported).map((b) => b.id) : [])} /> <b>All boards</b> (${boards.length})</label>
${boards.map((b) => html`<label key=${b.id} class="check-row ${b.imported ? 'muted' : ''}">
<input type="checkbox" disabled=${b.imported} checked=${sel.includes(b.id)} onChange=${(e) => setSel(e.target.checked ? [...sel, b.id] : sel.filter((x) => x !== b.id))} />
${b.name} <span class="muted small">${b.workspace} · ${b.items ?? '?'} items${b.imported ? ' · already imported' : ''}</span></label>`)}
</div>
<button class="btn primary" disabled=${!sel.length || status?.running} onClick=${start}>Import ${sel.length} board${sel.length === 1 ? '' : 's'}</button>` : null}
${status && (status.running || status.log.length) ? html`<div class="import-log">
<div class="row gap"><strong>${status.running ? 'Importing…' : status.error ? 'Import failed' : 'Import finished'}</strong>${status.running ? html`<${Spinner} />` : null}
<span class="muted small">${status.counts.boards || 0} boards · ${status.counts.items || 0} items · ${status.counts.updates || 0} updates · ${status.counts.files || 0} files</span></div>
<pre>${status.log.slice(-200).join('\n')}</pre></div>` : null}
</div>`;
}
// ─── User menu ───────────────────────────────────────────
export function UserMenu() {
const s = useStore();
const signOut = async () => { await api.post('/api/auth/logout'); location.href = '/'; };
return html`<div class="user-menu">
<div class="um-head"><${Avatar} user=${s.me} size=${40} /><div><strong>${s.me.name}</strong><div class="muted small">${s.me.email}</div></div></div>
<${Menu} items=${[
{ label: 'Edit my name', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Your name', value: s.me.name }); if (n) { const me = await api.patch('/api/me', { name: n }); update({ me }); loadUsers(); } } },
{ label: 'My work', icon: 'work', onClick: () => navigate('/my-work') },
{ divider: true },
{ label: 'Light theme', icon: 'sun', active: s.theme === 'light', onClick: () => setTheme('light') },
{ label: 'Dark theme', icon: 'moon', active: s.theme === 'dark', onClick: () => setTheme('dark') },
{ label: 'Night theme', icon: 'moon', active: s.theme === 'night', onClick: () => setTheme('night') },
{ label: 'Match system', icon: 'gear', active: s.theme === 'system', onClick: () => setTheme('system') },
{ divider: true },
s.me.role === 'admin' && { label: 'Administration', icon: 'shield', onClick: () => navigate('/admin') },
{ label: 'Sign out', icon: 'logout', onClick: signOut },
]} />
</div>`;
}