// 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` update({ sidebarOpen: true })}><${Icon} name="menu" size=${20} /> ` : 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." />
navigate('/my-work')}>${work ? open.length : '–'} Open items assigned to you
navigate('/my-work')}>${work ? overdue : '–'} Overdue
navigate('/my-work')}>${work ? today : '–'} Due today
navigate('/inbox')}>${s.unread} Unread notifications
${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)} />`)}
openModal(NewBoardModal, { workspaceId: s.currentWorkspace || s.workspaces[0]?.id })}><${Icon} name="plus" size=${28} />New board
`;
}
function BoardCard({ b, ws }) {
return html` navigate(boardPath(b.id))}>
<${Icon} name="board" size=${14} /> ${b.name}
${ws}
`;
}
// ─── 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`
toggleDone(it)} title="Mark done"><${Icon} name="check" size=${12} />
navigate(boardPath(it.board_id, { itemId: it.id }))}>${it.name}${it.parent_name ? html` · in ${it.parent_name} ` : null}
${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.">
setWho(e.target.value)} title="View someone else's work">
${s.users.filter((u) => u.active).map((u) => html`${u.id === s.me.id ? 'Me' : u.name} `)}
/>
${!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.">
Mark all as read
/>
${[['all', 'All'], ['unread', 'Unread'], ['mention', 'Mentions'], ['assigned', 'Assigned'], ['automation', 'Automations']].map(([k, l]) => html`
setFilter(k)}>${l} `)}
${!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)}
{ e.stopPropagation(); mark([n.id], !!n.read); }}>
`)}
`}
`;
}
// ─── 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" />
<${Icon} name="search" size=${20} /> setQ(e.target.value)} autofocus />
${res ? html`
${res.boards.length ? html`
Boards ${res.boards.map((b) => html`
navigate(boardPath(b.id))}><${Icon} name="board" /> ${b.name} `)}` : null}
${res.items.length ? html`
Items ${res.items.map((i) => html`
navigate(boardPath(i.board_id, { itemId: i.id }))}>
${i.name} ${i.board_name}${i.group_title ? ` › ${i.group_title}` : ''} `)}` : null}
${res.updates.length ? html`
Updates ${res.updates.map((u) => html`
navigate(boardPath(u.board_id, { itemId: u.item_id }))}>
<${Avatar} user=${s.usersById[u.user_id]} size=${24} />${u.body.replace(/@\[([^\]]+)\]\([\w-]+\)/g, '@$1')} ${u.item_name} · ${timeAgo(u.created_at)} `)}` : 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`
{
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 ` : null}
/>
<${Icon} name="search" /> setQ(e.target.value)} />
${users.map((u) => html`
<${Avatar} user=${u} size=${56} />
${u.name} ${u.title || (u.role === 'admin' ? 'Admin' : 'Member')}
${u.email}
navigate(`/my-work?user=${u.id}`)}>Assigned work
`)}
`;
}
// ─── 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`}>
openModal(NewBoardModal, { workspaceId: ws.id })}><${Icon} name="plus" /> New board />
${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" />
setTab('people')}>People & access
setTab('import')}>Import from monday.com
${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`
Person Email Role Last seen Access
${s.users.map((u) => html`
<${Avatar} user=${u} size=${26} />${u.name}
${u.email}
patch(u, { role: e.target.value })}>Member Admin
${u.last_seen ? timeAgo(u.last_seen) : 'never signed in'}
${u.id === s.me.id ? html`you ` : html` {
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'} `}
`)}
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``;
}
// ─── User menu ───────────────────────────────────────────
export function UserMenu() {
const s = useStore();
const signOut = async () => { await api.post('/api/auth/logout'); location.href = '/'; };
return html``;
}