// 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`
${mobile ? html`` : null}

${title}

${sub ? html`

${sub}

` : null}
${children}
`; } // ─── 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`
<${PageHead} title=${`${greet}, ${s.me.name.split(' ')[0]}!`} sub="Quickly access your recent boards, inbox and work." />
${favs.length ? html`

<${Icon} name="star" /> Favorites

${favs.map((b) => html`<${BoardCard} key=${b.id} b=${b} ws=${wsName(b.workspace_id)} />`)}
` : null}

Boards

${recent.map((b) => html`<${BoardCard} key=${b.id} b=${b} ws=${wsName(b.workspace_id)} />`)}
`; } function BoardCard({ b, ws }) { return html``; } // ─── 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`
${it.board_name}${it.group ? ` › ${it.group}` : ''} ${it.status ? html`${it.status.name}` : html``} ${it.date ? fmtDate(it.date) : ''}
`; return html`
<${PageHead} title="My work" sub="Everything assigned to you, across every board.">
${!items ? html`<${Spinner} />` : !list.length ? html`<${Empty} icon="work" title="You're all caught up">

Items show up here when someone assigns you in a People column.

` : 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`

${title} ${rows.length}

${rows.map((it) => html`<${Row} key=${it.id} it=${it} />`)}
` : null; })} ${(() => { const rows = list.filter((i) => !i.date); return rows.length ? html`

Without a date ${rows.length}

${rows.map((it) => html`<${Row} key=${it.id} it=${it} />`)}
` : null; })()} `}
`; } // ─── 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`
<${PageHead} title="Inbox" sub="Mentions, assignments, replies and automation alerts.">
${[['all', 'All'], ['unread', 'Unread'], ['mention', 'Mentions'], ['assigned', 'Assigned'], ['automation', 'Automations']].map(([k, l]) => html` `)}
${!rows ? html`<${Spinner} />` : !shown.length ? html`<${Empty} icon="bell" title="Nothing new">

When someone mentions or assigns you, it shows up here.

` : html`
${shown.map((n) => html`
{ if (!n.read) mark([n.id]); if (n.board_id) navigate(boardPath(n.board_id, { itemId: n.item_id || undefined })); }}>
<${Avatar} user=${s.usersById[n.actor_id] || (n.actor_id ? null : { name: '⚡', email: 'auto' })} size=${36} /><${Icon} name=${icon[n.kind] || 'bell'} size=${11} />
<${RichText} text=${n.text.replace(/@\[([^\]]+)\]\([\w-]+\)/g, '@$1')} />
${n.board_name || ''}${n.item_name ? ` › ${n.item_name}` : ''} · ${timeAgo(n.created_at)}
`)}
`}
`; } // ─── 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`
<${PageHead} title="Search everything" /> ${res ? html` ${res.boards.length ? html`

Boards

${res.boards.map((b) => html``)}` : null} ${res.items.length ? html`

Items

${res.items.map((i) => html``)}` : null} ${res.updates.length ? html`

Updates

${res.updates.map((u) => html``)}` : null} ${!res.boards.length && !res.items.length && !res.updates.length ? html`

No results for "${q}".

` : null} ` : html`

Type at least two characters.

`}
`; } // ─── 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`
<${PageHead} title="Team" sub=${`${s.users.filter((u) => u.active).length} people`}> ${s.me.role === 'admin' ? html`` : null}
${users.map((u) => html`
<${Avatar} user=${u} size=${56} /> ${u.name}${u.title || (u.role === 'admin' ? 'Admin' : 'Member')} ${u.email}
`)}
`; } // ─── 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`
<${PageHead} title=${ws.name} sub=${ws.description || `${boards.length} boards`}>
${boards.map((b) => html`<${BoardCard} key=${b.id} b=${b} ws=${s.folders.find((f) => f.id === b.folder_id)?.name || ''} />`)}
`; } // ─── 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`
<${PageHead} title="Administration" /> ${tab === 'people' ? html`<${AdminPeople} />` : html`<${MondayImport} />`}
`; } 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`
${s.users.map((u) => html``)}
PersonEmailRoleLast seenAccess
<${Avatar} user=${u} size=${26} />${u.name} ${u.email} ${u.last_seen ? timeAgo(u.last_seen) : 'never signed in'} ${u.id === s.me.id ? html`you` : html``}

Anyone with an allowed company Google account can sign in and becomes a member. Admins can manage people, delete workspaces and run imports.

`; } 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`

Bring your boards over from monday.com

  1. In monday.com, click your avatar → Developers → My access tokens → Show, and copy the token.
  2. Paste it below and load your boards. Pick which ones to bring over.
  3. 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.
setToken(e.target.value)} autocomplete="off" />

The token is only used for this import and is never stored.

${boards ? html`
${boards.map((b) => html``)}
` : null} ${status && (status.running || status.log.length) ? html`
${status.running ? 'Importing…' : status.error ? 'Import failed' : 'Import finished'}${status.running ? html`<${Spinner} />` : null} ${status.counts.boards || 0} boards · ${status.counts.items || 0} items · ${status.counts.updates || 0} updates · ${status.counts.files || 0} files
${status.log.slice(-200).join('\n')}
` : null}
`; } // ─── User menu ─────────────────────────────────────────── export function UserMenu() { const s = useStore(); const signOut = async () => { await api.post('/api/auth/logout'); location.href = '/'; }; return html`
<${Avatar} user=${s.me} size=${40} />
${s.me.name}
${s.me.email}
<${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 }, ]} />
`; }