Deploy Waters Boards to AWS / deploy (push) Successful in 1m28s
The importer only creates accounts for active monday users. An update or
reply written by a deactivated staff member or an outside guest looked up
an account that didn't exist, bound undefined to SQLite, and stopped the
whole import ("Provided value cannot be bound to SQLite parameter 4").
Those posts now import with the author's name and no linked account.
A board that fails partway is now deleted and the import moves on, so the
next run retries it instead of skipping a half-imported board. A rejected
token or persistent rate limiting still stops the run. The import page
says "finished with errors" when some boards made it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
292 lines
23 KiB
JavaScript
292 lines
23 KiB
JavaScript
// 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 ? (status.counts.boards ? 'Import finished with errors' : '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>`;
|
||
}
|