Waters Boards: self-hosted work management to replace monday.com
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:
2026-10-13 15:34:47 -04:00
co-authored by Claude Opus 5.5
commit fcf3805626
44 changed files with 7443 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Copy to .env next to docker-compose.yml and fill in. Never commit .env.
PORT=3100
# Google sign-in — the OAuth "Web application" client ID from Google Cloud Console.
# See README.md › "Set up Google sign-in". Leave empty to disable Google sign-in.
GOOGLE_CLIENT_ID=
# Only Google accounts on these domains can sign in (comma-separated).
ALLOWED_DOMAINS=waterschurch.org
# These people are always admins (comma-separated). The very first person to sign in
# also becomes an admin.
ADMIN_EMAILS=you@waterschurch.org
# Optional break-glass password for the ADMIN_EMAILS accounts, for when Google sign-in
# is not set up yet or is broken. Use something long. Leave empty to disable.
ADMIN_PASSWORD=
# Name of the first workspace created on an empty database.
ORG_NAME=Waters Church
# Local development only: passwordless sign-in for any allowed-domain email.
# Ignored when NODE_ENV=production (which the Docker image sets).
# DEV_LOGIN=1
+23
View File
@@ -0,0 +1,23 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
COPY src ./src
COPY public ./public
# Database, uploads and nightly backups live here; mount a volume in compose.
RUN mkdir -p /app/data
ENV DATA_DIR=/app/data
ENV NODE_ENV=production
ENV PORT=3100
EXPOSE 3100
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:3100/healthz >/dev/null 2>&1 || exit 1
CMD ["node", "--disable-warning=ExperimentalWarning", "server.js"]
+14
View File
@@ -0,0 +1,14 @@
services:
boards:
build: .
image: waters-boards:latest
container_name: waters-boards
restart: unless-stopped
ports:
# Host port : container port. 3000 is the checklist app, so this uses 3100.
- "3100:3100"
env_file:
- .env
volumes:
# Database (boards.db), uploaded files and nightly backups.
- ./data:/app/data
+1188
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "waters-boards",
"version": "1.0.0",
"description": "Self-hosted boards, tasks and projects for the Waters Church team",
"main": "server.js",
"scripts": {
"start": "node --disable-warning=ExperimentalWarning server.js",
"dev": "node --disable-warning=ExperimentalWarning --watch server.js"
},
"license": "UNLICENSED",
"dependencies": {
"cookie-parser": "^1.4.7",
"dotenv": "^16.6.1",
"express": "^4.22.3",
"google-auth-library": "^11.3.0",
"multer": "^2.4.0"
},
"devDependencies": {
"htm": "^3.1.1"
},
"private": true,
"engines": {
"node": ">=22.13"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#181b34"/>
<rect x="112" y="112" width="76" height="288" rx="38" fill="#579bfc"/>
<rect x="218" y="112" width="76" height="210" rx="38" fill="#00c875"/>
<rect x="324" y="112" width="76" height="140" rx="38" fill="#fdab3d"/>
</svg>

After

Width:  |  Height:  |  Size: 348 B

+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Boards" />
<title>Waters Boards</title>
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icons/icon-180.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;600;700&display=swap" />
<link rel="stylesheet" href="/styles.css" />
<script src="https://accounts.google.com/gsi/client" async defer></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/js/app.js"></script>
</body>
</html>
+168
View File
@@ -0,0 +1,168 @@
// Board/item mutations: optimistic local update, then the server call.
import { api } from './api.js';
import { state, mutateBoard, serverCall, loadBoard, loadTree, navigate, toast, boardPath } from './store.js';
import { confirmDialog, promptDialog } from './ui.js';
const B = (boardId) => state.boardData[boardId];
export function setValue(boardId, itemId, colId, value) {
mutateBoard(boardId, (b) => {
const it = b.items.find((i) => i.id === itemId);
if (!it) return;
if (value === null || value === '' || (Array.isArray(value) && !value.length)) delete it.vals[colId];
else it.vals[colId] = value;
it.updated_at = Date.now();
});
return serverCall(boardId, api.patch(`/api/items/${itemId}`, { vals: { [colId]: value } }));
}
export function renameItem(boardId, itemId, name) {
if (!name.trim()) return;
mutateBoard(boardId, (b) => { const it = b.items.find((i) => i.id === itemId); if (it) it.name = name; });
return serverCall(boardId, api.patch(`/api/items/${itemId}`, { name }));
}
export async function createItem(boardId, { group_id, parent_id, name, vals = {}, top } = {}) {
const b = B(boardId);
const tmp = { id: 'tmp' + Math.random(), board_id: boardId, group_id: parent_id ? null : group_id, parent_id: parent_id || null, name, vals, position: top ? -1e9 : 1e9, created_at: Date.now(), updated_at: Date.now() };
if (b) { b.items.push(tmp); }
mutateBoard(boardId, () => {});
try {
const it = await api.post(`/api/boards/${boardId}/items`, { group_id, parent_id, name, vals, top });
mutateBoard(boardId, (bd) => { const i = bd.items.findIndex((x) => x.id === tmp.id); if (i >= 0) bd.items[i] = it; else bd.items.push(it); });
return it;
} catch (e) {
toast(e.message, 'error'); loadBoard(boardId); throw e;
}
}
export async function deleteItems(boardId, ids, { ask = true } = {}) {
if (ask && !(await confirmDialog({ title: ids.length > 1 ? `Delete ${ids.length} items?` : 'Delete this item?', body: 'Its subitems, updates and files are deleted too. This cannot be undone.' }))) return false;
mutateBoard(boardId, (b) => { b.items = b.items.filter((i) => !ids.includes(i.id) && !ids.includes(i.parent_id)); });
if (state.route.itemId && ids.includes(state.route.itemId)) navigate(boardPath(boardId, { viewId: state.route.viewId }));
await serverCall(boardId, ids.length === 1 ? api.del(`/api/items/${ids[0]}`) : api.post('/api/items/bulk', { ids, action: 'delete' }));
toast(ids.length > 1 ? `${ids.length} items deleted` : 'Item deleted');
return true;
}
export async function archiveItems(boardId, ids) {
mutateBoard(boardId, (b) => { b.items = b.items.filter((i) => !ids.includes(i.id) && !ids.includes(i.parent_id)); });
await serverCall(boardId, api.post('/api/items/bulk', { ids, action: 'archive' }));
toast(ids.length > 1 ? `${ids.length} items archived` : 'Item archived');
}
export async function duplicateItems(boardId, ids) {
await serverCall(boardId, ids.length === 1 ? api.post(`/api/items/${ids[0]}/duplicate`) : api.post('/api/items/bulk', { ids, action: 'duplicate' }));
loadBoard(boardId);
}
export async function moveItem(boardId, itemId, { group_id, position }) {
mutateBoard(boardId, (b) => {
const it = b.items.find((i) => i.id === itemId);
if (it) { if (group_id) it.group_id = group_id; if (position != null) it.position = position; }
});
return serverCall(boardId, api.patch(`/api/items/${itemId}`, { group_id, position }));
}
export async function moveItemsToGroup(boardId, ids, group_id) {
mutateBoard(boardId, (b) => { for (const it of b.items) if (ids.includes(it.id)) it.group_id = group_id; });
await serverCall(boardId, api.post('/api/items/bulk', { ids, action: 'move', group_id }));
}
export async function moveItemToBoard(boardId, itemId, targetBoardId) {
const target = state.boardData[targetBoardId] || await loadBoard(targetBoardId);
const g = target.groups[0];
if (!g) return;
mutateBoard(boardId, (b) => { b.items = b.items.filter((i) => i.id !== itemId && i.parent_id !== itemId); });
await serverCall(boardId, api.patch(`/api/items/${itemId}`, { group_id: g.id }));
toast(`Moved to ${target.board.name}`);
loadBoard(targetBoardId);
}
// Position between two neighbours (fractional indexing).
export function between(a, b) {
if (a == null && b == null) return 1;
if (a == null) return b - 1;
if (b == null) return a + 1;
return (a + b) / 2;
}
// ─── Groups ──────────────────────────────────────────────
export async function addGroup(boardId, { top = true } = {}) {
const r = await serverCall(boardId, api.post(`/api/boards/${boardId}/groups`, { title: 'New Group', top }));
await loadBoard(boardId);
return r.id;
}
export function patchGroup(boardId, groupId, patch) {
mutateBoard(boardId, (b) => { const g = b.groups.find((x) => x.id === groupId); if (g) Object.assign(g, patch); });
return serverCall(boardId, api.patch(`/api/groups/${groupId}`, patch));
}
export async function deleteGroup(boardId, groupId) {
const b = B(boardId);
const g = b.groups.find((x) => x.id === groupId);
const n = b.items.filter((i) => i.group_id === groupId).length;
if (!(await confirmDialog({ title: `Delete "${g.title}"?`, body: n ? `This deletes the group and its ${n} item${n === 1 ? '' : 's'}.` : 'This group is empty.' }))) return;
mutateBoard(boardId, (bd) => { bd.groups = bd.groups.filter((x) => x.id !== groupId); bd.items = bd.items.filter((i) => i.group_id !== groupId); });
await serverCall(boardId, api.del(`/api/groups/${groupId}`));
}
// ─── Columns ─────────────────────────────────────────────
export async function addColumn(boardId, type, { after, title } = {}) {
const r = await serverCall(boardId, api.post(`/api/boards/${boardId}/columns`, { type, after, title }));
await loadBoard(boardId);
return r.id;
}
export function patchColumn(boardId, colId, patch) {
mutateBoard(boardId, (b) => { const c = b.columns.find((x) => x.id === colId); if (c) Object.assign(c, patch); });
return serverCall(boardId, api.patch(`/api/columns/${colId}`, patch));
}
export async function deleteColumn(boardId, colId) {
const c = B(boardId).columns.find((x) => x.id === colId);
if (!(await confirmDialog({ title: `Delete column "${c.title}"?`, body: 'All values in this column are deleted.' }))) return;
mutateBoard(boardId, (b) => { b.columns = b.columns.filter((x) => x.id !== colId); });
await serverCall(boardId, api.del(`/api/columns/${colId}`));
}
// ─── Boards / workspace structure ────────────────────────
export async function renameBoard(boardId, name) {
mutateBoard(boardId, (b) => { b.board.name = name; });
const tb = state.boards.find((b) => b.id === boardId); if (tb) tb.name = name;
await serverCall(boardId, api.patch(`/api/boards/${boardId}`, { name }));
}
export async function deleteBoard(boardId) {
const b = state.boards.find((x) => x.id === boardId);
if (!(await confirmDialog({ title: `Delete board "${b?.name}"?`, body: 'Every item, update and file on it is deleted. This cannot be undone.' }))) return;
await api.del(`/api/boards/${boardId}`);
delete state.boardData[boardId];
if (state.route.boardId === boardId) navigate('/');
await loadTree();
toast('Board deleted');
}
export async function duplicateBoard(boardId) {
const r = await api.post(`/api/boards/${boardId}/duplicate`, {});
await loadTree();
navigate(boardPath(r.id));
}
export async function newFolder(workspace_id, parent_id) {
const name = await promptDialog({ title: 'New folder', placeholder: 'Folder name', confirm: 'Create' });
if (!name) return;
await api.post('/api/folders', { workspace_id, parent_id, name });
await loadTree();
}
export async function renameFolder(f) {
const name = await promptDialog({ title: 'Rename folder', value: f.name });
if (!name) return;
await api.patch(`/api/folders/${f.id}`, { name });
await loadTree();
}
export async function deleteFolder(f) {
if (!(await confirmDialog({ title: `Delete folder "${f.name}"?`, body: 'Boards inside are kept and moved up a level.' }))) return;
await api.del(`/api/folders/${f.id}`);
await loadTree();
}
export async function toggleFavorite(boardId) {
const on = !state.favorites.includes(boardId);
state.favorites = on ? [...state.favorites, boardId] : state.favorites.filter((x) => x !== boardId);
mutateBoard(boardId, (b) => { b.favorite = on; });
await api.post(`/api/boards/${boardId}/favorite`, { on });
}
+44
View File
@@ -0,0 +1,44 @@
// Fetch wrapper + live event stream.
export const clientId = Math.random().toString(36).slice(2);
export class ApiError extends Error {
constructor(status, message) { super(message); this.status = status; }
}
async function request(method, url, body, opts = {}) {
const headers = { 'X-Client-Id': clientId };
let payload = body;
if (body !== undefined && !(body instanceof FormData) && typeof body !== 'string') {
headers['Content-Type'] = 'application/json';
payload = JSON.stringify(body);
} else if (typeof body === 'string') headers['Content-Type'] = opts.contentType || 'text/plain';
const res = await fetch(url, { method, headers, body: payload, credentials: 'same-origin' });
if (res.status === 401 && !url.startsWith('/api/auth')) {
window.dispatchEvent(new CustomEvent('wb:signed-out'));
}
const ct = res.headers.get('content-type') || '';
const data = ct.includes('json') ? await res.json() : await res.text();
if (!res.ok) throw new ApiError(res.status, data?.error || res.statusText);
return data;
}
export const api = {
get: (u) => request('GET', u),
post: (u, b = {}, o) => request('POST', u, b, o),
patch: (u, b = {}) => request('PATCH', u, b),
del: (u) => request('DELETE', u),
};
let es = null;
const listeners = new Set();
export function onEvent(fn) { listeners.add(fn); return () => listeners.delete(fn); }
export function connectEvents() {
if (es) return;
es = new EventSource(`/api/events?client=${clientId}`);
es.onmessage = (m) => {
let ev; try { ev = JSON.parse(m.data); } catch { return; }
for (const fn of listeners) fn(ev);
};
es.onopen = () => { for (const fn of listeners) fn({ type: 'reconnected' }); };
}
export function disconnectEvents() { es?.close(); es = null; }
+138
View File
@@ -0,0 +1,138 @@
// Entry point: sign-in gate, app shell, routing, overlays, mobile tab bar.
import { html, render, useState, useEffect, useRef } from '../vendor/preact-htm.mjs';
import { api, connectEvents } from './api.js';
import { state, update, useStore, navigate, loadBootstrap, applyTheme } from './store.js';
import { Icon, PopoverHost, ModalHost, Spinner, useMobile } from './ui.js';
import { Sidebar } from './sidebar.js';
import { BoardPage } from './board.js';
import { HomePage, MyWorkPage, InboxPage, SearchPage, PeoplePage, AdminPage, WorkspacePage } from './pages.js';
applyTheme();
function App() {
const s = useStore();
const [phase, setPhase] = useState('loading'); // loading | signin | ready
useEffect(() => {
loadBootstrap().then(() => { setPhase('ready'); connectEvents(); }).catch(() => setPhase('signin'));
const out = () => setPhase('signin');
window.addEventListener('wb:signed-out', out);
return () => window.removeEventListener('wb:signed-out', out);
}, []);
// Keyboard: "/" or Cmd/Ctrl+K opens search
useEffect(() => {
const fn = (e) => {
if (/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName) || document.activeElement?.isContentEditable) return;
if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || e.key === '/') { e.preventDefault(); navigate('/search'); }
};
document.addEventListener('keydown', fn);
return () => document.removeEventListener('keydown', fn);
}, []);
if (phase === 'loading') return html`<div class="splash"><span class="logo-mark big"><i /><i /><i /></span></div>`;
if (phase === 'signin') return html`<${SignIn} onDone=${async () => { await loadBootstrap(); connectEvents(); setPhase('ready'); }} />`;
return html`<div class="app ${s.sidebarCollapsed ? 'nav-collapsed' : ''}">
<${Sidebar} />
<main class="main"><${Route} /></main>
<${MobileTabs} />
<${PopoverHost} />
<${ModalHost} />
<${Toasts} />
</div>`;
}
function Route() {
const r = useStore().route;
useEffect(() => {
const b = r.boardId && state.boards.find((x) => x.id === r.boardId);
document.title = b ? `${b.name} · Waters Boards` : 'Waters Boards';
}, [r.name, r.boardId, state.boards.length]);
switch (r.name) {
case 'board': return html`<${BoardPage} key=${r.boardId} boardId=${r.boardId} viewId=${r.viewId} itemId=${r.itemId} />`;
case 'my-work': return html`<${MyWorkPage} key=${r.q?.user || 'me'} />`;
case 'inbox': return html`<${InboxPage} />`;
case 'search': return html`<${SearchPage} />`;
case 'people': return html`<${PeoplePage} />`;
case 'admin': return html`<${AdminPage} />`;
case 'workspace': return html`<${WorkspacePage} workspaceId=${r.workspaceId} />`;
default: return html`<${HomePage} />`;
}
}
function MobileTabs() {
const s = useStore();
const mobile = useMobile();
if (!mobile || s.route.itemId) return null;
const r = s.route.name;
const Tab = ({ icon, label, to, active, badge, onClick }) => html`<button class="mtab ${active ? 'active' : ''}" onClick=${onClick || (() => navigate(to))}>
<${Icon} name=${icon} size=${22} />${badge ? html`<span class="rail-badge">${badge > 99 ? '99+' : badge}</span>` : null}<span>${label}</span></button>`;
return html`<nav class="mobile-tabs">
<${Tab} icon="home" label="Home" to="/" active=${r === 'home'} />
<${Tab} icon="work" label="My work" to="/my-work" active=${r === 'my-work'} />
<${Tab} icon="board" label="Boards" active=${r === 'board' || s.sidebarOpen} onClick=${() => update({ sidebarOpen: true })} />
<${Tab} icon="bell" label="Inbox" to="/inbox" active=${r === 'inbox'} badge=${s.unread} />
<${Tab} icon="search" label="Search" to="/search" active=${r === 'search'} />
</nav>`;
}
function Toasts() {
const s = useStore();
return html`<div class="toasts">${s.toasts.map((t) => html`<div key=${t.id} class="toast ${t.kind}">${t.text}
${t.action ? html`<button class="link" onClick=${t.action.onClick}>${t.action.label}</button>` : null}</div>`)}</div>`;
}
// ─── Sign in ─────────────────────────────────────────────
function SignIn({ onDone }) {
const [cfg, setCfg] = useState(null);
const [err, setErr] = useState('');
const [mode, setMode] = useState('google');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const btn = useRef();
useEffect(() => { api.get('/api/auth/config').then(setCfg).catch(() => setCfg({})); }, []);
useEffect(() => {
if (!cfg?.googleClientId || mode !== 'google') return;
let tries = 0;
const init = () => {
if (!window.google?.accounts?.id) { if (tries++ < 50) setTimeout(init, 100); else setErr("Couldn't load Google sign-in. Check your connection."); return; }
window.google.accounts.id.initialize({
client_id: cfg.googleClientId,
callback: async (resp) => {
setErr('');
try { await api.post('/api/auth/google', { credential: resp.credential }); onDone(); }
catch (e) { setErr(e.message); }
},
hd: cfg.allowedDomains?.length === 1 ? cfg.allowedDomains[0] : undefined,
ux_mode: 'popup',
});
window.google.accounts.id.renderButton(btn.current, { theme: 'outline', size: 'large', text: 'continue_with', shape: 'pill', width: 280 });
};
init();
}, [cfg, mode]);
const submitPassword = async (e) => {
e.preventDefault(); setErr('');
try { await api.post(mode === 'dev' ? '/api/auth/dev' : '/api/auth/password', { email, password, name: email.split('@')[0] }); onDone(); }
catch (e2) { setErr(e2.message); }
};
if (!cfg) return html`<div class="splash"><${Spinner} /></div>`;
return html`<div class="signin">
<div class="signin-card">
<span class="logo-mark big"><i /><i /><i /></span>
<h1>Waters Boards</h1>
<p class="muted">Sign in with your ${cfg.allowedDomains?.map((d) => '@' + d).join(' or ')} account</p>
${mode === 'google' ? html`
${cfg.googleClientId ? html`<div class="gbtn" ref=${btn} />` : html`<p class="warn-note">Google sign-in isn't set up on this server yet (GOOGLE_CLIENT_ID is empty). An admin can still sign in with a password.</p>`}
${cfg.passwordLogin ? html`<button class="link small" onClick=${() => setMode('password')}>Admin sign-in with password</button>` : null}
${cfg.devLogin ? html`<button class="link small" onClick=${() => setMode('dev')}>Developer sign-in (local only)</button>` : null}
` : html`<form class="signin-form" onSubmit=${submitPassword}>
<input class="input" type="email" placeholder="Email" value=${email} onInput=${(e) => setEmail(e.target.value)} autofocus required />
${mode === 'password' ? html`<input class="input" type="password" placeholder="Password" value=${password} onInput=${(e) => setPassword(e.target.value)} required />` : null}
<button class="btn primary">Sign in</button>
<button type="button" class="link small" onClick=${() => setMode('google')}>Back to Google sign-in</button>
</form>`}
${err ? html`<p class="error-note">${err}</p>` : null}
</div>
</div>`;
}
render(html`<${App} />`, document.getElementById('app'));
if ('serviceWorker' in navigator && location.protocol === 'https:') navigator.serviceWorker.register('/sw.js').catch(() => {});
+110
View File
@@ -0,0 +1,110 @@
// Automations: "When … then …" recipes for a board.
import { html, useState } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, useStore, loadBoard, toast } from './store.js';
import { Icon, ModalHeader, confirmDialog } from './ui.js';
const TRIGGERS = {
status_change: 'When a status changes',
column_change: 'When a column changes',
item_created: 'When an item is created',
date_arrives: 'When a date arrives',
};
const ACTIONS = {
notify: 'notify someone',
move_to_group: 'move the item to a group',
set_status: 'set a status',
assign: 'assign a person',
set_date_today: "set a date to today",
clear_column: 'clear a column',
archive: 'archive the item',
};
export function AutomationsModal({ boardId }) {
const s = useStore();
const b = s.boardData[boardId];
const [adding, setAdding] = useState(false);
if (!b) return null;
const toggle = async (a) => { await api.patch(`/api/automations/${a.id}`, { enabled: !a.enabled }); loadBoard(boardId); };
const remove = async (a) => { if (await confirmDialog({ title: 'Delete this automation?' })) { await api.del(`/api/automations/${a.id}`); loadBoard(boardId); } };
return html`<div class="dialog automations"><${ModalHeader} title="Automations" sub="Let the board do the repetitive work." />
${b.automations.map((a) => html`<div key=${a.id} class="auto-row ${a.enabled ? '' : 'off'}">
<${Icon} name="bolt" />
<span class="auto-text">${describe(b, a.trigger, a.action)}</span>
<label class="switch"><input type="checkbox" checked=${a.enabled} onChange=${() => toggle(a)} /><span /></label>
<button class="icon-btn" onClick=${() => remove(a)}><${Icon} name="trash" /></button>
</div>`)}
${!b.automations.length && !adding ? html`<p class="muted">No automations yet. Some ideas: notify the owner when an item is stuck, move items to "Done" when their status is Done, remind people on their due date.</p>` : null}
${adding ? html`<${Builder} board=${b} done=${() => setAdding(false)} />` : html`<button class="btn primary" onClick=${() => setAdding(true)}><${Icon} name="plus" /> Create automation</button>`}
</div>`;
}
function describe(b, t, a) {
const col = (id) => b.columns.find((c) => c.id === id)?.title || '?';
const label = (cid, lid) => b.columns.find((c) => c.id === cid)?.settings.labels?.find((l) => l.id === lid)?.name || 'anything';
const group = (id) => b.groups.find((g) => g.id === id)?.title || '?';
const user = (id) => state.usersById[id]?.name || '?';
let when = '';
if (t.type === 'status_change') when = html`When <b>${col(t.column_id)}</b> changes to <b>${t.to ? label(t.column_id, t.to) : 'anything'}</b>`;
else if (t.type === 'column_change') when = html`When <b>${col(t.column_id)}</b> changes`;
else if (t.type === 'item_created') when = html`When an item is created`;
else if (t.type === 'date_arrives') when = html`When <b>${col(t.column_id)}</b> arrives`;
let then = '';
if (a.type === 'notify') then = html`notify <b>${a.to === 'user' ? user(a.user_id) : `people in ${col(a.column_id)}`}</b>`;
else if (a.type === 'move_to_group') then = html`move item to <b>${group(a.group_id)}</b>`;
else if (a.type === 'set_status') then = html`set <b>${col(a.column_id)}</b> to <b>${label(a.column_id, a.label)}</b>`;
else if (a.type === 'assign') then = html`assign <b>${user(a.user_id)}</b> in <b>${col(a.column_id)}</b>`;
else if (a.type === 'set_date_today') then = html`set <b>${col(a.column_id)}</b> to today`;
else if (a.type === 'clear_column') then = html`clear <b>${col(a.column_id)}</b>`;
else if (a.type === 'archive') then = html`archive the item`;
return html`${when}, ${then}`;
}
function Builder({ board, done }) {
const cols = (types) => board.columns.filter((c) => types.includes(c.type));
const [t, setT] = useState({ type: 'status_change', column_id: cols(['status'])[0]?.id, to: '' });
const [a, setA] = useState({ type: 'notify', to: 'people_column', column_id: cols(['people'])[0]?.id });
const statusCol = board.columns.find((c) => c.id === t.column_id);
const save = async () => {
try {
await api.post(`/api/boards/${board.board.id}/automations`, { trigger: { ...t, to: t.to || null }, action: a });
loadBoard(board.board.id); done();
} catch (e) { toast(e.message, 'error'); }
};
const colSelect = (types, value, onChange) => html`<select class="input sm" value=${value || ''} onChange=${(e) => onChange(e.target.value)}>
<option value="" disabled>column…</option>${cols(types).map((c) => html`<option value=${c.id}>${c.title}</option>`)}</select>`;
const valid = (t.type === 'item_created' || t.column_id) && (
a.type === 'archive' || (a.type === 'notify' && (a.to === 'user' ? a.user_id : a.column_id)) || (a.type === 'move_to_group' && a.group_id) ||
(a.type === 'set_status' && a.column_id && a.label) || (a.type === 'assign' && a.column_id && a.user_id) || (['set_date_today', 'clear_column'].includes(a.type) && a.column_id));
return html`<div class="auto-builder">
<div class="auto-sentence">
<select class="input sm" value=${t.type} onChange=${(e) => {
const type = e.target.value;
const first = { status_change: cols(['status'])[0]?.id, date_arrives: cols(['date', 'timeline'])[0]?.id, column_change: board.columns[0]?.id }[type];
setT({ type, column_id: first, to: '' });
}}>${Object.entries(TRIGGERS).map(([k, l]) => html`<option value=${k}>${l}</option>`)}</select>
${t.type === 'status_change' ? html`${colSelect(['status'], t.column_id, (v) => setT({ ...t, column_id: v, to: '' }))} <span>changes to</span>
<select class="input sm" value=${t.to} onChange=${(e) => setT({ ...t, to: e.target.value })}><option value="">anything</option>
${(statusCol?.settings.labels || []).map((l) => html`<option value=${l.id}>${l.name}</option>`)}</select>` : null}
${t.type === 'column_change' ? colSelect(board.columns.map((c) => c.type), t.column_id, (v) => setT({ ...t, column_id: v })) : null}
${t.type === 'date_arrives' ? colSelect(['date', 'timeline'], t.column_id, (v) => setT({ ...t, column_id: v })) : null}
</div>
<div class="auto-sentence"><span>then</span>
<select class="input sm" value=${a.type} onChange=${(e) => setA({ type: e.target.value })}>${Object.entries(ACTIONS).map(([k, l]) => html`<option value=${k}>${l}</option>`)}</select>
${a.type === 'notify' ? html`
<select class="input sm" value=${a.to || 'people_column'} onChange=${(e) => setA({ ...a, to: e.target.value })}><option value="people_column">people in column</option><option value="user">a specific person</option></select>
${a.to === 'user' ? html`<select class="input sm" value=${a.user_id || ''} onChange=${(e) => setA({ ...a, user_id: e.target.value })}><option value="" disabled>person…</option>${state.users.filter((u) => u.active).map((u) => html`<option value=${u.id}>${u.name}</option>`)}</select>`
: colSelect(['people'], a.column_id, (v) => setA({ ...a, column_id: v }))}
<input class="input sm grow" placeholder="Message (optional)" value=${a.text || ''} onInput=${(e) => setA({ ...a, text: e.target.value })} />` : null}
${a.type === 'move_to_group' ? html`<select class="input sm" value=${a.group_id || ''} onChange=${(e) => setA({ ...a, group_id: e.target.value })}><option value="" disabled>group…</option>${board.groups.map((g) => html`<option value=${g.id}>${g.title}</option>`)}</select>` : null}
${a.type === 'set_status' ? html`${colSelect(['status'], a.column_id, (v) => setA({ ...a, column_id: v, label: '' }))}
<select class="input sm" value=${a.label || ''} onChange=${(e) => setA({ ...a, label: e.target.value })}><option value="" disabled>label…</option>
${(board.columns.find((c) => c.id === a.column_id)?.settings.labels || []).map((l) => html`<option value=${l.id}>${l.name}</option>`)}</select>` : null}
${a.type === 'assign' ? html`${colSelect(['people'], a.column_id, (v) => setA({ ...a, column_id: v }))}
<select class="input sm" value=${a.user_id || ''} onChange=${(e) => setA({ ...a, user_id: e.target.value })}><option value="" disabled>person…</option>${state.users.filter((u) => u.active).map((u) => html`<option value=${u.id}>${u.name}</option>`)}</select>` : null}
${a.type === 'set_date_today' ? colSelect(['date'], a.column_id, (v) => setA({ ...a, column_id: v })) : null}
${a.type === 'clear_column' ? colSelect(board.columns.map((c) => c.type), a.column_id, (v) => setA({ ...a, column_id: v })) : null}
</div>
<div class="modal-actions"><button class="btn ghost" onClick=${done}>Cancel</button><button class="btn primary" disabled=${!valid} onClick=${save}>Create automation</button></div>
</div>`;
}
+349
View File
@@ -0,0 +1,349 @@
// Board page: header, view tabs, toolbar (search / person / filter / sort / hide) and the active view.
import { html, useState, useEffect, useMemo } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, update, useStore, loadBoard, loadTree, navigate, boardPath, mutateBoard, serverCall, toast } from './store.js';
import { Icon, Avatar, AvatarStack, InlineEdit, menu, openPopover, openModal, closeModal, ModalHeader, Spinner, Empty, promptDialog, confirmDialog, useMobile } from './ui.js';
import { COLUMN_TYPES, cellText } from './cells.js';
import * as A from './actions.js';
import { TableView } from './table.js';
import { KanbanView } from './kanban.js';
import { CalendarView } from './calendar.js';
import { TimelineView } from './timeline.js';
import { ChartView, FilesView } from './chart.js';
import { ItemPanel } from './item.js';
import { AutomationsModal } from './automations.js';
import { ActivityModal, ImportCsvModal } from './boardModals.js';
export const VIEW_TYPES = {
table: { label: 'Table', icon: 'table', desc: 'Groups, columns and items' },
kanban: { label: 'Kanban', icon: 'kanban', desc: 'Cards by status' },
calendar: { label: 'Calendar', icon: 'calendar', desc: 'Items by date' },
timeline: { label: 'Timeline', icon: 'timeline', desc: 'Gantt-style schedule' },
chart: { label: 'Chart', icon: 'chart', desc: 'Counts by status, person…' },
files: { label: 'Files', icon: 'paperclip', desc: 'Every file on the board' },
};
// Ephemeral per-board filter state (not shared with others).
const filters = {};
export function getFilter(boardId) { return (filters[boardId] ||= { q: '', people: [], rules: [], sort: null }); }
function setFilter(boardId, patch) { Object.assign(getFilter(boardId), patch); update(); }
const activeFilterCount = (f) => (f.q ? 1 : 0) + (f.people.length ? 1 : 0) + f.rules.length;
// Returns top-level items passing the filter. A parent stays visible if any subitem matches.
export function filterItems(board, f) {
const subs = {};
for (const it of board.items) if (it.parent_id) (subs[it.parent_id] ||= []).push(it);
const q = f.q.trim().toLowerCase();
const peopleCols = board.columns.filter((c) => c.type === 'people');
const matches = (it) => {
if (q) {
const hay = [it.name, ...board.columns.map((c) => cellText(board, c, it))].join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
if (f.people.length && !peopleCols.some((c) => (it.vals[c.id] || []).some((u) => f.people.includes(u)))) return false;
for (const r of f.rules) {
const col = board.columns.find((c) => c.id === r.colId);
if (!col) continue;
const v = it.vals[col.id];
const empty = v == null || v === '' || (Array.isArray(v) && !v.length);
if (r.op === 'empty' && !empty) return false;
if (r.op === 'not_empty' && empty) return false;
if (r.op === 'is' || r.op === 'is_not') {
const vs = Array.isArray(v) ? v : [v ?? '__empty'];
const hit = r.values.some((x) => vs.includes(x) || (x === '__empty' && empty));
if (r.op === 'is' && !hit) return false;
if (r.op === 'is_not' && hit) return false;
}
if (r.op === 'contains' && !cellText(board, col, it).toLowerCase().includes(String(r.values[0] || '').toLowerCase())) return false;
if (r.op === 'before' || r.op === 'after') {
const d = col.type === 'timeline' ? v?.from : v && String(v).slice(0, 10);
if (!d) return false;
if (r.op === 'before' && !(d < r.values[0])) return false;
if (r.op === 'after' && !(d > r.values[0])) return false;
}
}
return true;
};
const anyFilter = activeFilterCount(f) > 0;
let out = board.items.filter((it) => !it.parent_id && (!anyFilter || matches(it) || (subs[it.id] || []).some(matches)));
if (f.sort) {
const col = f.sort.colId === '__name' ? null : board.columns.find((c) => c.id === f.sort.colId);
const key = (it) => {
if (!col) return it.name.toLowerCase();
const v = it.vals[col.id];
if (col.type === 'number' || col.type === 'rating') return v ?? -Infinity;
if (col.type === 'date') return v || '';
if (col.type === 'timeline') return v?.from || '';
if (col.type === 'status') { const i = (col.settings.labels || []).findIndex((l) => l.id === v); return i < 0 ? 999 : i; }
if (col.type === 'created') return it.created_at;
if (col.type === 'updated') return it.updated_at;
return cellText(board, col, it).toLowerCase();
};
const dir = f.sort.dir === 'desc' ? -1 : 1;
out = [...out].sort((a, b) => { const x = key(a); const y = key(b); return (x > y ? 1 : x < y ? -1 : 0) * dir; });
} else out = [...out].sort((a, b) => a.position - b.position);
return { items: out, subs, matches: anyFilter ? matches : null };
}
export function BoardPage({ boardId, viewId, itemId }) {
const s = useStore();
const mobile = useMobile();
const data = s.boardData[boardId];
useEffect(() => { loadBoard(boardId).catch(() => {}); }, [boardId]);
if (!data) return html`<div class="page-loading"><${Spinner} /></div>`;
if (data.missing) return html`<${Empty} title="This board doesn't exist anymore"><button class="btn primary" onClick=${() => navigate('/')}>Go home</button><//>`;
const view = data.views.find((v) => v.id === viewId) || data.views[0];
const f = getFilter(boardId);
const filtered = filterItems(data, f);
const hidden = view?.settings?.hidden || [];
const ViewComp = { table: TableView, kanban: KanbanView, calendar: CalendarView, timeline: TimelineView, chart: ChartView, files: FilesView }[view?.type] || TableView;
return html`<div class="board-page">
<${BoardHeader} data=${data} view=${view} mobile=${mobile} />
<${Toolbar} data=${data} view=${view} f=${f} mobile=${mobile} />
<div class="board-body view-${view?.type}">
<${ViewComp} key=${view?.id} board=${data} view=${view} items=${filtered.items} subs=${filtered.subs} matches=${filtered.matches} hidden=${hidden} filter=${f} mobile=${mobile} />
</div>
${itemId ? html`<${ItemPanel} key=${itemId} board=${data} itemId=${itemId} viewId=${view?.id} />` : null}
</div>`;
}
function BoardHeader({ data, view, mobile }) {
const b = data.board;
const people = useMemo(() => {
const ids = new Set();
for (const c of data.columns) if (c.type === 'people') for (const it of data.items) for (const u of it.vals[c.id] || []) ids.add(u);
return [...ids];
}, [data]);
const openMenu = (e) => menu(e, [
{ label: 'Rename board', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Rename board', value: b.name }); if (n) A.renameBoard(b.id, n); } },
{ label: 'Edit description', icon: 'info', onClick: async () => {
const n = await promptDialog({ title: 'Board description', value: b.description || '', confirm: 'Save' });
if (n != null) { mutateBoard(b.id, (d) => { d.board.description = n; }); api.patch(`/api/boards/${b.id}`, { description: n }); }
} },
{ label: 'Rename items…', icon: 'edit', hint: b.item_label, onClick: async () => {
const n = await promptDialog({ title: 'What do you call items on this board?', value: b.item_label, placeholder: 'e.g. Task, Request, Equipment' });
if (n) { mutateBoard(b.id, (d) => { d.board.item_label = n; }); api.patch(`/api/boards/${b.id}`, { item_label: n }); }
} },
{ divider: true },
{ label: 'Move to…', icon: 'move', onClick: () => openModal(MoveBoardModal, { board: b }) },
{ label: 'Duplicate board', icon: 'copy', onClick: () => A.duplicateBoard(b.id) },
{ label: 'Automations', icon: 'bolt', onClick: () => openModal(AutomationsModal, { boardId: b.id }, { class: 'wide' }) },
{ label: 'Activity log', icon: 'activity', onClick: () => openModal(ActivityModal, { boardId: b.id }, { class: 'wide' }) },
{ divider: true },
{ label: 'Export to CSV / Excel', icon: 'download', onClick: () => { location.href = `/api/boards/${b.id}/export.csv`; } },
{ label: 'Import from CSV', icon: 'upload', onClick: () => openModal(ImportCsvModal, { boardId: b.id }) },
{ divider: true },
{ label: 'Delete board', icon: 'trash', danger: true, onClick: () => A.deleteBoard(b.id) },
], { align: 'right' });
return html`<header class="board-header">
<div class="board-title-row">
${mobile ? html`<button class="icon-btn" onClick=${() => update({ sidebarOpen: true })} aria-label="Menu"><${Icon} name="menu" size=${20} /></button>` : null}
<h1 class="board-title"><${InlineEdit} value=${b.name} onSave=${(n) => n && A.renameBoard(b.id, n)} /></h1>
<button class="icon-btn star ${data.favorite ? 'on' : ''}" onClick=${() => A.toggleFavorite(b.id)} title="Favorite"><${Icon} name="star" fill=${data.favorite} /></button>
<div class="spacer" />
${!mobile ? html`
<button class="btn ghost sm" onClick=${() => openModal(ActivityModal, { boardId: b.id }, { class: 'wide' })}><${Icon} name="activity" /> Activity</button>
<button class="btn ghost sm" onClick=${() => openModal(AutomationsModal, { boardId: b.id }, { class: 'wide' })}><${Icon} name="bolt" /> Automate${data.automations.length ? ` / ${data.automations.filter((a) => a.enabled).length}` : ''}</button>
<span class="header-people" title="People on this board"><${AvatarStack} ids=${people} size=${26} max=${4} /></span>` : null}
<button class="icon-btn" onClick=${openMenu} aria-label="Board menu"><${Icon} name="dots" size=${20} /></button>
</div>
${b.description && !mobile ? html`<p class="board-desc">${b.description}</p>` : null}
<${ViewTabs} data=${data} view=${view} />
</header>`;
}
function ViewTabs({ data, view }) {
const b = data.board;
const addView = (e) => menu(e, Object.entries(VIEW_TYPES).map(([type, t]) => ({
label: t.label, icon: t.icon, hint: t.desc,
onClick: async () => {
const r = await serverCall(b.id, api.post(`/api/boards/${b.id}/views`, { type, name: t.label }));
await loadBoard(b.id);
navigate(boardPath(b.id, { viewId: r.id }));
},
})));
const viewMenu = (e, v) => menu(e, [
{ label: 'Rename', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Rename view', value: v.name }); if (n) { mutateBoard(b.id, () => { v.name = n; }); api.patch(`/api/views/${v.id}`, { name: n }); } } },
{ label: 'Delete', icon: 'trash', danger: true, disabled: data.views.length <= 1, onClick: async () => {
if (!(await confirmDialog({ title: `Delete view "${v.name}"?`, body: 'Only the view is removed — items are untouched.' }))) return;
await serverCall(b.id, api.del(`/api/views/${v.id}`)); await loadBoard(b.id); navigate(boardPath(b.id));
} },
]);
return html`<nav class="view-tabs">
${data.views.map((v) => html`<button key=${v.id} class="view-tab ${v.id === view?.id ? 'active' : ''}"
onClick=${() => navigate(boardPath(b.id, { viewId: v.id }))} onContextMenu=${(e) => viewMenu(e, v)}>
<${Icon} name=${VIEW_TYPES[v.type]?.icon || 'table'} size=${14} /> ${v.name}
${v.id === view?.id ? html`<span class="tab-dots" onClick=${(e) => viewMenu(e, v)}><${Icon} name="dots" size=${14} /></span>` : null}
</button>`)}
<button class="view-tab add" onClick=${addView} title="Add view"><${Icon} name="plus" size=${14} /></button>
</nav>`;
}
function Toolbar({ data, view, f, mobile }) {
const b = data.board;
const [searchOpen, setSearchOpen] = useState(!!f.q);
const firstGroup = [...data.groups].sort((a, z) => a.position - z.position)[0];
const newItem = async () => {
const it = await A.createItem(b.id, { group_id: firstGroup?.id, name: `New ${b.item_label.toLowerCase()}`, top: true });
if (it) navigate(boardPath(b.id, { viewId: view?.id, itemId: it.id }));
};
const hidden = view?.settings?.hidden || [];
const nPeople = f.people.length;
return html`<div class="toolbar">
<div class="split-btn">
<button class="btn primary sm" onClick=${newItem}>New ${b.item_label.toLowerCase()}</button>
<button class="btn primary sm caret" onClick=${(e) => menu(e, [
{ label: 'New group of items', icon: 'group', onClick: () => A.addGroup(b.id) },
{ label: 'Import items from CSV', icon: 'upload', onClick: () => openModal(ImportCsvModal, { boardId: b.id }) },
])}><${Icon} name="chevDown" size=${14} /></button>
</div>
${searchOpen || f.q ? html`<div class="tb-search"><${Icon} name="search" />
<input placeholder="Search this board" value=${f.q} autofocus onInput=${(e) => setFilter(b.id, { q: e.target.value })}
onBlur=${() => { if (!f.q) setSearchOpen(false); }} onKeyDown=${(e) => { if (e.key === 'Escape') { setFilter(b.id, { q: '' }); setSearchOpen(false); } }} />
${f.q ? html`<button class="icon-btn" onClick=${() => setFilter(b.id, { q: '' })}><${Icon} name="x" size=${14} /></button>` : null}</div>`
: html`<button class="tb-btn" onClick=${() => setSearchOpen(true)}><${Icon} name="search" /> ${mobile ? '' : 'Search'}</button>`}
<button class="tb-btn ${nPeople ? 'on' : ''}" onClick=${(e) => openPopover(e.currentTarget, PersonFilter, { boardId: b.id })}>
${nPeople === 1 ? html`<${Avatar} user=${state.usersById[f.people[0]]} size=${20} />` : html`<${Icon} name="person" />`} ${mobile ? '' : 'Person'}${nPeople > 1 ? ` / ${nPeople}` : ''}</button>
<button class="tb-btn ${f.rules.length ? 'on' : ''}" onClick=${(e) => openPopover(e.currentTarget, FilterBuilder, { boardId: b.id }, { keepOnScroll: true })}>
<${Icon} name="filter" /> ${mobile ? '' : 'Filter'}${f.rules.length ? ` / ${f.rules.length}` : ''}</button>
<button class="tb-btn ${f.sort ? 'on' : ''}" onClick=${(e) => openPopover(e.currentTarget, SortBuilder, { boardId: b.id })}>
<${Icon} name="sort" /> ${mobile ? '' : 'Sort'}</button>
${view?.type === 'table' || view?.type === 'kanban' ? html`<button class="tb-btn ${hidden.length ? 'on' : ''}" onClick=${(e) => openPopover(e.currentTarget, HideColumns, { boardId: b.id, view })}>
<${Icon} name="eyeOff" /> ${mobile ? '' : 'Hide'}${hidden.length ? ` / ${hidden.length}` : ''}</button>` : null}
${activeFilterCount(f) || f.sort ? html`<button class="tb-btn subtle" onClick=${() => setFilter(b.id, { q: '', people: [], rules: [], sort: null })}>Clear all</button>` : null}
</div>`;
}
function PersonFilter({ boardId }) {
const s = useStore();
const f = getFilter(boardId);
const data = s.boardData[boardId];
const ids = new Set();
for (const c of data.columns) if (c.type === 'people') for (const it of data.items) for (const u of it.vals[c.id] || []) ids.add(u);
const users = [...ids].map((i) => s.usersById[i]).filter(Boolean);
const toggle = (id) => setFilter(boardId, { people: f.people.includes(id) ? f.people.filter((x) => x !== id) : [...f.people, id] });
return html`<div class="pop-pad person-filter">
<div class="pop-title">Filter by person</div>
<div class="avatar-row">
${users.map((u) => html`<button key=${u.id} class="avatar-toggle ${f.people.includes(u.id) ? 'on' : ''}" onClick=${() => toggle(u.id)} title=${u.name}><${Avatar} user=${u} size=${32} /></button>`)}
${!users.length ? html`<span class="muted">No one is assigned on this board yet.</span>` : null}
</div>
${s.me && ids.has(s.me.id) ? html`<button class="btn ghost sm" onClick=${() => setFilter(boardId, { people: [s.me.id] })}>Just me</button>` : null}
</div>`;
}
const OPS = {
status: [['is', 'is'], ['is_not', 'is not']], dropdown: [['is', 'is'], ['is_not', 'is not'], ['empty', 'is empty']],
people: [['is', 'is'], ['is_not', 'is not'], ['empty', 'is empty']],
date: [['before', 'is before'], ['after', 'is after'], ['empty', 'is empty'], ['not_empty', 'is not empty']],
timeline: [['before', 'starts before'], ['after', 'starts after'], ['empty', 'is empty']],
checkbox: [['not_empty', 'is checked'], ['empty', 'is not checked']],
};
const defaultOps = [['contains', 'contains'], ['empty', 'is empty'], ['not_empty', 'is not empty']];
function FilterBuilder({ boardId }) {
const s = useStore();
const data = s.boardData[boardId];
const f = getFilter(boardId);
const cols = data.columns.filter((c) => !['files', 'item_id', 'updated', 'created'].includes(c.type));
const setRules = (rules) => setFilter(boardId, { rules });
const add = () => { const c = cols[0]; if (c) setRules([...f.rules, { colId: c.id, op: (OPS[c.type] || defaultOps)[0][0], values: [] }]); };
return html`<div class="pop-pad filter-builder">
<div class="pop-title">Advanced filters <span class="muted small">Only you see these</span></div>
${f.rules.map((r, i) => {
const col = data.columns.find((c) => c.id === r.colId);
const ops = OPS[col?.type] || defaultOps;
const set = (patch) => setRules(f.rules.map((x, j) => (j === i ? { ...x, ...patch } : x)));
return html`<div key=${i} class="filter-rule">
<span class="muted small">${i === 0 ? 'Where' : 'And'}</span>
<select class="input sm" value=${r.colId} onChange=${(e) => { const c = data.columns.find((x) => x.id === e.target.value); set({ colId: c.id, op: (OPS[c.type] || defaultOps)[0][0], values: [] }); }}>
${cols.map((c) => html`<option value=${c.id}>${c.title}</option>`)}
</select>
<select class="input sm" value=${r.op} onChange=${(e) => set({ op: e.target.value })}>${ops.map(([v, l]) => html`<option value=${v}>${l}</option>`)}</select>
<${RuleValue} col=${col} rule=${r} set=${set} />
<button class="icon-btn" onClick=${() => setRules(f.rules.filter((_, j) => j !== i))}><${Icon} name="x" /></button>
</div>`;
})}
<div class="row gap"><button class="btn ghost sm" onClick=${add}><${Icon} name="plus" /> New filter</button>
${f.rules.length ? html`<button class="btn ghost sm" onClick=${() => setRules([])}>Clear</button>` : null}</div>
${!f.rules.length ? html`<div class="quick-filters">${data.columns.filter((c) => c.type === 'status').slice(0, 2).map((c) => html`
<div key=${c.id} class="qf-col"><div class="qf-title">${c.title}</div>
${(c.settings.labels || []).map((l) => html`<button key=${l.id} class="qf-opt" onClick=${() => setRules([{ colId: c.id, op: 'is', values: [l.id] }])}>
<i style=${{ background: l.color }} />${l.name}<span class="muted">${data.items.filter((it) => it.vals[c.id] === l.id).length}</span></button>`)}</div>`)}</div>` : null}
</div>`;
}
function RuleValue({ col, rule, set }) {
if (!col || ['empty', 'not_empty'].includes(rule.op)) return html`<span />`;
if (col.type === 'status' || col.type === 'dropdown') {
return html`<select class="input sm" multiple size="3" onChange=${(e) => set({ values: [...e.target.selectedOptions].map((o) => o.value) })}>
${(col.settings.labels || []).map((l) => html`<option value=${l.id} selected=${rule.values.includes(l.id)}>${l.name}</option>`)}
${col.type === 'status' ? html`<option value="__empty" selected=${rule.values.includes('__empty')}>(empty)</option>` : null}
</select>`;
}
if (col.type === 'people') {
return html`<select class="input sm" multiple size="3" onChange=${(e) => set({ values: [...e.target.selectedOptions].map((o) => o.value) })}>
${state.users.map((u) => html`<option value=${u.id} selected=${rule.values.includes(u.id)}>${u.name}</option>`)}</select>`;
}
if (col.type === 'date' || col.type === 'timeline') {
return html`<input type="date" class="input sm" value=${rule.values[0] || ''} onInput=${(e) => set({ values: [e.target.value] })} />`;
}
return html`<input class="input sm" value=${rule.values[0] || ''} placeholder="Value" onInput=${(e) => set({ values: [e.target.value] })} />`;
}
function SortBuilder({ boardId }) {
const s = useStore();
const data = s.boardData[boardId];
const f = getFilter(boardId);
const sort = f.sort || { colId: '', dir: 'asc' };
return html`<div class="pop-pad sort-builder">
<div class="pop-title">Sort by</div>
<div class="row gap">
<select class="input sm" value=${sort.colId} onChange=${(e) => setFilter(boardId, { sort: e.target.value ? { ...sort, colId: e.target.value } : null })}>
<option value="">Choose column</option>
<option value="__name">${data.board.item_label}</option>
${data.columns.map((c) => html`<option value=${c.id}>${c.title}</option>`)}
</select>
<select class="input sm" value=${sort.dir} disabled=${!f.sort} onChange=${(e) => setFilter(boardId, { sort: { ...sort, dir: e.target.value } })}>
<option value="asc">Ascending</option><option value="desc">Descending</option>
</select>
${f.sort ? html`<button class="icon-btn" onClick=${() => setFilter(boardId, { sort: null })}><${Icon} name="x" /></button>` : null}
</div>
</div>`;
}
function HideColumns({ boardId, view }) {
const s = useStore();
const data = s.boardData[boardId];
const v = data.views.find((x) => x.id === view.id) || view;
const hidden = v.settings?.hidden || [];
const save = (h) => { mutateBoard(boardId, () => { v.settings = { ...v.settings, hidden: h }; }); api.patch(`/api/views/${v.id}`, { settings: { ...v.settings, hidden: h } }); };
return html`<div class="pop-pad hide-cols">
<div class="pop-title">Display columns <span class="muted small">Saved for everyone on this view</span></div>
<label class="check-row"><input type="checkbox" checked=${!hidden.length} onChange=${(e) => save(e.target.checked ? [] : data.columns.map((c) => c.id))} /> All columns</label>
${data.columns.map((c) => html`<label key=${c.id} class="check-row">
<input type="checkbox" checked=${!hidden.includes(c.id)} onChange=${(e) => save(e.target.checked ? hidden.filter((x) => x !== c.id) : [...hidden, c.id])} />
<span class="type-dot" style=${{ background: COLUMN_TYPES[c.type]?.color }}><${Icon} name=${COLUMN_TYPES[c.type]?.icon || 'edit'} size=${11} /></span>${c.title}</label>`)}
</div>`;
}
function MoveBoardModal({ board }) {
const s = useStore();
const [ws, setWs] = useState(board.workspace_id);
const [folder, setFolder] = useState(board.folder_id || '');
const save = async () => {
await api.patch(`/api/boards/${board.id}`, { workspace_id: ws, folder_id: folder || null });
await loadTree(); loadBoard(board.id);
closeModal();
toast('Board moved');
};
return html`<div class="dialog"><${ModalHeader} title="Move board" />
<label class="field-label">Workspace</label>
<select class="input" value=${ws} onChange=${(e) => { setWs(e.target.value); setFolder(''); }}>${s.workspaces.map((w) => html`<option value=${w.id}>${w.name}</option>`)}</select>
<label class="field-label">Folder</label>
<select class="input" value=${folder} onChange=${(e) => setFolder(e.target.value)}>
<option value="">(no folder)</option>
${s.folders.filter((f) => f.workspace_id === ws).map((f) => html`<option value=${f.id}>${f.parent_id ? '— ' : ''}${f.name}</option>`)}
</select>
<div class="modal-actions"><button class="btn primary" onClick=${save}>Move</button></div></div>`;
}
+96
View File
@@ -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>`;
}
+86
View File
@@ -0,0 +1,86 @@
// Calendar: month grid of items by a date (or timeline) column; drag to reschedule.
import { html, useState } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { navigate, boardPath, mutateBoard } from './store.js';
import { Icon, ymd, parseDate, todayStr, textOn, Empty, useMobile } from './ui.js';
import { labelOf } from './cells.js';
import * as A from './actions.js';
export function CalendarView({ board, view, items, subs }) {
const boardId = board.board.id;
const mobile = useMobile();
const dateCols = board.columns.filter((c) => c.type === 'date' || c.type === 'timeline');
const col = dateCols.find((c) => c.id === view.settings?.column) || dateCols[0];
const status = board.columns.find((c) => c.type === 'status');
const [month, setMonth] = useState(() => { const d = new Date(); return new Date(d.getFullYear(), d.getMonth(), 1); });
const [drag, setDrag] = useState(null);
if (!col) return html`<${Empty} icon="calendar" title="Calendar needs a date column"><button class="btn primary" onClick=${() => A.addColumn(boardId, 'date')}>Add a date column</button><//>`;
const all = [...items, ...items.flatMap((i) => subs[i.id] || [])];
const byDay = {};
for (const it of all) {
const v = it.vals[col.id];
if (!v) continue;
if (col.type === 'date') (byDay[String(v).slice(0, 10)] ||= []).push(it);
else {
const a = parseDate(v.from); const b = parseDate(v.to || v.from);
for (let d = new Date(a); d <= b && (d - a) < 400 * 86400000; d.setDate(d.getDate() + 1)) (byDay[ymd(d)] ||= []).push(it);
}
}
const start = new Date(month); start.setDate(1 - start.getDay());
const days = Array.from({ length: 42 }, (_, i) => { const d = new Date(start); d.setDate(start.getDate() + i); return d; });
const setCol = (id) => { mutateBoard(boardId, () => { view.settings = { ...view.settings, column: id }; }); api.patch(`/api/views/${view.id}`, { settings: { ...view.settings, column: id } }); };
const drop = (day) => {
if (!drag) return;
const v = drag.vals[col.id];
if (col.type === 'date') A.setValue(boardId, drag.id, col.id, String(v || '').includes('T') ? `${day}T${String(v).split('T')[1]}` : day);
else {
const len = v?.from ? (parseDate(v.to) - parseDate(v.from)) : 0;
const to = new Date(parseDate(day).getTime() + len);
A.setValue(boardId, drag.id, col.id, { from: day, to: ymd(to) });
}
setDrag(null);
};
const chip = (it) => {
const l = status && labelOf(status, it.vals[status.id]);
return html`<button key=${it.id} class="cal-item" draggable="true" onDragStart=${() => setDrag(it)}
style=${{ background: l?.color || 'var(--primary)', color: textOn(l?.color || '#0073ea') }}
onClick=${() => navigate(boardPath(boardId, { viewId: view.id, itemId: it.id }))}>${it.name}</button>`;
};
if (mobile) {
// Agenda list on phones.
const keys = Object.keys(byDay).filter((k) => k.slice(0, 7) === ymd(month).slice(0, 7)).sort();
return html`<div class="calendar mobile">
<${CalHead} month=${month} setMonth=${setMonth} dateCols=${dateCols} col=${col} setCol=${setCol} />
${keys.map((k) => html`<div key=${k} class="agenda-day"><div class="agenda-date ${k === todayStr() ? 'today' : ''}">${parseDate(k).toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })}</div>
<div class="agenda-items">${byDay[k].map(chip)}</div></div>`)}
${!keys.length ? html`<p class="muted pad">Nothing scheduled this month.</p>` : null}
</div>`;
}
return html`<div class="calendar">
<${CalHead} month=${month} setMonth=${setMonth} dateCols=${dateCols} col=${col} setCol=${setCol} />
<div class="cal-grid">
${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => html`<div key=${d} class="cal-dow">${d}</div>`)}
${days.map((d) => {
const k = ymd(d); const list = byDay[k] || [];
return html`<div key=${k} class="cal-day ${d.getMonth() !== month.getMonth() ? 'out' : ''} ${k === todayStr() ? 'today' : ''}"
onDragOver=${(e) => e.preventDefault()} onDrop=${() => drop(k)}
onDblClick=${async () => { const it = await A.createItem(boardId, { group_id: board.groups[0]?.id, name: 'New item', vals: { [col.id]: col.type === 'date' ? k : { from: k, to: k } } }); if (it) navigate(boardPath(boardId, { viewId: view.id, itemId: it.id })); }}>
<span class="cal-num">${d.getDate()}</span>
<div class="cal-items">${list.slice(0, 4).map(chip)}${list.length > 4 ? html`<span class="muted small">+${list.length - 4} more</span>` : null}</div>
</div>`;
})}
</div>
<p class="muted small pad-x">Drag items to reschedule · double-click a day to add an item</p>
</div>`;
}
function CalHead({ month, setMonth, dateCols, col, setCol }) {
return html`<div class="cal-head">
<button class="btn ghost sm" onClick=${() => { const d = new Date(); setMonth(new Date(d.getFullYear(), d.getMonth(), 1)); }}>Today</button>
<button class="icon-btn" onClick=${() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}><${Icon} name="chevLeft" /></button>
<button class="icon-btn" onClick=${() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}><${Icon} name="chevRight" /></button>
<h3>${month.toLocaleString([], { month: 'long', year: 'numeric' })}</h3>
<span class="spacer" />
${dateCols.length > 1 ? html`<select class="input sm" value=${col.id} onChange=${(e) => setCol(e.target.value)}>${dateCols.map((c) => html`<option value=${c.id}>${c.title}</option>`)}</select>` : null}
</div>`;
}
+388
View File
@@ -0,0 +1,388 @@
// Column types: metadata, cell display/editing, pickers and group-footer summaries.
import { html, useState, useRef, useEffect } from '../vendor/preact-htm.mjs';
import { state, toast } from './store.js';
import { api } from './api.js';
import { setValue, patchColumn } from './actions.js';
import {
Icon, Avatar, AvatarStack, openPopover, closePopover, InlineEdit, fmtDate, fmtRange, parseDate, ymd, todayStr,
textOn, LABEL_COLORS, fileUrl, isImage, fmtSize, timeAgo, ColorPicker,
} from './ui.js';
export const COLUMN_TYPES = {
status: { label: 'Status', icon: 'check', color: '#00c875' },
priority: { label: 'Priority', icon: 'bolt', color: '#401694' },
text: { label: 'Text', icon: 'edit', color: '#fdab3d' },
people: { label: 'People', icon: 'person', color: '#579bfc' },
date: { label: 'Date', icon: 'calendar', color: '#a25ddc' },
timeline: { label: 'Timeline', icon: 'timeline', color: '#9d50dd' },
number: { label: 'Numbers', icon: 'chart', color: '#ffcb00' },
long_text: { label: 'Long text', icon: 'message', color: '#ff642e' },
dropdown: { label: 'Dropdown', icon: 'chevDown', color: '#00a9ff' },
tags: { label: 'Tags', icon: 'pin', color: '#00c875' },
checkbox: { label: 'Checkbox', icon: 'check', color: '#ff7575' },
files: { label: 'Files', icon: 'paperclip', color: '#f65f7c' },
link: { label: 'Link', icon: 'link', color: '#66ccff' },
email: { label: 'Email', icon: 'message', color: '#0086c0' },
phone: { label: 'Phone', icon: 'message', color: '#037f4c' },
rating: { label: 'Rating', icon: 'star', color: '#ffcb00' },
created: { label: 'Creation log', icon: 'activity', color: '#7f5347' },
updated: { label: 'Last updated', icon: 'refresh', color: '#7f5347' },
item_id: { label: 'Item ID', icon: 'info', color: '#676879' },
};
export const DEFAULT_WIDTH = { status: 140, people: 110, date: 120, timeline: 170, number: 110, checkbox: 90, rating: 130, files: 120, text: 170, long_text: 200, dropdown: 170, tags: 170, link: 170, email: 180, phone: 140, created: 160, updated: 140, item_id: 110 };
export const colWidth = (c) => c.width || DEFAULT_WIDTH[c.type] || 150;
export function labelOf(col, id) { return (col.settings?.labels || []).find((l) => l.id === id); }
export function isDoneLabel(l) { return !!l && (l.done || /^done$/i.test(l.name)); }
// Is the item "done"? — first status column with a done label.
export function itemDone(board, item) {
for (const c of board.columns) if (c.type === 'status' && isDoneLabel(labelOf(c, item.vals[c.id]))) return true;
return false;
}
// Plain-text value, for search, sort and filters.
export function cellText(board, col, item) {
const v = item.vals[col.id];
switch (col.type) {
case 'status': return labelOf(col, v)?.name || '';
case 'dropdown': return (v || []).map((x) => labelOf(col, x)?.name).filter(Boolean).join(', ');
case 'people': return (v || []).map((u) => state.usersById[u]?.name).filter(Boolean).join(', ');
case 'timeline': return v?.from ? `${v.from} ${v.to}` : '';
case 'link': return v ? (v.text || v.url || '') : '';
case 'tags': return (v || []).join(', ');
case 'checkbox': return v ? 'checked' : '';
case 'files': return (board.files || []).filter((f) => (v || []).includes(f.id)).map((f) => f.name).join(', ');
case 'created': return state.usersById[item.created_by]?.name || '';
default: return v == null ? '' : String(v);
}
}
// ─── Cell ────────────────────────────────────────────────
export function Cell({ board, col, item, compact }) {
const v = item.vals[col.id];
const boardId = board.board.id;
const set = (val) => setValue(boardId, item.id, col.id, val);
const open = (e, Comp, extra = {}, opts = {}) => { e.stopPropagation(); openPopover(e.currentTarget, Comp, { board, col, item, set, ...extra }, opts); };
switch (col.type) {
case 'status': {
const l = labelOf(col, v);
return html`<button class="cell-status" style=${{ background: l?.color || 'var(--status-empty)', color: l ? textOn(l.color) : 'transparent' }}
onClick=${(e) => open(e, StatusPicker, {}, { align: 'center' })}>
<span class="fold" />${l?.name || '-'}</button>`;
}
case 'people':
return html`<button class="cell-people" onClick=${(e) => open(e, PeoplePicker, {}, { align: 'center' })}>
<${AvatarStack} ids=${v || []} size=${compact ? 24 : 28} /></button>`;
case 'date': {
const d = parseDate(v);
const overdue = d && ymd(d) < todayStr() && !itemDone(board, item);
const today = d && ymd(d) === todayStr();
return html`<button class="cell-date ${overdue ? 'overdue' : ''}" onClick=${(e) => open(e, DatePicker, {}, { align: 'center' })}>
${d ? html`${overdue ? html`<span class="warn" title="Overdue">!</span>` : today ? html`<span class="today-dot" />` : null}${fmtDate(v)}` : html`<span class="ghost-icon"><${Icon} name="calendar" /></span>`}
</button>`;
}
case 'timeline': {
const g = board.groups.find((x) => x.id === (item.group_id || board.items.find((i) => i.id === item.parent_id)?.group_id));
const pct = v?.from ? timelineProgress(v) : 0;
return html`<button class="cell-timeline" onClick=${(e) => open(e, DatePicker, { range: true }, { align: 'center' })}>
${v?.from ? html`<span class="tl-bar" style=${{ background: `linear-gradient(90deg, ${g?.color || '#579bfc'} ${pct}%, var(--tl-rest) ${pct}%)` }}><span>${fmtRange(v)}</span></span>`
: html`<span class="tl-bar empty"><span>-</span></span>`}
</button>`;
}
case 'number':
return html`<div class="cell-number"><${InlineEdit} value=${v == null ? '' : String(v)} placeholder=""
onSave=${(s) => { const n = s === '' ? null : Number(s.replace(/[^0-9.\-]/g, '')); if (s !== '' && !Number.isFinite(n)) return toast('Numbers only', 'error'); set(n); }} />
${v != null && col.settings?.unit ? html`<span class="unit">${col.settings.unit}</span>` : null}</div>`;
case 'checkbox':
return html`<label class="cell-check" onClick=${(e) => e.stopPropagation()}><input type="checkbox" checked=${!!v} onChange=${(e) => set(e.target.checked || null)} /><span /></label>`;
case 'rating': {
const max = col.settings?.max || 5;
return html`<div class="cell-rating">${Array.from({ length: max }, (_, i) => html`
<button key=${i} class=${i < (v || 0) ? 'on' : ''} onClick=${(e) => { e.stopPropagation(); set(v === i + 1 ? null : i + 1); }}><${Icon} name="star" fill=${i < (v || 0)} size=${15} /></button>`)}</div>`;
}
case 'long_text':
return html`<button class="cell-text long" title=${v || ''} onClick=${(e) => open(e, LongTextEditor, {}, { keepOnScroll: true })}>${v || ''}</button>`;
case 'dropdown':
return html`<button class="cell-chips" onClick=${(e) => open(e, LabelsPicker, {}, { matchWidth: true })}>
${(v || []).map((x) => labelOf(col, x)).filter(Boolean).map((l) => html`<span key=${l.id} class="chip">${l.name}</span>`)}</button>`;
case 'tags':
return html`<button class="cell-chips" onClick=${(e) => open(e, TagsEditor, {}, { matchWidth: true })}>
${(v || []).map((t) => html`<span key=${t} class="chip tag">#${t}</span>`)}</button>`;
case 'link':
return html`<div class="cell-link">
${v?.url ? html`<a href=${v.url} target="_blank" rel="noopener" onClick=${(e) => e.stopPropagation()}>${v.text || v.url}</a>` : null}
<button class="cell-edit-btn" onClick=${(e) => open(e, LinkEditor)}><${Icon} name=${v?.url ? 'edit' : 'link'} size=${13} /></button></div>`;
case 'email':
return html`<div class="cell-link">${v ? html`<a href=${'mailto:' + v} onClick=${(e) => e.stopPropagation()}>${v}</a>` : null}
<button class="cell-edit-btn" onClick=${(e) => open(e, SimpleEditor, { kind: 'email' })}><${Icon} name="edit" size=${13} /></button></div>`;
case 'phone':
return html`<div class="cell-link">${v ? html`<a href=${'tel:' + v} onClick=${(e) => e.stopPropagation()}>${v}</a>` : null}
<button class="cell-edit-btn" onClick=${(e) => open(e, SimpleEditor, { kind: 'tel' })}><${Icon} name="edit" size=${13} /></button></div>`;
case 'files': {
const files = (board.files || []).filter((f) => (v || []).includes(f.id));
return html`<button class="cell-files" onClick=${(e) => open(e, FilesEditor, {}, { keepOnScroll: true })}>
${files.slice(0, 3).map((f) => isImage(f) ? html`<img key=${f.id} src=${fileUrl(f)} alt="" loading="lazy" />` : html`<span key=${f.id} class="file-ext">${ext(f.name)}</span>`)}
${files.length > 3 ? html`<span class="file-more">+${files.length - 3}</span>` : null}
${!files.length ? html`<span class="ghost-icon"><${Icon} name="paperclip" /></span>` : null}
</button>`;
}
case 'created':
return html`<div class="cell-meta"><${Avatar} user=${state.usersById[item.created_by]} size=${22} /><span>${fmtDate(ymd(new Date(item.created_at)))}</span></div>`;
case 'updated':
return html`<div class="cell-meta muted">${timeAgo(item.updated_at)} ago</div>`;
case 'item_id':
return html`<div class="cell-meta muted mono">${item.id}</div>`;
default:
return html`<div class="cell-text"><${InlineEdit} value=${v || ''} onSave=${(s) => set(s || null)} /></div>`;
}
}
const ext = (n) => (n.split('.').pop() || 'file').slice(0, 4).toUpperCase();
function timelineProgress(v) {
const a = parseDate(v.from); const b = parseDate(v.to || v.from);
const now = new Date(); now.setHours(0, 0, 0, 0);
if (now < a) return 0;
if (now >= b) return 100;
return Math.round(((now - a) / (b - a + 86400000)) * 100);
}
// ─── Status picker (with label editing) ──────────────────
export function StatusPicker({ board, col, set, item, onPick }) {
const [editing, setEditing] = useState(false);
const labels = col.settings?.labels || [];
const pick = (id) => { closePopover(); onPick ? onPick(id) : set(id); };
if (editing) return html`<${LabelEditor} board=${board} col=${col} onDone=${() => setEditing(false)} />`;
return html`<div class="status-picker">
<div class="status-grid">
${labels.map((l) => html`<button key=${l.id} class="status-opt" style=${{ background: l.color, color: textOn(l.color) }} onClick=${() => pick(l.id)}>${l.name}</button>`)}
<button class="status-opt blank" onClick=${() => pick(null)}>-</button>
</div>
<div class="menu-divider" />
<button class="menu-item" onClick=${() => setEditing(true)}><${Icon} name="edit" /> Edit labels</button>
</div>`;
}
function LabelEditor({ board, col, onDone }) {
const [labels, setLabels] = useState(() => (col.settings?.labels || []).map((l) => ({ ...l })));
const save = () => { patchColumn(board.board.id, col.id, { settings: { ...col.settings, labels: labels.filter((l) => l.name.trim()) } }); onDone(); };
const upd = (i, patch) => setLabels(labels.map((l, j) => (j === i ? { ...l, ...patch } : l)));
return html`<div class="label-editor">
${labels.map((l, i) => html`<div key=${l.id} class="label-row">
<button class="swatch popover-keep" style=${{ background: l.color }} onClick=${(e) => {
const next = LABEL_COLORS[(LABEL_COLORS.indexOf(l.color) + 1) % LABEL_COLORS.length]; upd(i, { color: next });
}} title="Click to change color" />
<input class="input sm" value=${l.name} onInput=${(e) => upd(i, { name: e.target.value })} />
${col.type === 'status' ? html`<button class="icon-btn ${l.done ? 'on' : ''}" title="Counts as done" onClick=${() => upd(i, { done: !l.done })}><${Icon} name="check" /></button>` : null}
<button class="icon-btn" title="Remove" onClick=${() => setLabels(labels.filter((_, j) => j !== i))}><${Icon} name="x" /></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" /> New label</button>
<div class="modal-actions"><button class="btn ghost sm" onClick=${onDone}>Cancel</button><button class="btn primary sm" onClick=${save}>Apply</button></div>
</div>`;
}
// ─── Dropdown labels (multi) ─────────────────────────────
function LabelsPicker({ board, col, item, set }) {
const [q, setQ] = useState('');
const sel = item.vals[col.id] || [];
const labels = col.settings?.labels || [];
const shown = labels.filter((l) => l.name.toLowerCase().includes(q.toLowerCase()));
const toggle = (id) => set(sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]);
const create = () => {
const l = { id: Math.random().toString(36).slice(2, 9), name: q.trim(), color: LABEL_COLORS[labels.length % LABEL_COLORS.length] };
patchColumn(board.board.id, col.id, { settings: { ...col.settings, labels: [...labels, l] } });
set([...sel, l.id]); setQ('');
};
return html`<div class="picker">
<input class="input sm" placeholder="Search or add options" value=${q} onInput=${(e) => setQ(e.target.value)} autofocus
onKeyDown=${(e) => { if (e.key === 'Enter' && q.trim()) { const ex = labels.find((l) => l.name.toLowerCase() === q.trim().toLowerCase()); ex ? toggle(ex.id) : create(); } }} />
<div class="picker-list">
${shown.map((l) => html`<button key=${l.id} class="picker-row" onClick=${() => toggle(l.id)}>
<span class="check-box ${sel.includes(l.id) ? 'on' : ''}"><${Icon} name="check" size=${12} /></span><span class="chip">${l.name}</span></button>`)}
${q.trim() && !labels.some((l) => l.name.toLowerCase() === q.trim().toLowerCase()) ? html`<button class="picker-row" onClick=${create}><${Icon} name="plus" /> Create "${q.trim()}"</button>` : null}
${!labels.length && !q ? html`<div class="muted pad">Type to add the first option</div>` : null}
</div></div>`;
}
function TagsEditor({ col, item, set }) {
const [q, setQ] = useState('');
const tags = item.vals[col.id] || [];
return html`<div class="picker">
<div class="chips-wrap">${tags.map((t) => html`<span key=${t} class="chip tag">#${t}<button onClick=${() => set(tags.filter((x) => x !== t))}><${Icon} name="x" size=${10} /></button></span>`)}</div>
<input class="input sm" placeholder="Add a tag and press Enter" value=${q} autofocus onInput=${(e) => setQ(e.target.value)}
onKeyDown=${(e) => { if (e.key === 'Enter' && q.trim()) { const t = q.trim().replace(/^#/, ''); if (!tags.includes(t)) set([...tags, t]); setQ(''); } }} />
</div>`;
}
// ─── People ──────────────────────────────────────────────
export function PeoplePicker({ col, item, set, value, onChange, single }) {
const [q, setQ] = useState('');
const sel = value ?? (item?.vals[col.id] || []);
const apply = onChange || set;
const users = state.users.filter((u) => u.active && (u.name + ' ' + u.email).toLowerCase().includes(q.toLowerCase()));
const toggle = (id) => {
if (single) { apply(sel.includes(id) ? [] : [id]); closePopover(); return; }
apply(sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]);
};
const chosen = sel.map((id) => state.usersById[id]).filter(Boolean);
return html`<div class="picker people-picker">
${chosen.length ? html`<div class="chips-wrap">${chosen.map((u) => html`<span key=${u.id} class="chip person"><${Avatar} user=${u} size=${20} />${u.name}
<button onClick=${() => toggle(u.id)} aria-label="Remove"><${Icon} name="x" size=${10} /></button></span>`)}</div>` : null}
<input class="input sm" placeholder="Search names" value=${q} onInput=${(e) => setQ(e.target.value)} autofocus />
<div class="picker-list">
<div class="picker-section">Suggested people</div>
${users.filter((u) => !sel.includes(u.id)).slice(0, 50).map((u) => html`<button key=${u.id} class="picker-row" onClick=${() => toggle(u.id)}>
<${Avatar} user=${u} size=${24} /><span>${u.name}</span><span class="muted small">${u.email}</span></button>`)}
${!users.length ? html`<div class="muted pad">No one matches "${q}"</div>` : null}
</div></div>`;
}
// ─── Dates ───────────────────────────────────────────────
export function DatePicker({ col, item, set, range, value: v0, onChange }) {
const value = v0 !== undefined ? v0 : item?.vals[col.id];
const apply = onChange || set;
const init = range ? parseDate(value?.from) : parseDate(value);
const [month, setMonth] = useState(() => { const d = init || new Date(); return new Date(d.getFullYear(), d.getMonth(), 1); });
const [from, setFrom] = useState(range ? value?.from || null : null);
const [time, setTime] = useState(!range && String(value || '').includes('T') ? String(value).split('T')[1] : '');
const [showTime, setShowTime] = useState(!!time);
const sel = range ? null : (value ? String(value).slice(0, 10) : null);
const days = [];
const start = new Date(month); start.setDate(1 - start.getDay());
for (let i = 0; i < 42; i++) { const d = new Date(start); d.setDate(start.getDate() + i); days.push(d); }
const pending = useRef(false);
const click = (d) => {
const s = ymd(d);
if (!range) { apply(showTime && time ? `${s}T${time}` : s); closePopover(); return; }
if (!pending.current) { setFrom(s); pending.current = true; apply({ from: s, to: s }); return; }
const [a, b] = s < from ? [s, from] : [from, s];
pending.current = false; apply({ from: a, to: b }); closePopover();
};
const inRange = (s) => range && value?.from && s >= value.from && s <= (value.to || value.from);
return html`<div class="datepicker">
<div class="dp-head">
<button class="icon-btn" onClick=${() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}><${Icon} name="chevLeft" /></button>
<strong>${month.toLocaleString([], { month: 'long', year: 'numeric' })}</strong>
<button class="icon-btn" onClick=${() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}><${Icon} name="chevRight" /></button>
</div>
<div class="dp-grid">
${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d, i) => html`<span key=${'h' + i} class="dp-dow">${d}</span>`)}
${days.map((d) => { const s = ymd(d); return html`<button key=${s} class="dp-day ${d.getMonth() !== month.getMonth() ? 'out' : ''} ${s === todayStr() ? 'today' : ''} ${s === sel || (range && (s === value?.from || s === value?.to)) ? 'sel' : ''} ${inRange(s) ? 'inrange' : ''}" onClick=${() => click(d)}>${d.getDate()}</button>`; })}
</div>
${range ? html`<div class="muted small pad-x">${pending.current ? 'Now pick the end date' : 'Pick a start date, then an end date'}</div>` : null}
<div class="dp-foot">
<button class="btn ghost sm" onClick=${() => { click(new Date()); }}>Today</button>
${!range ? html`<button class="btn ghost sm" onClick=${() => setShowTime(!showTime)}>${showTime ? 'No time' : 'Add time'}</button>` : null}
${value ? html`<button class="btn ghost sm" onClick=${() => { apply(null); closePopover(); }}>Clear</button>` : null}
</div>
${showTime && !range ? html`<div class="dp-time"><input type="time" class="input sm" value=${time} onInput=${(e) => setTime(e.target.value)} />
<button class="btn primary sm" disabled=${!sel} onClick=${() => { apply(time ? `${sel}T${time}` : sel); closePopover(); }}>Set</button></div>` : null}
</div>`;
}
// ─── Text-ish editors ────────────────────────────────────
function LongTextEditor({ col, item, set }) {
const [v, setV] = useState(item.vals[col.id] || '');
const save = () => { if (v !== (item.vals[col.id] || '')) set(v.trim() || null); closePopover(); };
return html`<div class="longtext-editor">
<textarea class="input" rows="8" value=${v} onInput=${(e) => setV(e.target.value)} autofocus
onKeyDown=${(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) save(); }} />
<div class="modal-actions"><span class="muted small">⌘/Ctrl + Enter to save</span><button class="btn primary sm" onClick=${save}>Save</button></div>
</div>`;
}
function LinkEditor({ col, item, set }) {
const cur = item.vals[col.id] || {};
const [url, setUrl] = useState(cur.url || '');
const [text, setText] = useState(cur.text || '');
const save = (e) => { e.preventDefault(); let u = url.trim(); if (u && !/^[a-z]+:/i.test(u)) u = 'https://' + u; set(u ? { url: u, text: text.trim() } : null); closePopover(); };
return html`<form class="form-pop" onSubmit=${save}>
<label class="field-label">Web address</label><input class="input sm" value=${url} onInput=${(e) => setUrl(e.target.value)} placeholder="www.example.com" autofocus />
<label class="field-label">Text to display</label><input class="input sm" value=${text} onInput=${(e) => setText(e.target.value)} placeholder="Optional" />
<div class="modal-actions"><button class="btn primary sm">Save</button></div></form>`;
}
function SimpleEditor({ col, item, set, kind }) {
const [v, setV] = useState(item.vals[col.id] || '');
const save = (e) => { e.preventDefault(); set(v.trim() || null); closePopover(); };
return html`<form class="form-pop" onSubmit=${save}>
<input class="input sm" type=${kind} value=${v} onInput=${(e) => setV(e.target.value)} autofocus />
<div class="modal-actions"><button class="btn primary sm">Save</button></div></form>`;
}
// ─── Files ───────────────────────────────────────────────
export function FilesEditor({ board, col, item }) {
const ids = item.vals[col.id] || [];
const files = (board.files || []).filter((f) => ids.includes(f.id));
const [busy, setBusy] = useState(false);
const input = useRef();
const upload = async (list) => {
if (!list?.length) return;
setBusy(true);
const fd = new FormData();
for (const f of list) fd.append('files', f);
fd.append('column_id', col.id);
try { await api.post(`/api/items/${item.id}/files`, fd); const { loadBoard } = await import('./store.js'); await loadBoard(board.board.id); }
catch (e) { toast(e.message, 'error'); }
setBusy(false);
};
const remove = async (f) => {
await api.del(`/api/files/${f.id}`);
const { loadBoard } = await import('./store.js'); loadBoard(board.board.id);
};
return html`<div class="files-pop" onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { e.preventDefault(); upload(e.dataTransfer.files); }}>
<div class="files-list">
${files.map((f) => html`<div key=${f.id} class="file-row">
${isImage(f) ? html`<img src=${fileUrl(f)} alt="" />` : html`<span class="file-ext">${ext(f.name)}</span>`}
<a href=${fileUrl(f)} target="_blank" rel="noopener">${f.name}</a><span class="muted small">${fmtSize(f.size)}</span>
<a class="icon-btn" href=${fileUrl(f, true)} title="Download"><${Icon} name="download" /></a>
<button class="icon-btn" title="Remove" onClick=${() => remove(f)}><${Icon} name="trash" /></button></div>`)}
${!files.length ? html`<div class="muted pad">Drop files here</div>` : null}
</div>
<input type="file" multiple hidden ref=${input} onChange=${(e) => upload(e.target.files)} />
<button class="btn primary sm" disabled=${busy} onClick=${() => input.current.click()}><${Icon} name="upload" /> ${busy ? 'Uploading…' : 'Add files'}</button>
</div>`;
}
// ─── Group footer summaries ──────────────────────────────
export function Summary({ board, col, items }) {
if (col.type === 'status' || col.type === 'dropdown') {
const counts = {};
let total = 0;
for (const it of items) {
const vs = col.type === 'status' ? [it.vals[col.id] || '_'] : (it.vals[col.id]?.length ? it.vals[col.id] : ['_']);
for (const v of vs) { counts[v] = (counts[v] || 0) + 1; total++; }
}
if (!total) return html`<div class="summary" />`;
const segs = Object.entries(counts).map(([id, n]) => ({ l: labelOf(col, id), n }))
.sort((a, b) => (a.l ? 0 : 1) - (b.l ? 0 : 1));
return html`<div class="summary"><div class="battery" title=${segs.map((s) => `${s.l?.name || 'Empty'}: ${s.n}`).join('\n')}>
${segs.map((s, i) => html`<span key=${i} style=${{ flex: s.n, background: s.l?.color || 'var(--status-empty)' }} />`)}</div></div>`;
}
if (col.type === 'number') {
const nums = items.map((i) => i.vals[col.id]).filter((x) => typeof x === 'number');
const sum = nums.reduce((a, b) => a + b, 0);
return html`<div class="summary num">${nums.length ? html`<strong>${+sum.toFixed(2)}${col.settings?.unit ? ' ' + col.settings.unit : ''}</strong><span class="muted small">sum</span>` : null}</div>`;
}
if (col.type === 'date' || col.type === 'timeline') {
const ds = items.flatMap((i) => { const v = i.vals[col.id]; return !v ? [] : col.type === 'date' ? [String(v).slice(0, 10)] : [v.from, v.to]; }).filter(Boolean).sort();
if (!ds.length) return html`<div class="summary" />`;
return html`<div class="summary"><span class="tl-bar summary-range"><span>${fmtRange({ from: ds[0], to: ds[ds.length - 1] })}</span></span></div>`;
}
if (col.type === 'people') {
const ids = [...new Set(items.flatMap((i) => i.vals[col.id] || []))];
return html`<div class="summary">${ids.length ? html`<${AvatarStack} ids=${ids} size=${22} />` : null}</div>`;
}
if (col.type === 'checkbox') {
const n = items.filter((i) => i.vals[col.id]).length;
return html`<div class="summary muted small">${items.length ? `${n}/${items.length}` : ''}</div>`;
}
if (col.type === 'files') {
const n = items.reduce((a, i) => a + (i.vals[col.id]?.length || 0), 0);
return html`<div class="summary muted small">${n ? `${n} files` : ''}</div>`;
}
return html`<div class="summary" />`;
}
export { ColorPicker };
+81
View File
@@ -0,0 +1,81 @@
// Chart view (board dashboard) and Files gallery view.
import { html } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, navigate, boardPath, mutateBoard } from './store.js';
import { Icon, fileUrl, isImage, fmtSize, timeAgo, Empty, todayStr, Avatar } from './ui.js';
import { labelOf, itemDone } from './cells.js';
export function ChartView({ board, view, items, subs }) {
const boardId = board.board.id;
const cols = ['status', 'people', 'dropdown'].flatMap((t) => board.columns.filter((c) => c.type === t));
const by = view.settings?.by || cols[0]?.id || '__group';
const kind = view.settings?.kind || 'bar';
const setting = (patch) => { mutateBoard(boardId, () => { view.settings = { ...view.settings, ...patch }; }); api.patch(`/api/views/${view.id}`, { settings: { ...view.settings, ...patch } }); };
const buckets = new Map();
const add = (key, label, color, n = 1) => { const b = buckets.get(key) || { label, color, n: 0 }; b.n += n; buckets.set(key, b); };
const col = board.columns.find((c) => c.id === by);
for (const it of items) {
if (by === '__group') { const g = board.groups.find((x) => x.id === it.group_id); add(g?.id, g?.title || '—', g?.color); continue; }
const v = it.vals[by];
if (col.type === 'status') { const l = labelOf(col, v); add(l?.id || '_', l?.name || 'Empty', l?.color || '#c4c4c4'); }
else if (col.type === 'dropdown') { if (!v?.length) add('_', 'Empty', '#c4c4c4'); for (const x of v || []) { const l = labelOf(col, x); if (l) add(l.id, l.name, l.color); } }
else if (col.type === 'people') { if (!v?.length) add('_', 'Unassigned', '#c4c4c4'); for (const u of v || []) add(u, state.usersById[u]?.name || '?', null); }
}
const data = [...buckets.values()].sort((a, b) => b.n - a.n);
const maxN = Math.max(1, ...data.map((d) => d.n));
const total = items.length;
const done = items.filter((i) => itemDone(board, i)).length;
const dateCol = board.columns.find((c) => c.type === 'date');
const overdue = dateCol ? items.filter((i) => i.vals[dateCol.id] && String(i.vals[dateCol.id]).slice(0, 10) < todayStr() && !itemDone(board, i)).length : 0;
const palette = ['#579bfc', '#a25ddc', '#00c875', '#fdab3d', '#e2445c', '#66ccff', '#ff642e', '#037f4c', '#ff158a', '#784bd1'];
let acc = 0;
const arcs = data.map((d, i) => {
const a0 = (acc / total) * Math.PI * 2; acc += d.n; const a1 = (acc / total) * Math.PI * 2;
const large = a1 - a0 > Math.PI ? 1 : 0;
const p = (a) => `${100 + 80 * Math.sin(a)} ${100 - 80 * Math.cos(a)}`;
const path = data.length === 1 ? 'M100 20 A80 80 0 1 1 99.99 20 Z' : `M100 100 L${p(a0)} A80 80 0 ${large} 1 ${p(a1)} Z`;
return html`<path key=${i} d=${path} fill=${d.color || palette[i % palette.length]}><title>${d.label}: ${d.n}</title></path>`;
});
return html`<div class="chart-view">
<div class="kpis">
<div class="kpi"><span class="kpi-n">${total}</span><span class="muted">${board.board.item_label}s</span></div>
<div class="kpi"><span class="kpi-n" style=${{ color: '#00c875' }}>${total ? Math.round((done / total) * 100) : 0}%</span><span class="muted">Done (${done})</span></div>
<div class="kpi"><span class="kpi-n" style=${{ color: overdue ? '#e2445c' : undefined }}>${overdue}</span><span class="muted">Overdue</span></div>
<div class="kpi"><span class="kpi-n">${Object.keys(subs).reduce((a, k) => a + subs[k].length, 0)}</span><span class="muted">Subitems</span></div>
</div>
<div class="chart-card">
<div class="view-options">
<span class="muted small">Group by</span>
<select class="input sm" value=${by} onChange=${(e) => setting({ by: e.target.value })}>
<option value="__group">Group</option>${cols.map((c) => html`<option value=${c.id}>${c.title}</option>`)}
</select>
<div class="seg"><button class=${kind === 'bar' ? 'on' : ''} onClick=${() => setting({ kind: 'bar' })}>Bar</button><button class=${kind === 'pie' ? 'on' : ''} onClick=${() => setting({ kind: 'pie' })}>Pie</button></div>
</div>
${!total ? html`<p class="muted pad">No items to chart.</p>` : kind === 'bar' ? html`<div class="bars">
${data.map((d, i) => html`<div key=${i} class="bar-row">
<span class="bar-label">${d.label}</span>
<span class="bar-track"><span class="bar" style=${{ width: `${(d.n / maxN) * 100}%`, background: d.color || palette[i % palette.length] }} /></span>
<span class="bar-n">${d.n} <span class="muted small">${Math.round((d.n / total) * 100)}%</span></span>
</div>`)}</div>`
: html`<div class="pie-wrap"><svg viewBox="0 0 200 200" width="240" height="240">${arcs}</svg>
<div class="legend">${data.map((d, i) => html`<div key=${i}><i style=${{ background: d.color || palette[i % palette.length] }} />${d.label}<span class="muted"> ${d.n}</span></div>`)}</div></div>`}
</div>
</div>`;
}
export function FilesView({ board, view }) {
const files = board.files || [];
if (!files.length) return html`<${Empty} icon="paperclip" title="No files on this board yet"><p class="muted">Add a Files column or attach files to items.</p><//>`;
const itemName = Object.fromEntries(board.items.map((i) => [i.id, i.name]));
return html`<div class="file-gallery">
${[...files].sort((a, b) => b.created_at - a.created_at).map((f) => html`<div key=${f.id} class="file-card">
<a href=${fileUrl(f)} target="_blank" rel="noopener" class="file-thumb">${isImage(f) ? html`<img src=${fileUrl(f)} alt="" loading="lazy" />` : html`<span class="file-ext big">${(f.name.split('.').pop() || '').slice(0, 4).toUpperCase()}</span>`}</a>
<div class="file-info"><span class="file-name" title=${f.name}>${f.name}</span>
<button class="link small" onClick=${() => navigate(boardPath(board.board.id, { viewId: view.id, itemId: f.item_id }))}>${itemName[f.item_id] || 'Item'}</button>
<span class="muted small">${fmtSize(f.size)} · ${timeAgo(f.created_at)}</span></div>
</div>`)}
</div>`;
}
+272
View File
@@ -0,0 +1,272 @@
// Item panel: slides in from the right (full screen on phones). Updates, info, files, activity.
import { html, useState, useEffect, useRef } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, useStore, navigate, boardPath, loadBoard, toast } from './store.js';
import { Icon, Avatar, InlineEdit, menu, timeAgo, RichText, fileUrl, isImage, fmtSize, confirmDialog, Spinner, fmtDate, ymd } from './ui.js';
import { Cell, COLUMN_TYPES } from './cells.js';
import * as A from './actions.js';
export function ItemPanel({ board, itemId, viewId }) {
const s = useStore();
const boardId = board.board.id;
const item = board.items.find((i) => i.id === itemId);
const [detail, setDetail] = useState(null);
const [tab, setTab] = useState(window.innerWidth < 769 ? 'info' : 'updates');
const close = () => navigate(boardPath(boardId, { viewId }));
const reload = () => api.get(`/api/items/${itemId}`).then(setDetail).catch((e) => { if (e.status === 404) close(); });
useEffect(() => { reload(); }, [itemId]);
useEffect(() => {
const fn = (e) => { if (e.detail === boardId) reload(); };
window.addEventListener('wb:board-changed', fn);
const key = (e) => { if (e.key === 'Escape' && !s.popover && !s.modal && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName)) close(); };
document.addEventListener('keydown', key);
return () => { window.removeEventListener('wb:board-changed', fn); document.removeEventListener('keydown', key); };
}, [itemId]);
if (!item) {
return html`<aside class="item-panel"><div class="panel-head"><button class="icon-btn" onClick=${close}><${Icon} name="x" /></button></div>
${detail === null ? html`<${Spinner} />` : html`<p class="pad muted">This item was deleted or moved.</p>`}</aside>`;
}
const parent = item.parent_id ? board.items.find((i) => i.id === item.parent_id) : null;
const group = board.groups.find((g) => g.id === (item.group_id || parent?.group_id));
const subs = board.items.filter((i) => i.parent_id === item.id).sort((a, b) => a.position - b.position);
const nUpdates = detail?.updates.filter((u) => !u.parent_id).length ?? board.updateCounts?.[item.id] ?? 0;
const nFiles = detail?.files.length ?? 0;
const itemMenu = (e) => menu(e, [
{ label: 'Duplicate', icon: 'copy', onClick: () => A.duplicateItems(boardId, [item.id]) },
{ label: 'Copy link', icon: 'link', onClick: () => { navigator.clipboard?.writeText(location.origin + boardPath(boardId, { itemId: item.id })); toast('Link copied'); } },
!item.parent_id && { label: 'Move to board', icon: 'board', keepOpen: true, onClick: (ev) => menu(ev, state.boards.filter((b) => b.id !== boardId).map((b) => ({ label: b.name, onClick: () => { A.moveItemToBoard(boardId, item.id, b.id); close(); } }))) },
{ label: 'Archive', icon: 'archive', onClick: () => { A.archiveItems(boardId, [item.id]); close(); } },
{ label: 'Delete', icon: 'trash', danger: true, onClick: () => A.deleteItems(boardId, [item.id]) },
], { align: 'right' });
return html`<div class="panel-backdrop" onClick=${close} />
<aside class="item-panel" role="dialog" aria-label=${item.name}>
<div class="panel-head">
<button class="icon-btn" onClick=${close} aria-label="Close"><${Icon} name="x" size=${20} /></button>
<div class="crumbs muted small">
<span>${board.board.name}</span>
${group ? html`<${Icon} name="chevRight" size=${12} /><span style=${{ color: group.color }}>${group.title}</span>` : null}
${parent ? html`<${Icon} name="chevRight" size=${12} /><button class="link" onClick=${() => navigate(boardPath(boardId, { viewId, itemId: parent.id }))}>${parent.name}</button>` : null}
</div>
<button class="icon-btn" onClick=${itemMenu}><${Icon} name="dots" size=${20} /></button>
</div>
<h2 class="panel-title"><${InlineEdit} value=${item.name} onSave=${(n) => n && A.renameItem(boardId, item.id, n)} /></h2>
<nav class="panel-tabs">
<button class=${tab === 'updates' ? 'active' : ''} onClick=${() => setTab('updates')}>Updates${nUpdates ? ` / ${nUpdates}` : ''}</button>
<button class=${tab === 'info' ? 'active' : ''} onClick=${() => setTab('info')}>Info${subs.length ? ` / ${subs.length}` : ''}</button>
<button class=${tab === 'files' ? 'active' : ''} onClick=${() => setTab('files')}>Files${nFiles ? ` / ${nFiles}` : ''}</button>
<button class=${tab === 'activity' ? 'active' : ''} onClick=${() => setTab('activity')}>Activity</button>
</nav>
<div class="panel-body">
${!detail ? html`<${Spinner} />`
: tab === 'updates' ? html`<${Updates} item=${item} detail=${detail} reload=${reload} board=${board} />`
: tab === 'info' ? html`<${Info} board=${board} item=${item} subs=${subs} viewId=${viewId} detail=${detail} />`
: tab === 'files' ? html`<${Files} item=${item} detail=${detail} reload=${reload} boardId=${boardId} />`
: html`<${Activity} detail=${detail} />`}
</div>
</aside>`;
}
// ─── Updates ─────────────────────────────────────────────
function Updates({ item, detail, reload, board }) {
const top = detail.updates.filter((u) => !u.parent_id);
const replies = {};
for (const u of detail.updates) if (u.parent_id) (replies[u.parent_id] ||= []).unshift(u);
return html`<div class="updates">
<${Composer} itemId=${item.id} onPosted=${reload} boardId=${board.board.id} />
${top.map((u) => html`<${UpdateCard} key=${u.id} u=${u} replies=${replies[u.id] || []} reload=${reload} itemId=${item.id} boardId=${board.board.id} />`)}
${!top.length ? html`<div class="empty-updates"><${Icon} name="message" size=${40} /><h3>No updates yet</h3>
<p class="muted">Share progress, mention a teammate with @, or attach files.</p></div>` : null}
</div>`;
}
function Composer({ itemId, parentId, onPosted, boardId, compact, autoFocus }) {
const [text, setText] = useState('');
const [files, setFiles] = useState([]);
const [mentions, setMentions] = useState([]);
const [suggest, setSuggest] = useState(null);
const [busy, setBusy] = useState(false);
const [open, setOpen] = useState(!compact || autoFocus);
const ta = useRef();
const fileIn = useRef();
const onInput = (e) => {
setText(e.target.value);
const upto = e.target.value.slice(0, e.target.selectionStart);
const m = upto.match(/(^|\s)@([\w .'-]{0,30})$/);
setSuggest(m ? m[2] : null);
};
const pick = (u) => {
const pos = ta.current.selectionStart;
const before = text.slice(0, pos).replace(/@([\w .'-]{0,30})$/, `@${u.name} `);
setText(before + text.slice(pos));
setMentions([...new Set([...mentions, u.id])]);
setSuggest(null);
setTimeout(() => { ta.current.focus(); ta.current.selectionStart = ta.current.selectionEnd = before.length; }, 0);
};
const submit = async () => {
if (!text.trim() && !files.length) return;
setBusy(true);
let body = text;
const used = mentions.filter((id) => text.includes('@' + state.usersById[id]?.name));
for (const id of used) body = body.split('@' + state.usersById[id].name).join(`@[${state.usersById[id].name}](${id})`);
const fd = new FormData();
fd.append('body', body);
fd.append('mentions', JSON.stringify(used));
if (parentId) fd.append('parent_id', parentId);
for (const f of files) fd.append('files', f);
try {
await api.post(`/api/items/${itemId}/updates`, fd);
setText(''); setFiles([]); setMentions([]); if (compact) setOpen(false);
onPosted(); loadBoard(boardId);
} catch (e) { toast(e.message, 'error'); }
setBusy(false);
};
if (!open) return html`<button class="reply-open" onClick=${() => setOpen(true)}>Write a reply…</button>`;
const sugg = suggest !== null ? state.users.filter((u) => u.active && u.name.toLowerCase().includes(suggest.toLowerCase())).slice(0, 6) : [];
return html`<div class="composer ${compact ? 'compact' : ''}"
onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { e.preventDefault(); setFiles([...files, ...e.dataTransfer.files]); }}>
<textarea ref=${ta} rows=${compact ? 2 : 3} placeholder=${parentId ? 'Write a reply… (@ to mention)' : 'Write an update and mention others with @'} value=${text}
onInput=${onInput} autofocus=${autoFocus}
onKeyDown=${(e) => {
if (sugg.length && (e.key === 'Enter' || e.key === 'Tab')) { e.preventDefault(); pick(sugg[0]); return; }
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit();
if (e.key === 'Escape') setSuggest(null);
}}
onPaste=${(e) => { const fs = [...(e.clipboardData?.files || [])]; if (fs.length) { e.preventDefault(); setFiles([...files, ...fs]); } }} />
${sugg.length ? html`<div class="mention-pop">${sugg.map((u) => html`<button key=${u.id} onMouseDown=${(e) => { e.preventDefault(); pick(u); }}><${Avatar} user=${u} size=${22} />${u.name}</button>`)}</div>` : null}
${files.length ? html`<div class="composer-files">${files.map((f, i) => html`<span key=${i} class="chip">${f.name}<button onClick=${() => setFiles(files.filter((_, j) => j !== i))}><${Icon} name="x" size=${10} /></button></span>`)}</div>` : null}
<div class="composer-actions">
<button class="icon-btn" title="Attach files" onClick=${() => fileIn.current.click()}><${Icon} name="paperclip" /></button>
<button class="icon-btn" title="Mention someone" onClick=${() => { setText(text + (text && !text.endsWith(' ') ? ' @' : '@')); setSuggest(''); ta.current.focus(); }}>@</button>
<input type="file" multiple hidden ref=${fileIn} onChange=${(e) => setFiles([...files, ...e.target.files])} />
<span class="spacer" />
${compact ? html`<button class="btn ghost sm" onClick=${() => setOpen(false)}>Cancel</button>` : null}
<button class="btn primary sm" disabled=${busy || (!text.trim() && !files.length)} onClick=${submit}>${busy ? 'Posting…' : parentId ? 'Reply' : 'Update'}</button>
</div>
</div>`;
}
function UpdateCard({ u, replies, reload, itemId, boardId, isReply }) {
const me = state.me;
const author = state.usersById[u.user_id];
const [editing, setEditing] = useState(false);
const [replying, setReplying] = useState(false);
const liked = u.likes?.includes(me.id);
const mine = u.user_id === me.id || me.role === 'admin';
const cardMenu = (e) => menu(e, [
{ label: 'Edit', icon: 'edit', onClick: () => setEditing(true) },
{ label: 'Copy text', icon: 'copy', onClick: () => navigator.clipboard?.writeText(u.body.replace(/@\[([^\]]+)\]\([\w-]+\)/g, '@$1')) },
{ label: 'Delete', icon: 'trash', danger: true, onClick: async () => { if (await confirmDialog({ title: 'Delete this update?' })) { await api.del(`/api/updates/${u.id}`); reload(); loadBoard(boardId); } } },
], { align: 'right' });
return html`<article class="update ${isReply ? 'reply' : ''}">
<header class="update-head">
<${Avatar} user=${author || { name: u.author_name || 'Imported', email: u.author_name || '' }} size=${isReply ? 28 : 34} />
<div class="update-meta"><strong>${author?.name || u.author_name || 'Imported'}</strong>
<span class="muted small" title=${new Date(u.created_at).toLocaleString()}>${timeAgo(u.created_at)}${u.edited_at ? ' · edited' : ''}</span></div>
${mine ? html`<button class="icon-btn" onClick=${cardMenu}><${Icon} name="dots" /></button>` : null}
</header>
${editing ? html`<${EditUpdate} u=${u} done=${() => { setEditing(false); reload(); }} />`
: html`<div class="update-body"><${RichText} text=${u.body} /></div>`}
${u.files?.length ? html`<div class="update-files">${u.files.map((f) => isImage(f)
? html`<a key=${f.id} href=${fileUrl(f)} target="_blank" rel="noopener"><img src=${fileUrl(f)} alt=${f.name} loading="lazy" /></a>`
: html`<a key=${f.id} class="file-chip" href=${fileUrl(f)} target="_blank" rel="noopener"><${Icon} name="file" /> ${f.name}</a>`)}</div>` : null}
<footer class="update-actions">
<button class=${liked ? 'on' : ''} onClick=${async () => { await api.post(`/api/updates/${u.id}/like`); reload(); }}>
<${Icon} name="thumb" /> ${liked ? 'Liked' : 'Like'}${u.likes?.length ? ` · ${u.likes.length}` : ''}</button>
${!isReply ? html`<button onClick=${() => setReplying(true)}><${Icon} name="reply" /> Reply</button>` : null}
</footer>
${replies.length || replying ? html`<div class="replies">
${replies.map((r) => html`<${UpdateCard} key=${r.id} u=${r} replies=${[]} reload=${reload} itemId=${itemId} boardId=${boardId} isReply=${true} />`)}
${replying ? html`<${Composer} itemId=${itemId} parentId=${u.id} onPosted=${() => { setReplying(false); reload(); }} boardId=${boardId} compact=${true} autoFocus=${true} />` : null}
</div>` : null}
</article>`;
}
function EditUpdate({ u, done }) {
const [v, setV] = useState(u.body);
return html`<div class="composer"><textarea rows="4" value=${v} onInput=${(e) => setV(e.target.value)} autofocus />
<div class="composer-actions"><span class="spacer" /><button class="btn ghost sm" onClick=${done}>Cancel</button>
<button class="btn primary sm" onClick=${async () => { await api.patch(`/api/updates/${u.id}`, { body: v }); done(); }}>Save</button></div></div>`;
}
// ─── Info (all columns + subitems) ───────────────────────
function Info({ board, item, subs, viewId, detail }) {
const boardId = board.board.id;
const [newSub, setNewSub] = useState('');
const creator = state.usersById[item.created_by];
return html`<div class="info">
<div class="info-grid">
${board.columns.map((c) => html`<div key=${c.id} class="info-row">
<div class="info-label"><span class="type-dot" style=${{ background: COLUMN_TYPES[c.type]?.color }}><${Icon} name=${COLUMN_TYPES[c.type]?.icon || 'edit'} size=${11} /></span>${c.title}</div>
<div class="info-value tc-${c.type}"><${Cell} board=${board} col=${c} item=${item} /></div>
</div>`)}
</div>
${!item.parent_id ? html`<div class="info-subitems">
<h4>Subitems <span class="muted">${subs.length}</span></h4>
${subs.map((s) => {
const status = board.columns.find((c) => c.type === 'status');
return html`<div key=${s.id} class="sub-line">
<button class="link" onClick=${() => navigate(boardPath(boardId, { viewId, itemId: s.id }))}>${s.name}</button>
${status ? html`<div class="sub-status cell-status"><${Cell} board=${board} col=${status} item=${s} /></div>` : null}
</div>`;
})}
<input class="input sm" placeholder="+ Add subitem" value=${newSub} onInput=${(e) => setNewSub(e.target.value)}
onKeyDown=${async (e) => { if (e.key === 'Enter' && newSub.trim()) { const n = newSub.trim(); setNewSub(''); await A.createItem(boardId, { parent_id: item.id, name: n }); } }} />
</div>` : null}
<p class="muted small info-foot">Created ${creator ? `by ${creator.name} ` : ''}on ${fmtDate(ymd(new Date(item.created_at)), { year: true })} · Updated ${timeAgo(item.updated_at)} ago</p>
</div>`;
}
// ─── Files ───────────────────────────────────────────────
function Files({ item, detail, reload, boardId }) {
const input = useRef();
const [busy, setBusy] = useState(false);
const upload = async (list) => {
if (!list?.length) return;
setBusy(true);
const fd = new FormData();
for (const f of list) fd.append('files', f);
try { await api.post(`/api/items/${item.id}/files`, fd); reload(); loadBoard(boardId); } catch (e) { toast(e.message, 'error'); }
setBusy(false);
};
return html`<div class="files-tab" onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { e.preventDefault(); upload(e.dataTransfer.files); }}>
<button class="dropzone" onClick=${() => input.current.click()}><${Icon} name="upload" size=${24} /><span>${busy ? 'Uploading…' : 'Drag files here or click to upload'}</span></button>
<input type="file" multiple hidden ref=${input} onChange=${(e) => upload(e.target.files)} />
<div class="file-grid">
${detail.files.map((f) => html`<div key=${f.id} class="file-card">
<a href=${fileUrl(f)} target="_blank" rel="noopener" class="file-thumb">${isImage(f) ? html`<img src=${fileUrl(f)} alt="" loading="lazy" />` : html`<span class="file-ext big">${(f.name.split('.').pop() || '').slice(0, 4).toUpperCase()}</span>`}</a>
<div class="file-info"><span class="file-name" title=${f.name}>${f.name}</span><span class="muted small">${fmtSize(f.size)} · ${timeAgo(f.created_at)}</span></div>
<div class="file-actions">
<a class="icon-btn" href=${fileUrl(f, true)} title="Download"><${Icon} name="download" /></a>
<button class="icon-btn" title="Delete" onClick=${async () => { if (await confirmDialog({ title: `Delete ${f.name}?` })) { await api.del(`/api/files/${f.id}`); reload(); loadBoard(boardId); } }}><${Icon} name="trash" /></button>
</div>
</div>`)}
</div>
</div>`;
}
// ─── Activity ────────────────────────────────────────────
export function activityText(a) {
const d = a.data || {};
switch (a.action) {
case 'item_created': return 'created this item';
case 'renamed': return html`renamed from <s>${d.from}</s> to <b>${d.to}</b>`;
case 'moved_group': return html`moved to group <b>${d.to}</b>`;
case 'moved_board': return 'moved from another board';
case 'value': return html`<span class="act-col">${d.column}</span> ${d.from ? html`<span class="act-from">${d.from}</span> → ` : ''}<span class="act-to">${d.to || '(cleared)'}</span>`;
case 'update_posted': return 'posted an update';
case 'file_added': return html`added ${(d.names || []).join(', ')}`;
case 'archived': return 'archived this item';
default: return a.action.replace(/_/g, ' ');
}
}
function Activity({ detail }) {
return html`<div class="activity">
${detail.activity.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} />
<span class="act-who">${state.usersById[a.user_id]?.name || 'Automation'}</span>
<span class="act-what">${activityText(a)}</span>
</div>`)}
${!detail.activity.length ? html`<p class="muted pad">No activity yet.</p>` : null}
</div>`;
}
+52
View File
@@ -0,0 +1,52 @@
// Kanban: one lane per label of a status column; drag cards between lanes.
import { html, useState } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { navigate, boardPath, mutateBoard } from './store.js';
import { Icon, AvatarStack, textOn, fmtDate, fmtRange, Empty } from './ui.js';
import { labelOf, Cell } from './cells.js';
import * as A from './actions.js';
export function KanbanView({ board, view, items, hidden }) {
const boardId = board.board.id;
const statusCols = board.columns.filter((c) => c.type === 'status');
const col = statusCols.find((c) => c.id === view.settings?.column) || statusCols[0];
const [drag, setDrag] = useState(null);
const [over, setOver] = useState(null);
const [adding, setAdding] = useState(null);
if (!col) return html`<${Empty} icon="kanban" title="Kanban needs a status column">
<button class="btn primary" onClick=${() => A.addColumn(boardId, 'status')}>Add a status column</button><//>`;
const lanes = [...(col.settings.labels || []), { id: null, name: 'Empty', color: '#c4c4c4' }];
const people = board.columns.find((c) => c.type === 'people');
const date = board.columns.find((c) => c.type === 'date' || c.type === 'timeline');
const extra = board.columns.filter((c) => !hidden.includes(c.id) && c !== col && c !== people && c !== date && ['text', 'number', 'dropdown', 'tags'].includes(c.type)).slice(0, 3);
const setCol = (id) => { mutateBoard(boardId, () => { view.settings = { ...view.settings, column: id }; }); api.patch(`/api/views/${view.id}`, { settings: { ...view.settings, column: id } }); };
return html`<div class="kanban-wrap">
${statusCols.length > 1 ? html`<div class="view-options"><span class="muted small">Lanes from</span>
<select class="input sm" value=${col.id} onChange=${(e) => setCol(e.target.value)}>${statusCols.map((c) => html`<option value=${c.id}>${c.title}</option>`)}</select></div>` : null}
<div class="kanban">
${lanes.map((l) => {
const cards = items.filter((it) => (it.vals[col.id] || null) === l.id);
if (l.id === null && !cards.length) return null;
return html`<div key=${l.id || 'none'} class="lane ${over === l.id ? 'over' : ''}"
onDragOver=${(e) => { e.preventDefault(); setOver(l.id); }} onDragLeave=${() => setOver(null)}
onDrop=${(e) => { e.preventDefault(); setOver(null); if (drag && (drag.vals[col.id] || null) !== l.id) A.setValue(boardId, drag.id, col.id, l.id); setDrag(null); }}>
<div class="lane-head" style=${{ background: l.color, color: textOn(l.color) }}>${l.name} <span>${cards.length}</span></div>
<div class="lane-body">
${cards.map((it) => html`<div key=${it.id} class="kcard" draggable="true" onDragStart=${() => setDrag(it)} onDragEnd=${() => setDrag(null)}
onClick=${() => navigate(boardPath(boardId, { viewId: view.id, itemId: it.id }))}>
<div class="kcard-name">${it.name}</div>
${extra.map((c) => it.vals[c.id] != null ? html`<div key=${c.id} class="kcard-field"><span class="muted small">${c.title}</span><div onClick=${(e) => e.stopPropagation()}><${Cell} board=${board} col=${c} item=${it} compact /></div></div>` : null)}
<div class="kcard-foot">
${date && it.vals[date.id] ? html`<span class="muted small"><${Icon} name="calendar" size=${12} /> ${date.type === 'date' ? fmtDate(it.vals[date.id]) : fmtRange(it.vals[date.id])}</span>` : html`<span />`}
${people ? html`<${AvatarStack} ids=${it.vals[people.id] || []} size=${22} />` : null}
</div>
</div>`)}
${adding === l.id ? html`<input class="input sm" autofocus placeholder="Name"
onKeyDown=${async (e) => { if (e.key === 'Enter' && e.target.value.trim()) { const name = e.target.value.trim(); e.target.value = ''; await A.createItem(boardId, { group_id: board.groups[0]?.id, name, vals: l.id ? { [col.id]: l.id } : {} }); } if (e.key === 'Escape') setAdding(undefined); }}
onBlur=${() => setAdding(undefined)} />`
: html`<button class="lane-add" onClick=${() => setAdding(l.id)}><${Icon} name="plus" /> Add ${board.board.item_label.toLowerCase()}</button>`}
</div>
</div>`;
})}
</div></div>`;
}
+291
View File
@@ -0,0 +1,291 @@
// 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>`;
}
+165
View File
@@ -0,0 +1,165 @@
// Left navigation: global nav rail + workspace tree (folders, boards, favorites).
import { html, useState } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, update, useStore, navigate, boardPath, loadTree, localSet, localGet } from './store.js';
import { Icon, Avatar, menu, openModal, closeModal, ModalHeader, promptDialog, confirmDialog, openPopover, ColorPicker, GROUP_COLORS } from './ui.js';
import * as A from './actions.js';
import { UserMenu } from './pages.js';
export function Sidebar() {
const s = useStore();
const [q, setQ] = useState('');
const [openFolders, setOpenFolders] = useState(() => { try { return JSON.parse(localGet('wb.openFolders') || '{}'); } catch { return {}; } });
const ws = s.workspaces.find((w) => w.id === s.currentWorkspace) || s.workspaces[0];
const r = s.route;
const toggleFolder = (id) => { const n = { ...openFolders, [id]: !openFolders[id] }; setOpenFolders(n); localSet('wb.openFolders', JSON.stringify(n)); };
const pickWs = (id) => { localSet('wb.workspace', id); update({ currentWorkspace: id }); };
const favs = s.boards.filter((b) => s.favorites.includes(b.id));
const filter = q.trim().toLowerCase();
const wsBoards = s.boards.filter((b) => b.workspace_id === ws?.id && (!filter || b.name.toLowerCase().includes(filter)));
const wsFolders = s.folders.filter((f) => f.workspace_id === ws?.id);
const wsMenu = (e) => menu(e, [
...s.workspaces.map((w) => ({ label: w.name, icon: 'workspace', active: w.id === ws?.id, onClick: () => pickWs(w.id) })),
{ divider: true },
{ label: 'Add workspace', icon: 'plus', onClick: async () => {
const name = await promptDialog({ title: 'New workspace', placeholder: 'e.g. Production, Creative, Facilities', confirm: 'Create' });
if (name) { const w = await api.post('/api/workspaces', { name, color: GROUP_COLORS[s.workspaces.length % GROUP_COLORS.length] }); await loadTree(); pickWs(w.id); }
} },
ws && { label: 'Workspace overview', icon: 'info', onClick: () => navigate(`/workspaces/${ws.id}`) },
ws && { label: 'Rename workspace', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Rename workspace', value: ws.name }); if (n) { await api.patch(`/api/workspaces/${ws.id}`, { name: n }); loadTree(); } } },
ws && s.me.role === 'admin' && s.workspaces.length > 1 && { label: 'Delete workspace', icon: 'trash', danger: true, onClick: async () => {
if (await confirmDialog({ title: `Delete workspace "${ws.name}"?`, body: 'Every board, item and file in it is deleted permanently.' })) { await api.del(`/api/workspaces/${ws.id}`); await loadTree(); pickWs(s.workspaces[0]?.id); }
} },
]);
const addMenu = (e) => menu(e, [
{ label: 'New board', icon: 'board', onClick: () => openModal(NewBoardModal, { workspaceId: ws.id }) },
{ label: 'New folder', icon: 'folder', onClick: () => A.newFolder(ws.id) },
]);
const BoardLink = ({ b, depth = 0 }) => html`<div class="nav-board ${r.boardId === b.id ? 'active' : ''}" style=${{ paddingLeft: 12 + depth * 14 }}
draggable="true" onDragStart=${(e) => { e.dataTransfer.setData('wb/board', b.id); }}
onClick=${() => navigate(boardPath(b.id))}
onContextMenu=${(e) => boardMenu(e, b)}>
<${Icon} name="board" size=${15} /><span class="nav-label">${b.name}</span>
<button class="icon-btn nav-more" onClick=${(e) => boardMenu(e, b)}><${Icon} name="dots" size=${14} /></button>
</div>`;
const boardMenu = (e, b) => menu(e, [
{ label: 'Open in new tab', icon: 'expand', onClick: () => window.open(boardPath(b.id), '_blank') },
{ label: 'Rename', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Rename board', value: b.name }); if (n) { await A.renameBoard(b.id, n); loadTree(); } } },
{ label: s.favorites.includes(b.id) ? 'Remove from favorites' : 'Add to favorites', icon: 'star', onClick: () => A.toggleFavorite(b.id).then(loadTree) },
{ label: 'Duplicate', icon: 'copy', onClick: () => A.duplicateBoard(b.id) },
{ label: 'Move to folder', icon: 'folder', keepOpen: true, onClick: (ev) => menu(ev, [{ label: '(no folder)', onClick: () => moveBoard(b, null) },
...wsFolders.map((f) => ({ label: f.name, onClick: () => moveBoard(b, f.id) }))]) },
{ divider: true },
{ label: 'Delete', icon: 'trash', danger: true, onClick: () => A.deleteBoard(b.id) },
]);
const moveBoard = async (b, folderId) => { await api.patch(`/api/boards/${b.id}`, { folder_id: folderId }); loadTree(); };
const Folder = ({ f, depth = 0 }) => {
const open = openFolders[f.id] || !!filter;
const kids = wsFolders.filter((x) => x.parent_id === f.id);
const boards = wsBoards.filter((b) => b.folder_id === f.id);
if (filter && !boards.length && !kids.length) return null;
const fMenu = (e) => menu(e, [
{ label: 'New board here', icon: 'board', onClick: () => openModal(NewBoardModal, { workspaceId: ws.id, folderId: f.id }) },
{ label: 'New subfolder', icon: 'folder', onClick: () => A.newFolder(ws.id, f.id) },
{ label: 'Rename', icon: 'edit', onClick: () => A.renameFolder(f) },
{ label: 'Color', icon: 'gear', keepOpen: true, onClick: (ev) => openPopover(ev.currentTarget, ColorPicker, { value: f.color, onPick: async (c) => { await api.patch(`/api/folders/${f.id}`, { color: c }); loadTree(); } }) },
{ divider: true },
{ label: 'Delete folder', icon: 'trash', danger: true, onClick: () => A.deleteFolder(f) },
]);
return html`<div class="nav-folder">
<div class="nav-folder-row" style=${{ paddingLeft: 8 + depth * 14 }} onClick=${() => toggleFolder(f.id)} onContextMenu=${fMenu}
onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { const id = e.dataTransfer.getData('wb/board'); if (id) moveBoard({ id }, f.id); }}>
<${Icon} name=${open ? 'chevDown' : 'chevRight'} size=${13} />
<span style=${{ color: f.color || undefined }}><${Icon} name=${open ? 'folderOpen' : 'folder'} size=${15} /></span>
<span class="nav-label">${f.name}</span>
<button class="icon-btn nav-more" onClick=${fMenu}><${Icon} name="dots" size=${14} /></button>
</div>
${open ? html`${kids.map((k) => html`<${Folder} key=${k.id} f=${k} depth=${depth + 1} />`)}${boards.map((b) => html`<${BoardLink} key=${b.id} b=${b} depth=${depth + 1} />`)}` : null}
</div>`;
};
return html`<aside class="sidebar ${s.sidebarOpen ? 'open' : ''} ${s.sidebarCollapsed ? 'collapsed' : ''}">
<div class="rail">
<button class="logo" onClick=${() => navigate('/')} title="Home"><span class="logo-mark"><i /><i /><i /></span></button>
<${RailBtn} icon="home" label="Home" active=${r.name === 'home'} onClick=${() => navigate('/')} />
<${RailBtn} icon="work" label="My work" active=${r.name === 'my-work'} onClick=${() => navigate('/my-work')} />
<${RailBtn} icon="bell" label="Inbox" active=${r.name === 'inbox'} onClick=${() => navigate('/inbox')} badge=${s.unread} />
<${RailBtn} icon="search" label="Search" active=${r.name === 'search'} onClick=${() => navigate('/search')} />
<${RailBtn} icon="people" label="Team" active=${r.name === 'people'} onClick=${() => navigate('/people')} />
<span class="spacer" />
${s.me?.role === 'admin' ? html`<${RailBtn} icon="shield" label="Admin" active=${r.name === 'admin'} onClick=${() => navigate('/admin')} />` : null}
<button class="rail-me" onClick=${(e) => openPopover(e.currentTarget, UserMenu, {}, {})} title=${s.me?.name}><${Avatar} user=${s.me} size=${32} /></button>
</div>
<div class="nav-panel">
<div class="nav-head">
<button class="ws-picker" onClick=${wsMenu}>
<span class="ws-icon" style=${{ background: ws?.color || '#579bfc' }}>${(ws?.name || '?')[0]}</span>
<span class="nav-label">${ws?.name || 'Workspace'}</span><${Icon} name="chevDown" size=${14} />
</button>
<button class="icon-btn" onClick=${addMenu} title="Add"><${Icon} name="plus" /></button>
<button class="icon-btn collapse-btn" onClick=${() => { localSet('wb.sidebarCollapsed', '1'); update({ sidebarCollapsed: true }); }} title="Collapse"><${Icon} name="chevLeft" /></button>
</div>
<div class="nav-search"><${Icon} name="search" size=${14} /><input placeholder="Search boards" value=${q} onInput=${(e) => setQ(e.target.value)} /></div>
<div class="nav-scroll">
${favs.length && !filter ? html`<div class="nav-section"><div class="nav-section-title"><${Icon} name="star" size=${13} /> Favorites</div>
${favs.map((b) => html`<${BoardLink} key=${'f' + b.id} b=${b} />`)}</div>` : null}
<div class="nav-section" onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { const id = e.dataTransfer.getData('wb/board'); if (id && e.target.classList.contains('nav-section-title')) moveBoard({ id }, null); }}>
<div class="nav-section-title">Boards</div>
${wsFolders.filter((f) => !f.parent_id).map((f) => html`<${Folder} key=${f.id} f=${f} />`)}
${wsBoards.filter((b) => !b.folder_id || !wsFolders.some((f) => f.id === b.folder_id)).map((b) => html`<${BoardLink} key=${b.id} b=${b} />`)}
${!wsBoards.length && !filter ? html`<button class="nav-empty" onClick=${() => openModal(NewBoardModal, { workspaceId: ws.id })}><${Icon} name="plus" /> Create your first board</button>` : null}
</div>
</div>
</div>
${s.sidebarCollapsed ? html`<button class="expand-btn" onClick=${() => { localSet('wb.sidebarCollapsed', null); update({ sidebarCollapsed: false }); }} title="Expand"><${Icon} name="chevRight" /></button>` : null}
</aside>
${s.sidebarOpen ? html`<div class="drawer-backdrop" onClick=${() => update({ sidebarOpen: false })} />` : null}`;
}
function RailBtn({ icon, label, active, onClick, badge }) {
return html`<button class="rail-btn ${active ? 'active' : ''}" onClick=${onClick} title=${label}>
<${Icon} name=${icon} size=${20} />${badge ? html`<span class="rail-badge">${badge > 99 ? '99+' : badge}</span>` : null}<span class="rail-label">${label}</span></button>`;
}
const TEMPLATES = [
['blank', 'Blank board', 'Owner, Status and Due date to start', 'board'],
['project', 'Project tracker', 'Owner, status, priority, timeline', 'timeline'],
['inventory', 'Equipment inventory', 'Model, serial #, location, condition, files', 'archive'],
['content', 'Content calendar', 'Owner, status, publish date, channel, caption', 'calendar'],
['requests', 'Requests', 'Intake for requests with priority and due date', 'inbox'],
];
export function NewBoardModal({ workspaceId, folderId }) {
const s = useStore();
const [name, setName] = useState('');
const [tpl, setTpl] = useState('blank');
const [ws, setWs] = useState(workspaceId);
const [folder, setFolder] = useState(folderId || '');
const [busy, setBusy] = useState(false);
const create = async (e) => {
e.preventDefault();
setBusy(true);
try {
const r = await api.post('/api/boards', { workspace_id: ws, folder_id: folder || null, name: name.trim() || 'New board', template: tpl });
await loadTree(); closeModal(); navigate(boardPath(r.id));
} finally { setBusy(false); }
};
return html`<form class="dialog" onSubmit=${create}><${ModalHeader} title="Create board" />
<label class="field-label">Board name</label>
<input class="input" value=${name} onInput=${(e) => setName(e.target.value)} placeholder="New board" autofocus />
<label class="field-label">Start from</label>
<div class="tpl-grid">${TEMPLATES.map(([k, l, d, i]) => html`<button type="button" key=${k} class="tpl ${tpl === k ? 'on' : ''}" onClick=${() => setTpl(k)}>
<${Icon} name=${i} size=${20} /><strong>${l}</strong><span class="muted small">${d}</span></button>`)}</div>
<div class="row gap">
<div class="grow"><label class="field-label">Workspace</label>
<select class="input" value=${ws} onChange=${(e) => { setWs(e.target.value); setFolder(''); }}>${s.workspaces.map((w) => html`<option value=${w.id}>${w.name}</option>`)}</select></div>
<div class="grow"><label class="field-label">Folder</label>
<select class="input" value=${folder} onChange=${(e) => setFolder(e.target.value)}><option value="">(none)</option>
${s.folders.filter((f) => f.workspace_id === ws).map((f) => html`<option value=${f.id}>${f.name}</option>`)}</select></div>
</div>
<div class="modal-actions"><button type="button" class="btn ghost" onClick=${closeModal}>Cancel</button><button class="btn primary" disabled=${busy}>Create board</button></div>
</form>`;
}
+143
View File
@@ -0,0 +1,143 @@
// Tiny global store: plain object + subscribe. Components call useStore() to re-render on change.
import { useEffect, useState } from '../vendor/preact-htm.mjs';
import { api, onEvent, clientId } from './api.js';
export const state = {
me: null,
users: [],
usersById: {},
workspaces: [],
folders: [],
boards: [],
favorites: [],
unread: 0,
online: [],
boardData: {}, // boardId → full board payload
route: parseRoute(),
toasts: [],
sidebarOpen: false, // mobile drawer
sidebarCollapsed: localGet('wb.sidebarCollapsed') === '1',
currentWorkspace: localGet('wb.workspace'),
theme: localGet('wb.theme') || 'system',
};
const subs = new Set();
let scheduled = false;
export function update(patch) {
if (patch) Object.assign(state, typeof patch === 'function' ? patch(state) : patch);
if (scheduled) return;
scheduled = true;
queueMicrotask(() => { scheduled = false; for (const fn of subs) fn(); });
}
export function useStore() {
const [, set] = useState(0);
useEffect(() => { const fn = () => set((n) => n + 1); subs.add(fn); return () => subs.delete(fn); }, []);
return state;
}
export function localGet(k) { try { return localStorage.getItem(k); } catch { return null; } }
export function localSet(k, v) { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch { /* private mode */ } }
// ─── Routing ─────────────────────────────────────────────
export function parseRoute(path = location.pathname, search = location.search) {
const p = path.split('/').filter(Boolean);
const q = Object.fromEntries(new URLSearchParams(search));
if (p[0] === 'boards' && p[1]) {
return { name: 'board', boardId: p[1], viewId: p[2] === 'views' ? p[3] : null, itemId: p.includes('items') ? p[p.indexOf('items') + 1] : null, q };
}
if (p[0] === 'workspaces' && p[1]) return { name: 'workspace', workspaceId: p[1], q };
if (['my-work', 'inbox', 'search', 'admin', 'people', 'favorites', 'profile'].includes(p[0])) return { name: p[0], q, tab: p[1] };
return { name: 'home', q };
}
export function navigate(path, { replace = false } = {}) {
if (path === location.pathname + location.search) return;
history[replace ? 'replaceState' : 'pushState']({}, '', path);
update({ route: parseRoute(), sidebarOpen: false });
}
window.addEventListener('popstate', () => update({ route: parseRoute() }));
export function boardPath(boardId, { viewId, itemId } = {}) {
let p = `/boards/${boardId}`;
if (viewId) p += `/views/${viewId}`;
if (itemId) p += `/items/${itemId}`;
return p;
}
// ─── Data loading ────────────────────────────────────────
export async function loadBootstrap() {
const d = await api.get('/api/bootstrap');
setUsers(d.users);
update({ me: d.me, workspaces: d.workspaces, folders: d.folders, boards: d.boards, favorites: d.favorites, unread: d.unread, online: d.online });
if (!state.currentWorkspace || !d.workspaces.some((w) => w.id === state.currentWorkspace)) {
update({ currentWorkspace: d.workspaces[0]?.id || null });
}
}
function setUsers(users) {
update({ users, usersById: Object.fromEntries(users.map((u) => [u.id, u])) });
}
export async function loadTree() {
const d = await api.get('/api/tree');
update({ workspaces: d.workspaces, folders: d.folders, boards: d.boards, favorites: d.favorites });
}
export async function loadUsers() { setUsers(await api.get('/api/users')); }
const inflight = {};
export async function loadBoard(boardId) {
if (inflight[boardId]) return inflight[boardId];
inflight[boardId] = api.get(`/api/boards/${boardId}`)
.then((d) => { state.boardData[boardId] = d; update(); return d; })
.catch((e) => { if (e.status === 404) { state.boardData[boardId] = { missing: true }; update(); } throw e; })
.finally(() => { delete inflight[boardId]; });
return inflight[boardId];
}
// Optimistic local edit of a loaded board, then server call; on failure, reload.
export function mutateBoard(boardId, fn) {
const b = state.boardData[boardId];
if (b && !b.missing) { fn(b); update(); }
}
export async function serverCall(boardId, promise) {
try { return await promise; }
catch (e) { toast(e.message, 'error'); if (boardId) loadBoard(boardId); throw e; }
}
// Live refresh: refetch what's on screen when someone else changes it.
const refetchTimers = {};
onEvent((ev) => {
if (ev.type === 'reconnected') {
if (state.me) { loadTree(); for (const id of Object.keys(state.boardData)) if (isVisible(id)) loadBoard(id); }
return;
}
if (ev.type === 'notification') {
api.get('/api/bootstrap').then((d) => update({ unread: d.unread, online: d.online }));
window.dispatchEvent(new CustomEvent('wb:notifications'));
return;
}
if (ev.origin === clientId) return;
if (ev.type === 'tree') loadTree();
if (ev.type === 'board' && state.boardData[ev.boardId]) {
clearTimeout(refetchTimers[ev.boardId]);
refetchTimers[ev.boardId] = setTimeout(() => {
if (isVisible(ev.boardId)) loadBoard(ev.boardId); else delete state.boardData[ev.boardId];
window.dispatchEvent(new CustomEvent('wb:board-changed', { detail: ev.boardId }));
}, 150);
}
});
function isVisible(boardId) { return state.route.boardId === boardId; }
// ─── Toasts ──────────────────────────────────────────────
let toastId = 0;
export function toast(text, kind = 'info', action) {
const t = { id: ++toastId, text, kind, action };
update({ toasts: [...state.toasts, t] });
setTimeout(() => update({ toasts: state.toasts.filter((x) => x.id !== t.id) }), action ? 6000 : 3500);
}
// ─── Theme ───────────────────────────────────────────────
export function applyTheme(theme = state.theme) {
const dark = theme === 'dark' || theme === 'night' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = theme === 'night' ? 'night' : dark ? 'dark' : 'light';
document.querySelector('meta[name=theme-color]')?.setAttribute('content', dark ? (theme === 'night' ? '#111111' : '#181b34') : '#ffffff');
}
export function setTheme(theme) { localSet('wb.theme', theme); update({ theme }); applyTheme(theme); }
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => applyTheme());
+312
View File
@@ -0,0 +1,312 @@
// Main table view: colored groups, inline-editable cells, drag & drop, column menus,
// subitems, footers, multi-select bulk actions. Becomes a card list on phones.
import { html, useState, useRef, useEffect } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, update, navigate, boardPath, mutateBoard, loadBoard } from './store.js';
import { Icon, InlineEdit, menu, openPopover, closePopover, ColorPicker, promptDialog, AvatarStack, textOn, fmtDate, fmtRange, openModal } from './ui.js';
import { Cell, Summary, COLUMN_TYPES, colWidth, labelOf } from './cells.js';
import * as A from './actions.js';
import { ColumnSettingsModal } from './boardModals.js';
const NAME_W = 340;
const selection = new Set();
let dragItem = null;
let dragGroup = null;
function setSel(fn) { fn(selection); update(); }
export function TableView({ board, view, items, subs, matches, hidden, filter, mobile }) {
const boardId = board.board.id;
const cols = board.columns.filter((c) => !hidden.includes(c.id));
const groups = [...board.groups].sort((a, b) => a.position - b.position);
const byGroup = {};
for (const it of items) (byGroup[it.group_id] ||= []).push(it);
const [expanded, setExpanded] = useState({});
const filtering = !!matches || !!filter.sort;
useEffect(() => () => selection.clear(), [boardId]);
if (mobile) return html`<${MobileList} board=${board} groups=${groups} byGroup=${byGroup} subs=${subs} view=${view} />`;
const totalW = NAME_W + cols.reduce((a, c) => a + colWidth(c), 0) + 60;
return html`<div class="table-scroll">
<div class="table" style=${{ minWidth: totalW }}>
${groups.map((g) => {
const gItems = byGroup[g.id] || [];
if (filtering && !gItems.length) return null;
return html`<${Group} key=${g.id} board=${board} group=${g} items=${gItems} cols=${cols} subs=${subs} view=${view}
expanded=${expanded} setExpanded=${setExpanded} totalW=${totalW} filtering=${filtering} />`;
})}
<button class="add-group-btn" onClick=${() => A.addGroup(boardId, { top: false })}><${Icon} name="plus" /> Add new group</button>
</div>
${selection.size ? html`<${BulkBar} board=${board} />` : null}
</div>`;
}
function Group({ board, group: g, items, cols, subs, view, expanded, setExpanded, totalW, filtering }) {
const boardId = board.board.id;
const [dropOver, setDropOver] = useState(false);
const allSel = items.length && items.every((i) => selection.has(i.id));
const groupMenu = (e) => menu(e, [
{ label: g.collapsed ? 'Expand group' : 'Collapse group', icon: g.collapsed ? 'chevDown' : 'chevRight', onClick: () => A.patchGroup(boardId, g.id, { collapsed: !g.collapsed }) },
{ label: 'Collapse all groups', icon: 'chevUp', onClick: () => board.groups.forEach((x) => A.patchGroup(boardId, x.id, { collapsed: true })) },
{ label: 'Expand all groups', icon: 'chevDown', onClick: () => board.groups.forEach((x) => A.patchGroup(boardId, x.id, { collapsed: false })) },
{ divider: true },
{ label: 'Add group', icon: 'plus', onClick: () => A.addGroup(boardId, { top: false }) },
{ label: 'Rename group', icon: 'edit', onClick: async () => { const t = await promptDialog({ title: 'Rename group', value: g.title }); if (t) A.patchGroup(boardId, g.id, { title: t }); } },
{ label: 'Change group color', icon: 'gear', keepOpen: true, onClick: (ev) => openPopover(ev.currentTarget, ColorPicker, { value: g.color, onPick: (c) => A.patchGroup(boardId, g.id, { color: c }) }) },
{ label: 'Duplicate group', icon: 'copy', onClick: async () => { await api.post(`/api/groups/${g.id}/duplicate`); loadBoard(boardId); } },
{ label: 'Move group up', icon: 'chevUp', onClick: () => moveGroup(board, g, -1) },
{ label: 'Move group down', icon: 'chevDown', onClick: () => moveGroup(board, g, 1) },
{ label: 'Select all items', icon: 'check', onClick: () => setSel((s) => items.forEach((i) => s.add(i.id))) },
{ divider: true },
{ label: 'Delete group', icon: 'trash', danger: true, onClick: () => A.deleteGroup(boardId, g.id) },
]);
const onGroupDragStart = (e) => { dragGroup = g.id; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', g.id); };
const onDrop = (e) => {
e.preventDefault(); setDropOver(false);
if (dragGroup && dragGroup !== g.id) {
const sorted = [...board.groups].sort((a, b) => a.position - b.position).filter((x) => x.id !== dragGroup);
const idx = sorted.findIndex((x) => x.id === g.id);
A.patchGroup(boardId, dragGroup, { position: A.between(sorted[idx - 1]?.position, g.position) });
dragGroup = null;
} else if (dragItem && !dragItem.parent_id && dragItem.group_id !== g.id) {
const last = items[items.length - 1];
A.moveItem(boardId, dragItem.id, { group_id: g.id, position: (last?.position ?? 0) + 1 });
dragItem = null;
}
};
if (g.collapsed) {
return html`<div class="group collapsed" style=${{ '--g': g.color }} onDragOver=${(e) => { e.preventDefault(); setDropOver(true); }} onDragLeave=${() => setDropOver(false)} onDrop=${onDrop}>
<div class="group-collapsed-row ${dropOver ? 'drop' : ''}" style=${{ width: totalW }}>
<div class="gc-name" style=${{ width: NAME_W }} draggable="true" onDragStart=${onGroupDragStart}>
<button class="icon-btn" onClick=${() => A.patchGroup(boardId, g.id, { collapsed: false })}><${Icon} name="chevRight" /></button>
<div><div class="group-title" style=${{ color: g.color }}>${g.title}</div><div class="muted small">${items.length} ${items.length === 1 ? board.board.item_label : 'items'}</div></div>
<button class="icon-btn group-menu-btn" onClick=${groupMenu}><${Icon} name="dots" /></button>
</div>
${cols.map((c) => html`<div key=${c.id} class="gc-cell" style=${{ width: colWidth(c) }}><div class="gc-label muted small">${c.title}</div><${Summary} board=${board} col=${c} items=${items} /></div>`)}
</div></div>`;
}
return html`<section class="group" style=${{ '--g': g.color }}>
<div class="group-head">
<button class="icon-btn group-menu-btn" onClick=${groupMenu}><${Icon} name="dots" /></button>
<button class="icon-btn" onClick=${() => A.patchGroup(boardId, g.id, { collapsed: true })} style=${{ color: g.color }}><${Icon} name="chevDown" /></button>
<span class="group-title" style=${{ color: g.color }} draggable="true" onDragStart=${onGroupDragStart}>
<${InlineEdit} value=${g.title} onSave=${(t) => t && A.patchGroup(boardId, g.id, { title: t })} /></span>
<span class="muted small group-count">${items.length} ${items.length === 1 ? board.board.item_label : 'items'}</span>
</div>
<div class="group-table ${dropOver ? 'drop' : ''}" onDragOver=${(e) => { if (dragGroup || (dragItem && !dragItem.parent_id)) { e.preventDefault(); setDropOver(true); } }}
onDragLeave=${(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDropOver(false); }} onDrop=${onDrop}>
<div class="trow thead">
<div class="tcell name-cell sticky" style=${{ width: NAME_W }}>
<span class="row-bar first" />
<label class="row-check"><input type="checkbox" checked=${allSel} onChange=${(e) => setSel((s) => items.forEach((i) => e.target.checked ? s.add(i.id) : s.delete(i.id)))} /></label>
<span class="th-title">${board.board.item_label}</span>
</div>
${cols.map((c) => html`<${ColumnHeader} key=${c.id} board=${board} col=${c} />`)}
<div class="tcell add-col"><button class="icon-btn" onClick=${(e) => addColumnMenu(e, boardId)} title="Add column"><${Icon} name="plus" /></button></div>
</div>
${items.map((it) => html`<${Row} key=${it.id} board=${board} item=${it} cols=${cols} view=${view} group=${g}
subs=${subs[it.id] || []} expanded=${!!expanded[it.id]} toggle=${() => setExpanded({ ...expanded, [it.id]: !expanded[it.id] })} siblings=${items} />`)}
<${AddRow} board=${board} group=${g} cols=${cols} />
<div class="trow tfoot">
<div class="tcell name-cell sticky" style=${{ width: NAME_W }}><span class="row-bar last" /></div>
${cols.map((c) => html`<div key=${c.id} class="tcell" style=${{ width: colWidth(c) }}><${Summary} board=${board} col=${c} items=${items} /></div>`)}
</div>
</div>
</section>`;
}
function moveGroup(board, g, dir) {
const sorted = [...board.groups].sort((a, b) => a.position - b.position);
const i = sorted.findIndex((x) => x.id === g.id);
const j = i + dir;
if (j < 0 || j >= sorted.length) return;
const other = sorted[j];
const pos = dir < 0 ? A.between(sorted[j - 1]?.position, other.position) : A.between(other.position, sorted[j + 1]?.position);
A.patchGroup(board.board.id, g.id, { position: pos });
}
export function addColumnMenu(e, boardId, after) {
menu(e, Object.entries(COLUMN_TYPES).map(([type, t]) => ({
label: t.label, icon: t.icon, onClick: () => A.addColumn(boardId, type, { after }),
})), { class: 'col-type-menu' });
}
function ColumnHeader({ board, col }) {
const boardId = board.board.id;
const w = colWidth(col);
const startResize = (e) => {
e.preventDefault(); e.stopPropagation();
const x0 = e.clientX;
const move = (ev) => mutateBoard(boardId, () => { col.width = Math.max(70, Math.min(600, w + ev.clientX - x0)); });
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); A.patchColumn(boardId, col.id, { width: col.width }); };
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
};
const colMenu = (e) => menu(e, [
{ label: 'Settings', icon: 'gear', onClick: () => openModal(ColumnSettingsModal, { boardId, colId: col.id }) },
{ label: 'Rename', icon: 'edit', onClick: async () => { const t = await promptDialog({ title: 'Rename column', value: col.title }); if (t) A.patchColumn(boardId, col.id, { title: t }); } },
{ label: 'Sort ascending', icon: 'sort', onClick: async () => { const { getFilter } = await import('./board.js'); Object.assign(getFilter(boardId), { sort: { colId: col.id, dir: 'asc' } }); update(); } },
{ label: 'Sort descending', icon: 'sort', onClick: async () => { const { getFilter } = await import('./board.js'); Object.assign(getFilter(boardId), { sort: { colId: col.id, dir: 'desc' } }); update(); } },
{ label: 'Add column to the right', icon: 'plus', keepOpen: true, onClick: (ev) => addColumnMenu(ev, boardId, col.id) },
{ label: 'Duplicate column', icon: 'copy', onClick: async () => {
const r = await api.post(`/api/boards/${boardId}/columns`, { type: col.type, title: `${col.title} copy`, settings: col.settings, after: col.id });
for (const it of board.items) if (it.vals[col.id] != null && col.type !== 'files') await api.patch(`/api/items/${it.id}`, { vals: { [r.id]: it.vals[col.id] } });
loadBoard(boardId);
} },
{ label: 'Move left', icon: 'chevLeft', onClick: () => moveCol(board, col, -1) },
{ label: 'Move right', icon: 'chevRight', onClick: () => moveCol(board, col, 1) },
{ divider: true },
{ label: 'Delete column', icon: 'trash', danger: true, onClick: () => A.deleteColumn(boardId, col.id) },
]);
return html`<div class="tcell th" style=${{ width: w }} onDblClick=${async () => { const t = await promptDialog({ title: 'Rename column', value: col.title }); if (t) A.patchColumn(boardId, col.id, { title: t }); }}>
<span class="th-title" title=${col.title}>${col.title}</span>
<button class="icon-btn th-menu" onClick=${colMenu}><${Icon} name="dots" size=${14} /></button>
<span class="col-resize" onPointerDown=${startResize} />
</div>`;
}
function moveCol(board, col, dir) {
const sorted = [...board.columns].sort((a, b) => a.position - b.position);
const i = sorted.findIndex((c) => c.id === col.id);
const j = i + dir;
if (j < 0 || j >= sorted.length) return;
const pos = dir < 0 ? A.between(sorted[j - 1]?.position, sorted[j].position) : A.between(sorted[j].position, sorted[j + 1]?.position);
mutateBoard(board.board.id, (b) => { col.position = pos; b.columns.sort((a, z) => a.position - z.position); });
A.patchColumn(board.board.id, col.id, { position: pos });
}
function Row({ board, item, cols, view, group, subs, expanded, toggle, siblings, isSub }) {
const boardId = board.board.id;
const [dropPos, setDropPos] = useState(null);
const nUpdates = board.updateCounts?.[item.id] || 0;
const open = () => navigate(boardPath(boardId, { viewId: view?.id, itemId: item.id }));
const sel = selection.has(item.id);
const active = state.route.itemId === item.id;
const rowMenu = (e) => menu(e, [
{ label: 'Open', icon: 'expand', onClick: open },
!isSub && { label: 'Add subitem', icon: 'subitem', onClick: async () => { if (!expanded) toggle(); await A.createItem(boardId, { parent_id: item.id, name: 'Subitem' }); } },
{ label: 'Duplicate', icon: 'copy', onClick: () => A.duplicateItems(boardId, [item.id]) },
!isSub && { label: 'Move to group', icon: 'move', keepOpen: true, onClick: (ev) => menu(ev, board.groups.map((g) => ({ label: g.title, onClick: () => A.moveItemsToGroup(boardId, [item.id], g.id) }))) },
!isSub && { label: 'Move to board', icon: 'board', keepOpen: true, onClick: (ev) => menu(ev, state.boards.filter((b) => b.id !== boardId).map((b) => ({ label: b.name, onClick: () => A.moveItemToBoard(boardId, item.id, b.id) }))) },
{ label: 'Copy link', icon: 'link', onClick: () => navigator.clipboard?.writeText(location.origin + boardPath(boardId, { itemId: item.id })) },
{ divider: true },
{ label: 'Archive', icon: 'archive', onClick: () => A.archiveItems(boardId, [item.id]) },
{ label: 'Delete', icon: 'trash', danger: true, onClick: () => A.deleteItems(boardId, [item.id]) },
]);
const onDrop = (e) => {
if (!dragItem || dragItem.id === item.id) return;
if (!!dragItem.parent_id !== !!item.parent_id || (isSub && dragItem.parent_id !== item.parent_id)) return;
e.preventDefault(); e.stopPropagation();
const idx = siblings.findIndex((s) => s.id === item.id);
const pos = dropPos === 'above' ? A.between(siblings[idx - 1]?.position, item.position) : A.between(item.position, siblings[idx + 1]?.position);
A.moveItem(boardId, dragItem.id, isSub ? { position: pos } : { group_id: group.id, position: pos });
dragItem = null; setDropPos(null);
};
return html`
<div class="trow item-row ${sel ? 'selected' : ''} ${active ? 'active' : ''} ${isSub ? 'sub' : ''} ${dropPos ? 'drop-' + dropPos : ''}"
onContextMenu=${rowMenu}
onDragOver=${(e) => { if (dragItem && dragItem.id !== item.id) { e.preventDefault(); const r = e.currentTarget.getBoundingClientRect(); setDropPos(e.clientY < r.top + r.height / 2 ? 'above' : 'below'); } }}
onDragLeave=${() => setDropPos(null)} onDrop=${onDrop}>
<div class="tcell name-cell sticky" style=${{ width: NAME_W }} draggable="true"
onDragStart=${(e) => { dragItem = item; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', item.id); }}
onDragEnd=${() => { dragItem = null; }}>
<button class="icon-btn row-menu" onClick=${rowMenu}><${Icon} name="dots" size=${14} /></button>
<span class="row-bar" />
<label class="row-check" onClick=${(e) => e.stopPropagation()}><input type="checkbox" checked=${sel} onChange=${(e) => setSel((s) => e.target.checked ? s.add(item.id) : s.delete(item.id))} /></label>
${isSub ? html`<span class="sub-indent" />` : html`<button class="icon-btn sub-toggle ${subs.length ? 'has' : ''} ${expanded ? 'open' : ''}" onClick=${toggle} title=${subs.length ? `${subs.length} subitems` : 'Add subitem'}>
<${Icon} name=${expanded ? 'chevDown' : 'chevRight'} size=${14} />${subs.length ? html`<span class="sub-count">${subs.length}</span>` : null}</button>`}
<span class="item-name"><${InlineEdit} value=${item.name} onSave=${(n) => n && A.renameItem(boardId, item.id, n)} /></span>
<button class="icon-btn open-item" onClick=${open} title="Open"><${Icon} name="expand" size=${13} /></button>
<button class="icon-btn updates-btn ${nUpdates ? 'has' : ''}" onClick=${open} title="Updates">
<${Icon} name=${nUpdates ? 'message' : 'messagePlus'} size=${18} />${nUpdates ? html`<span class="badge">${nUpdates}</span>` : null}</button>
</div>
${cols.map((c) => html`<div key=${c.id} class="tcell tc-${c.type}" style=${{ width: colWidth(c) }}><${Cell} board=${board} col=${c} item=${item} /></div>`)}
<div class="tcell add-col" />
</div>
${expanded && !isSub ? html`<div class="subitems">
${subs.sort((a, b) => a.position - b.position).map((s) => html`<${Row} key=${s.id} board=${board} item=${s} cols=${cols} view=${view} group=${group} subs=${[]} isSub=${true} siblings=${subs} />`)}
<${AddRow} board=${board} group=${group} cols=${cols} parent=${item} />
</div>` : null}`;
}
function AddRow({ board, group, cols, parent }) {
const [v, setV] = useState('');
const ref = useRef();
const add = async () => {
const name = v.trim();
if (!name) return;
setV('');
await A.createItem(board.board.id, parent ? { parent_id: parent.id, name } : { group_id: group.id, name });
ref.current?.focus();
};
return html`<div class="trow add-row ${parent ? 'sub' : ''}">
<div class="tcell name-cell sticky" style=${{ width: NAME_W }}>
<span class="row-bar faded" />
<span class="row-check-spacer" />
${parent ? html`<span class="sub-indent" />` : null}
<input ref=${ref} class="add-input" placeholder=${parent ? '+ Add subitem' : `+ Add ${board.board.item_label.toLowerCase()}`} value=${v}
onInput=${(e) => setV(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') add(); if (e.key === 'Escape') { setV(''); e.target.blur(); } }} onBlur=${add} />
</div>
<div class="tcell filler" style=${{ width: cols.reduce((a, c) => a + colWidth(c), 0) }} />
</div>`;
}
function BulkBar({ board }) {
const boardId = board.board.id;
const ids = [...selection].filter((id) => board.items.some((i) => i.id === id));
if (!ids.length) { selection.clear(); return null; }
const done = () => setSel((s) => s.clear());
const statusCols = board.columns.filter((c) => c.type === 'status');
return html`<div class="bulk-bar">
<span class="bulk-count">${ids.length}</span><span class="bulk-label">${ids.length === 1 ? 'Item' : 'Items'} selected</span>
<button class="bulk-act" onClick=${() => { A.duplicateItems(boardId, ids); done(); }}><${Icon} name="copy" /><span>Duplicate</span></button>
${statusCols.length ? html`<button class="bulk-act" onClick=${(e) => menu(e, statusCols.flatMap((c) => [{ label: c.title, disabled: true },
...(c.settings.labels || []).map((l) => ({ label: l.name, onClick: async () => { await api.post('/api/items/bulk', { ids, action: 'set', vals: { [c.id]: l.id } }); loadBoard(boardId); } }))]))}>
<${Icon} name="check" /><span>Set status</span></button>` : null}
<button class="bulk-act" onClick=${(e) => menu(e, board.groups.map((g) => ({ label: g.title, onClick: () => { A.moveItemsToGroup(boardId, ids, g.id); done(); } })))}><${Icon} name="move" /><span>Move to</span></button>
<button class="bulk-act" onClick=${() => { A.archiveItems(boardId, ids); done(); }}><${Icon} name="archive" /><span>Archive</span></button>
<button class="bulk-act danger" onClick=${async () => { if (await A.deleteItems(boardId, ids)) done(); }}><${Icon} name="trash" /><span>Delete</span></button>
<button class="icon-btn bulk-close" onClick=${done}><${Icon} name="x" /></button>
</div>`;
}
// ─── Phone layout: groups of cards ───────────────────────
function MobileList({ board, groups, byGroup, subs, view }) {
const boardId = board.board.id;
const status = board.columns.find((c) => c.type === 'status');
const people = board.columns.find((c) => c.type === 'people');
const date = board.columns.find((c) => c.type === 'date' || c.type === 'timeline');
const [adding, setAdding] = useState(null);
return html`<div class="mobile-list">
${groups.map((g) => {
const items = byGroup[g.id] || [];
return html`<section key=${g.id} class="m-group" style=${{ '--g': g.color }}>
<button class="m-group-head" onClick=${() => A.patchGroup(boardId, g.id, { collapsed: !g.collapsed })}>
<${Icon} name=${g.collapsed ? 'chevRight' : 'chevDown'} /><span style=${{ color: g.color }}>${g.title}</span><span class="muted small">${items.length}</span>
</button>
${!g.collapsed ? html`
${items.map((it) => {
const l = status && labelOf(status, it.vals[status.id]);
const dv = date && it.vals[date.id];
const n = (subs[it.id] || []).length;
return html`<button key=${it.id} class="m-card" onClick=${() => navigate(boardPath(boardId, { viewId: view?.id, itemId: it.id }))}>
<div class="m-card-top"><span class="m-name">${it.name}</span>${people ? html`<${AvatarStack} ids=${it.vals[people.id] || []} size=${24} />` : null}</div>
<div class="m-card-meta">
${status ? html`<span class="m-status" style=${{ background: l?.color || 'var(--status-empty)', color: l ? textOn(l.color) : 'var(--muted)' }}>${l?.name || status.title}</span>` : null}
${dv ? html`<span class="m-date"><${Icon} name="calendar" size=${13} /> ${date.type === 'date' ? fmtDate(dv) : fmtRange(dv)}</span>` : null}
${n ? html`<span class="muted small"><${Icon} name="subitem" size=${13} /> ${n}</span>` : null}
${board.updateCounts?.[it.id] ? html`<span class="muted small"><${Icon} name="message" size=${13} /> ${board.updateCounts[it.id]}</span>` : null}
</div></button>`;
})}
${adding === g.id ? html`<input class="input m-add-input" placeholder="Name" autofocus
onKeyDown=${async (e) => { if (e.key === 'Enter' && e.target.value.trim()) { const name = e.target.value.trim(); e.target.value = ''; await A.createItem(boardId, { group_id: g.id, name }); } if (e.key === 'Escape') setAdding(null); }}
onBlur=${() => setAdding(null)} />`
: html`<button class="m-add" onClick=${() => setAdding(g.id)}><${Icon} name="plus" /> Add ${board.board.item_label.toLowerCase()}</button>`}
` : null}
</section>`;
})}
<button class="btn ghost m-add-group" onClick=${() => A.addGroup(boardId, { top: false })}><${Icon} name="plus" /> Add group</button>
</div>`;
}
+96
View File
@@ -0,0 +1,96 @@
// Timeline (Gantt): bars from a timeline column (or a date column as single days), grouped
// by board group. Drag a bar to move it, drag its right edge to change the end date.
import { html, useState, useRef, useEffect } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { navigate, boardPath, mutateBoard } from './store.js';
import { Icon, ymd, parseDate, todayStr, Empty, AvatarStack } from './ui.js';
import * as A from './actions.js';
const DAY = 86400000;
const SCALES = { day: 40, week: 18, month: 6 };
export function TimelineView({ board, view, items }) {
const boardId = board.board.id;
const cols = board.columns.filter((c) => c.type === 'timeline' || c.type === 'date');
const col = cols.find((c) => c.id === view.settings?.column) || cols.find((c) => c.type === 'timeline') || cols[0];
const people = board.columns.find((c) => c.type === 'people');
const [scale, setScale] = useState(view.settings?.scale || 'week');
const scroller = useRef();
const [drag, setDrag] = useState(null);
const px = SCALES[scale];
const today = parseDate(todayStr());
const range = (it) => {
const v = it.vals[col?.id];
if (!v) return null;
if (col.type === 'date') { const d = parseDate(String(v).slice(0, 10)); return { from: d, to: d }; }
return { from: parseDate(v.from), to: parseDate(v.to || v.from) };
};
const ranges = items.map(range).filter(Boolean);
const min = new Date(Math.min(today - 14 * DAY, ...ranges.map((r) => r.from))); min.setDate(min.getDate() - 7);
const max = new Date(Math.max(today.getTime() + 60 * DAY, ...ranges.map((r) => r.to.getTime()))); max.setDate(max.getDate() + 14);
const days = Math.round((max - min) / DAY);
const x = (d) => Math.round((d - min) / DAY) * px;
useEffect(() => { if (scroller.current) scroller.current.scrollLeft = Math.max(0, x(today) - 200); }, [scale, col?.id]);
if (!col) return html`<${Empty} icon="timeline" title="Timeline needs a timeline or date column"><button class="btn primary" onClick=${() => A.addColumn(boardId, 'timeline')}>Add a timeline column</button><//>`;
const setting = (patch) => { mutateBoard(boardId, () => { view.settings = { ...view.settings, ...patch }; }); api.patch(`/api/views/${view.id}`, { settings: { ...view.settings, ...patch } }); };
const startDrag = (e, it, mode) => {
e.preventDefault(); e.stopPropagation();
const r = range(it); const x0 = e.clientX;
let delta = 0;
const move = (ev) => { delta = Math.round((ev.clientX - x0) / px); setDrag({ id: it.id, mode, delta }); };
const up = () => {
window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up);
setDrag(null);
if (!delta) { if (mode === 'move') navigate(boardPath(boardId, { viewId: view.id, itemId: it.id })); return; }
const shift = (d, n) => { const z = new Date(d); z.setDate(z.getDate() + n); return ymd(z); };
if (col.type === 'date') A.setValue(boardId, it.id, col.id, shift(r.from, delta));
else if (mode === 'move') A.setValue(boardId, it.id, col.id, { from: shift(r.from, delta), to: shift(r.to, delta) });
else { const to = new Date(r.to); to.setDate(to.getDate() + delta); A.setValue(boardId, it.id, col.id, { from: ymd(r.from), to: ymd(to < r.from ? r.from : to) }); }
};
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
};
// Header ticks
const ticks = [];
for (let i = 0; i <= days; i++) {
const d = new Date(min); d.setDate(d.getDate() + i);
if (scale === 'day' || (scale === 'week' && d.getDay() === 1) || (scale === 'month' && d.getDate() === 1)) ticks.push(d);
}
const groups = [...board.groups].sort((a, b) => a.position - b.position);
return html`<div class="timeline">
<div class="view-options">
<div class="seg">${Object.keys(SCALES).map((s) => html`<button key=${s} class=${scale === s ? 'on' : ''} onClick=${() => { setScale(s); setting({ scale: s }); }}>${s[0].toUpperCase() + s.slice(1)}s</button>`)}</div>
<button class="btn ghost sm" onClick=${() => { scroller.current.scrollLeft = Math.max(0, x(today) - 200); }}>Today</button>
${cols.length > 1 ? html`<select class="input sm" value=${col.id} onChange=${(e) => setting({ column: e.target.value })}>${cols.map((c) => html`<option value=${c.id}>${c.title}</option>`)}</select>` : null}
</div>
<div class="tl-body">
<div class="tl-names">
<div class="tl-corner" />
${groups.map((g) => { const gi = items.filter((i) => i.group_id === g.id); return gi.length ? html`<div key=${g.id}>
<div class="tl-group" style=${{ color: g.color }}>${g.title}</div>
${gi.map((it) => html`<div key=${it.id} class="tl-name" onClick=${() => navigate(boardPath(boardId, { viewId: view.id, itemId: it.id }))}>${it.name}</div>`)}</div>` : null; })}
</div>
<div class="tl-scroll" ref=${scroller}>
<div class="tl-canvas" style=${{ width: days * px }}>
<div class="tl-ticks">${ticks.map((d) => html`<span key=${d.getTime()} style=${{ left: x(d) }}>${scale === 'month' ? d.toLocaleString([], { month: 'short', year: '2-digit' }) : `${d.toLocaleString([], { month: 'short' })} ${d.getDate()}`}</span>`)}</div>
<div class="tl-today" style=${{ left: x(today) + px / 2 }} />
${groups.map((g) => { const gi = items.filter((i) => i.group_id === g.id); return gi.length ? html`<div key=${g.id}>
<div class="tl-group-row" />
${gi.map((it) => {
const r = range(it);
let left = r ? x(r.from) : 0; let width = r ? Math.max(px, x(r.to) - x(r.from) + px) : 0;
if (drag?.id === it.id) { if (drag.mode === 'move') left += drag.delta * px; else width = Math.max(px, width + drag.delta * px); }
return html`<div key=${it.id} class="tl-row">
${r ? html`<div class="tl-item" style=${{ left, width, background: g.color }} onPointerDown=${(e) => startDrag(e, it, 'move')} title=${it.name}>
<span class="tl-label">${it.name}</span>${people ? html`<${AvatarStack} ids=${it.vals[people.id] || []} size=${18} max=${2} />` : null}
${col.type === 'timeline' ? html`<span class="tl-resize" onPointerDown=${(e) => startDrag(e, it, 'resize')} />` : null}
</div>` : html`<button class="tl-set muted small" style=${{ left: x(today) }} onClick=${() => A.setValue(boardId, it.id, col.id, col.type === 'date' ? todayStr() : { from: todayStr(), to: ymd(new Date(today.getTime() + 6 * DAY)) })}>+ Set dates</button>`}
</div>`;
})}</div>` : null; })}
</div>
</div>
</div>
</div>`;
}
+330
View File
@@ -0,0 +1,330 @@
// Shared UI pieces: icons, avatars, popovers, modals, menus, inline editing, formatting.
import { html, useState, useEffect, useRef, useLayoutEffect } from '../vendor/preact-htm.mjs';
import { state, update } from './store.js';
// ─── Icons (24px stroke set) ─────────────────────────────
const P = {
home: 'M3 11l9-8 9 8M5 10v10h5v-6h4v6h5V10',
work: 'M9 11l3 3 8-8M20 12v7a2 2 0 01-2 2H6a2 2 0 01-2-2V5a2 2 0 012-2h9',
bell: 'M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 01-3.4 0',
inbox: 'M22 12h-6l-2 3h-4l-2-3H2M5.5 5.1L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.5-6.9A2 2 0 0016.8 4H7.2a2 2 0 00-1.7 1.1z',
search: 'M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.3-4.3',
plus: 'M12 5v14M5 12h14',
board: 'M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2zM3 9h18M9 21V9',
folder: 'M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z',
folderOpen: 'M6 14l1.5-2.9A2 2 0 019.2 10H20a2 2 0 011.9 2.5l-1.5 6A2 2 0 0118.5 20H4a2 2 0 01-2-2V5a2 2 0 012-2h3.9a2 2 0 011.7.9l.8 1.2a2 2 0 001.7.9H18a2 2 0 012 2v2',
star: 'M12 2l3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21l1.2-6.8-5-4.9 6.9-1z',
chevDown: 'M6 9l6 6 6-6', chevRight: 'M9 18l6-6-6-6', chevLeft: 'M15 18l-6-6 6-6', chevUp: 'M18 15l-6-6-6 6',
dots: 'M12 13a1 1 0 100-2 1 1 0 000 2zM19 13a1 1 0 100-2 1 1 0 000 2zM5 13a1 1 0 100-2 1 1 0 000 2z',
x: 'M18 6L6 18M6 6l12 12',
check: 'M20 6L9 17l-5-5',
trash: 'M3 6h18M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2',
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z',
copy: 'M20 9h-9a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-9a2 2 0 00-2-2zM5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1',
filter: 'M22 3H2l8 9.5V19l4 2v-8.5z',
sort: 'M3 6h13M3 12h9M3 18h5M17 8v12M14 17l3 3 3-3',
eye: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12zM12 15a3 3 0 100-6 3 3 0 000 6z',
eyeOff: 'M17.9 17.9A10 10 0 0112 20c-7 0-11-8-11-8a18.5 18.5 0 015.1-5.9M9.9 4.2A9 9 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.2 3.2M1 1l22 22M14.1 14.1a3 3 0 01-4.2-4.2',
person: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z',
people: 'M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.9M16 3.1a4 4 0 010 7.8',
group: 'M4 6h16M4 12h16M4 18h10',
table: 'M3 3h18v18H3zM3 9h18M3 15h18M9 3v18',
kanban: 'M4 3h4v18H4zM10 3h4v12h-4zM16 3h4v8h-4z',
calendar: 'M19 4H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zM16 2v4M8 2v4M3 10h18',
timeline: 'M3 6h10M7 12h12M5 18h8',
chart: 'M18 20V10M12 20V4M6 20v-6',
file: 'M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8zM14 2v6h6',
paperclip: 'M21.4 11.1l-9.2 9.2a6 6 0 01-8.5-8.5l9.2-9.2a4 4 0 015.7 5.7l-9.2 9.2a2 2 0 01-2.8-2.8l8.5-8.5',
bolt: 'M13 2L3 14h9l-1 8 10-12h-9z',
gear: 'M12 15a3 3 0 100-6 3 3 0 000 6zM19.4 15a1.7 1.7 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.8-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 11-4 0v-.1a1.7 1.7 0 00-1.1-1.5 1.7 1.7 0 00-1.8.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.7 1.7 0 00.3-1.8 1.7 1.7 0 00-1.5-1H3a2 2 0 110-4h.1a1.7 1.7 0 001.5-1.1 1.7 1.7 0 00-.3-1.8l-.1-.1a2 2 0 112.8-2.8l.1.1a1.7 1.7 0 001.8.3H9a1.7 1.7 0 001-1.5V3a2 2 0 114 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.8-.3l.1-.1a2 2 0 112.8 2.8l-.1.1a1.7 1.7 0 00-.3 1.8V9a1.7 1.7 0 001.5 1H21a2 2 0 110 4h-.1a1.7 1.7 0 00-1.5 1z',
menu: 'M3 12h18M3 6h18M3 18h18',
logout: 'M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9',
sun: 'M12 17a5 5 0 100-10 5 5 0 000 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4',
moon: 'M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z',
message: 'M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z',
messagePlus: 'M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2zM12 7v6M9 10h6',
activity: 'M22 12h-4l-3 9L9 3l-3 9H2',
info: 'M12 22a10 10 0 100-20 10 10 0 000 20zM12 16v-4M12 8h.01',
download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3',
upload: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12',
link: 'M10 13a5 5 0 007.5.5l3-3a5 5 0 00-7-7l-1.7 1.7M14 11a5 5 0 00-7.5-.5l-3 3a5 5 0 007 7l1.7-1.7',
thumb: 'M7 22V11M2 13v7a2 2 0 002 2h12.4a2 2 0 002-1.7l1.4-9A2 2 0 0017.8 9H14V4a3 3 0 00-3-3l-4 9',
reply: 'M9 17l-5-5 5-5M20 18v-2a4 4 0 00-4-4H4',
grip: 'M9 5h.01M9 12h.01M9 19h.01M15 5h.01M15 12h.01M15 19h.01',
expand: 'M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7',
archive: 'M21 8v13H3V8M1 3h22v5H1zM10 12h4',
move: 'M5 9l-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20',
workspace: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z',
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
import: 'M12 3v12M7 10l5 5 5-5M5 21h14',
subitem: 'M6 3v12a3 3 0 003 3h9M15 15l3 3-3 3',
pin: 'M12 17v5M9 10.8V5h6v5.8l3 3.2H6z',
refresh: 'M23 4v6h-6M1 20v-6h6M3.5 9a9 9 0 0114.8-3.4L23 10M1 14l4.7 4.4A9 9 0 0020.5 15',
undo: 'M3 7v6h6M21 17a9 9 0 00-15-6.7L3 13',
};
export function Icon({ name, size = 16, fill = false, class: cls = '' }) {
return html`<svg class="icon ${cls}" width=${size} height=${size} viewBox="0 0 24 24" fill=${fill ? 'currentColor' : 'none'}
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d=${P[name] || ''} /></svg>`;
}
// ─── Avatars ─────────────────────────────────────────────
const AV_COLORS = ['#579bfc', '#00c875', '#fdab3d', '#e2445c', '#a25ddc', '#037f4c', '#ff642e', '#66ccff', '#ff158a', '#784bd1', '#cab641', '#175a63'];
export function initials(name = '?') {
const p = name.trim().split(/\s+/);
return ((p[0]?.[0] || '') + (p.length > 1 ? p[p.length - 1][0] : '')).toUpperCase() || '?';
}
function hashColor(s = '') { let h = 0; for (const c of s) h = (h * 31 + c.charCodeAt(0)) | 0; return AV_COLORS[Math.abs(h) % AV_COLORS.length]; }
export function Avatar({ user, size = 28, ring = false }) {
if (!user) return html`<span class="avatar empty" style=${{ width: size, height: size }}><${Icon} name="person" size=${size * 0.6} /></span>`;
const online = state.online.includes(user.id);
return html`<span class="avatar ${ring ? 'ring' : ''}" title=${user.name} style=${{ width: size, height: size, fontSize: size * 0.4, background: user.avatar ? 'transparent' : hashColor(user.email || user.name) }}>
${user.avatar ? html`<img src=${user.avatar} alt="" referrerpolicy="no-referrer" />` : initials(user.name)}
${online && size >= 28 ? html`<i class="online-dot" />` : null}
</span>`;
}
export function AvatarStack({ ids = [], size = 26, max = 3 }) {
const users = ids.map((i) => state.usersById[i]).filter(Boolean);
if (!users.length) return html`<${Avatar} size=${size} />`;
const shown = users.slice(0, users.length > max ? max - 1 : max);
return html`<span class="avatar-stack">
${shown.map((u) => html`<${Avatar} key=${u.id} user=${u} size=${size} />`)}
${users.length > shown.length ? html`<span class="avatar more" style=${{ width: size, height: size, fontSize: size * 0.38 }}>+${users.length - shown.length}</span>` : null}
</span>`;
}
// ─── Popovers & modals (rendered by <Overlays/> in app.js) ──────
export function openPopover(anchor, Component, props = {}, opts = {}) {
const rect = anchor?.getBoundingClientRect ? anchor.getBoundingClientRect() : anchor;
update({ popover: { rect, Component, props, opts, key: Math.random() } });
}
export function closePopover() { if (state.popover) { const cb = state.popover.props?.onClose; update({ popover: null }); cb?.(); } }
export function openModal(Component, props = {}, opts = {}) { update({ modal: { Component, props, opts, key: Math.random() } }); }
export function closeModal() { update({ modal: null }); }
export function PopoverHost() {
const p = state.popover;
const ref = useRef();
const [pos, setPos] = useState(null);
useLayoutEffect(() => {
if (!p || !ref.current) return setPos(null);
const el = ref.current;
const { rect, opts } = p;
const w = el.offsetWidth; const h = el.offsetHeight;
const vw = window.innerWidth; const vh = window.innerHeight;
if (vw < 600 && !opts.inline) return setPos({ sheet: true });
let left = opts.align === 'right' ? rect.right - w : opts.align === 'center' ? rect.left + rect.width / 2 - w / 2 : rect.left;
let top = rect.bottom + 4;
if (top + h > vh - 8) top = Math.max(8, rect.top - h - 4);
left = Math.max(8, Math.min(left, vw - w - 8));
setPos({ left, top });
}, [p?.key]);
useEffect(() => {
if (!p) return;
const onKey = (e) => { if (e.key === 'Escape') closePopover(); };
const onDown = (e) => { if (ref.current && !ref.current.contains(e.target) && !e.target.closest?.('.modal, .popover-keep')) closePopover(); };
const onScroll = (e) => { if (!ref.current?.contains(e.target) && !p.opts.keepOnScroll) closePopover(); };
document.addEventListener('keydown', onKey);
setTimeout(() => document.addEventListener('pointerdown', onDown), 0);
window.addEventListener('resize', closePopover);
document.addEventListener('scroll', onScroll, true);
return () => {
document.removeEventListener('keydown', onKey); document.removeEventListener('pointerdown', onDown);
window.removeEventListener('resize', closePopover); document.removeEventListener('scroll', onScroll, true);
};
}, [p?.key]);
if (!p) return null;
const { Component, props } = p;
const sheet = pos?.sheet;
return html`
${sheet ? html`<div class="sheet-backdrop" />` : null}
<div ref=${ref} class="popover ${sheet ? 'sheet' : ''} ${p.opts.class || ''}" style=${sheet ? {} : { left: pos?.left ?? -9999, top: pos?.top ?? -9999, minWidth: p.opts.matchWidth ? p.rect.width : undefined }}>
<${Component} key=${p.key} ...${props} />
</div>`;
}
export function ModalHost() {
const m = state.modal;
useEffect(() => {
if (!m) return;
const onKey = (e) => { if (e.key === 'Escape' && !state.popover) closeModal(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [m?.key]);
if (!m) return null;
const { Component, props, opts } = m;
return html`<div class="modal-backdrop" onPointerDown=${(e) => { if (e.target === e.currentTarget) closeModal(); }}>
<div class="modal ${opts.class || ''}" role="dialog">
<${Component} key=${m.key} ...${props} />
</div>
</div>`;
}
export function ModalHeader({ title, sub }) {
return html`<div class="modal-head"><div><h2>${title}</h2>${sub ? html`<p class="muted">${sub}</p>` : null}</div>
<button class="icon-btn" onClick=${closeModal} aria-label="Close"><${Icon} name="x" /></button></div>`;
}
// A list menu: [{label, icon, onClick, danger, divider, sub}]
export function Menu({ items, title }) {
return html`<div class="menu">
${title ? html`<div class="menu-title">${title}</div>` : null}
${items.filter(Boolean).map((it, i) => it.divider ? html`<div key=${i} class="menu-divider" />` : html`
<button key=${i} class="menu-item ${it.danger ? 'danger' : ''} ${it.active ? 'active' : ''}" disabled=${it.disabled}
onClick=${(e) => { if (!it.keepOpen) closePopover(); it.onClick?.(e); }}>
${it.icon ? html`<${Icon} name=${it.icon} />` : html`<span class="menu-spacer" />`}
<span>${it.label}</span>
${it.hint ? html`<span class="menu-hint">${it.hint}</span>` : null}
</button>`)}
</div>`;
}
export function menu(e, items, opts = {}) {
e.stopPropagation(); e.preventDefault?.();
openPopover(e.currentTarget, Menu, { items }, opts);
}
// Confirm & prompt as modals returning promises.
export function confirmDialog({ title, body, confirm = 'Delete', danger = true }) {
return new Promise((resolve) => {
const Done = (v) => { closeModal(); resolve(v); };
openModal(() => html`<div class="dialog">
<${ModalHeader} title=${title} />
${body ? html`<p class="dialog-body">${body}</p>` : null}
<div class="modal-actions">
<button class="btn ghost" onClick=${() => Done(false)}>Cancel</button>
<button class="btn ${danger ? 'danger' : 'primary'}" onClick=${() => Done(true)} autofocus>${confirm}</button>
</div></div>`, {}, { class: 'small' });
});
}
export function promptDialog({ title, label, value = '', placeholder = '', confirm = 'Save' }) {
return new Promise((resolve) => {
function Prompt() {
const [v, setV] = useState(value);
const submit = (e) => { e.preventDefault(); if (!v.trim()) return; closeModal(); resolve(v.trim()); };
return html`<form class="dialog" onSubmit=${submit}>
<${ModalHeader} title=${title} />
${label ? html`<label class="field-label">${label}</label>` : null}
<input class="input" value=${v} placeholder=${placeholder} onInput=${(e) => setV(e.target.value)} ref=${(el) => el && setTimeout(() => { el.focus(); el.select(); }, 0)} />
<div class="modal-actions">
<button type="button" class="btn ghost" onClick=${() => { closeModal(); resolve(null); }}>Cancel</button>
<button class="btn primary" disabled=${!v.trim()}>${confirm}</button>
</div></form>`;
}
openModal(Prompt, {}, { class: 'small' });
});
}
// Click-to-edit text. Commits on Enter/blur, cancels on Escape.
export function InlineEdit({ value, onSave, class: cls = '', placeholder = '', multiline = false, autoEdit = false, onDone, title }) {
const [editing, setEditing] = useState(autoEdit);
const [v, setV] = useState(value || '');
useEffect(() => { if (!editing) setV(value || ''); }, [value, editing]);
const commit = () => {
setEditing(false); onDone?.();
const next = v.trim();
if (next !== (value || '') && (next || placeholder === '')) onSave(next);
else setV(value || '');
};
if (!editing) {
return html`<span class="inline-edit ${cls} ${!value ? 'placeholder' : ''}" title=${title || value} onClick=${(e) => { e.stopPropagation(); setEditing(true); }}>${value || placeholder}</span>`;
}
const Tag = multiline ? 'textarea' : 'input';
return html`<${Tag} class="inline-input ${cls}" value=${v} placeholder=${placeholder}
ref=${(el) => { if (el && !el.dataset.f) { el.dataset.f = 1; el.focus(); el.select?.(); } }}
onClick=${(e) => e.stopPropagation()}
onInput=${(e) => setV(e.target.value)} onBlur=${commit}
onKeyDown=${(e) => {
if (e.key === 'Enter' && (!multiline || !e.shiftKey)) { e.preventDefault(); e.target.blur(); }
if (e.key === 'Escape') { setV(value || ''); setEditing(false); onDone?.(); }
}} />`;
}
// ─── Formatting ──────────────────────────────────────────
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export function parseDate(s) {
if (!s) return null;
const [d, t] = String(s).split('T');
const [y, m, day] = d.split('-').map(Number);
if (!y || !m || !day) return null;
const dt = new Date(y, m - 1, day);
if (t) { const [hh, mm] = t.split(':').map(Number); dt.setHours(hh || 0, mm || 0); }
return dt;
}
export function ymd(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
export function todayStr() { return ymd(new Date()); }
export function fmtDate(s, { year } = {}) {
const d = parseDate(s);
if (!d) return '';
const showYear = year ?? d.getFullYear() !== new Date().getFullYear();
let out = `${MONTHS[d.getMonth()]} ${d.getDate()}${showYear ? `, ${d.getFullYear()}` : ''}`;
if (String(s).includes('T')) out += `, ${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
return out;
}
export function fmtRange(v) {
if (!v?.from) return '';
const a = parseDate(v.from); const b = parseDate(v.to || v.from);
if (!a || !b) return '';
const sameYear = a.getFullYear() === b.getFullYear() && a.getFullYear() === new Date().getFullYear();
if (v.from === v.to) return fmtDate(v.from);
if (a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear()) return `${MONTHS[a.getMonth()]} ${a.getDate()} - ${b.getDate()}${sameYear ? '' : `, ${a.getFullYear()}`}`;
return `${MONTHS[a.getMonth()]} ${a.getDate()} - ${MONTHS[b.getMonth()]} ${b.getDate()}${sameYear ? '' : `, ${b.getFullYear()}`}`;
}
export function timeAgo(ts) {
const s = Math.round((Date.now() - ts) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
if (s < 86400 * 7) return `${Math.floor(s / 86400)}d`;
return fmtDate(ymd(new Date(ts)));
}
export function fmtSize(n) {
if (!n) return '';
if (n < 1024) return `${n} B`;
if (n < 1024 ** 2) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / 1024 ** 2).toFixed(1)} MB`;
}
export function fileUrl(f, download) { return `/files/${f.id}/${encodeURIComponent(f.name)}${download ? '?download' : ''}`; }
export function isImage(f) { return /^image\//.test(f.mime || '') || /\.(png|jpe?g|gif|webp|svg)$/i.test(f.name); }
// Readable text on a colored background.
export function textOn(hex) {
const c = String(hex || '').replace('#', '');
if (c.length < 6) return '#fff';
const [r, g, b] = [0, 2, 4].map((i) => parseInt(c.slice(i, i + 2), 16));
return (r * 299 + g * 587 + b * 114) / 1000 > 170 ? '#323338' : '#fff';
}
export const isMobile = () => window.matchMedia('(max-width: 768px)').matches;
export function useMobile() {
const [m, setM] = useState(isMobile());
useEffect(() => {
const mq = window.matchMedia('(max-width: 768px)');
const fn = () => setM(mq.matches);
mq.addEventListener('change', fn);
return () => mq.removeEventListener('change', fn);
}, []);
return m;
}
// Render text with links and @mentions highlighted.
export function RichText({ text }) {
const parts = String(text || '').split(/(https?:\/\/[^\s<>()]+|@\[[^\]]+\]\([\w-]+\))/g);
return parts.map((p, i) => {
if (/^https?:\/\//.test(p)) return html`<a key=${i} href=${p} target="_blank" rel="noopener">${p}</a>`;
const m = p.match(/^@\[([^\]]+)\]\(([\w-]+)\)$/);
if (m) return html`<span key=${i} class="mention">@${m[1]}</span>`;
return p;
});
}
export const GROUP_COLORS = ['#579bfc', '#00c875', '#fdab3d', '#e2445c', '#a25ddc', '#037f4c', '#ff642e', '#66ccff', '#ff158a', '#9cd326', '#784bd1', '#cab641', '#bb3354', '#175a63', '#ff5ac4', '#7f5347', '#c4c4c4', '#333333'];
export const LABEL_COLORS = ['#00c875', '#fdab3d', '#df2f4a', '#579bfc', '#a25ddc', '#c4c4c4', '#037f4c', '#ff642e', '#9cd326', '#ffcb00', '#bb3354', '#784bd1', '#66ccff', '#ff158a', '#7f5347', '#225091', '#175a63', '#cab641', '#ff7575', '#fdbc64', '#333333', '#401694', '#5559df', '#68a1bd'];
export function ColorPicker({ colors = GROUP_COLORS, value, onPick }) {
return html`<div class="color-grid">${colors.map((c) => html`
<button key=${c} class="swatch ${c === value ? 'on' : ''}" style=${{ background: c }} onClick=${() => { onPick(c); closePopover(); }} aria-label=${c} />`)}</div>`;
}
export function Spinner() { return html`<div class="spinner" aria-label="Loading" />`; }
export function Empty({ icon = 'board', title, children }) {
return html`<div class="empty-state"><div class="empty-icon"><${Icon} name=${icon} size=${36} /></div><h3>${title}</h3>${children}</div>`;
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Waters Boards",
"short_name": "Boards",
"description": "Boards, tasks and projects for the Waters Church team",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#181b34",
"theme_color": "#181b34",
"icons": [
{ "src": "/icons/icon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+730
View File
@@ -0,0 +1,730 @@
/* Waters Boards — light / dark / night themes, desktop + phone layouts. */
:root {
--font: 'Figtree', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--bg: #f6f7fb; --surface: #ffffff; --surface-2: #f5f6f8; --surface-3: #ecedf5;
--text: #323338; --muted: #676879; --faint: #c3c6d4; --border: #d0d4e4; --border-soft: #e6e9ef;
--primary: #0073ea; --primary-hover: #0060b9; --primary-soft: #cce5ff; --primary-text: #fff;
--rail: #f6f7fb; --nav: #ffffff; --hover: #dcdfec66; --selected: #cce5ff;
--row-hover: #f5f6f8; --row-selected: #e5f4ff;
--status-empty: #c4c4c4; --tl-rest: #333333; --red: #e2445c; --green: #00c875; --orange: #fdab3d;
--shadow: 0 6px 20px rgba(0,0,0,.12); --shadow-lg: 0 15px 50px rgba(0,0,0,.18);
--radius: 8px; --radius-sm: 4px;
--rail-w: 56px; --nav-w: 256px; --row-h: 36px;
color-scheme: light;
}
:root[data-theme="dark"] {
--bg: #181b34; --surface: #30324e; --surface-2: #292f4c; --surface-3: #3a3d5c;
--text: #d5d8df; --muted: #9699a6; --faint: #4b4e69; --border: #4b4e69; --border-soft: #3b3e5a;
--primary: #0073ea; --primary-hover: #1f87f5; --primary-soft: #133774;
--rail: #181b34; --nav: #292f4c; --hover: #ffffff14; --selected: #133774;
--row-hover: #3a3d5c; --row-selected: #233a6b;
--status-empty: #797e93; --tl-rest: #5b5e77;
--shadow: 0 6px 20px rgba(0,0,0,.4); --shadow-lg: 0 15px 50px rgba(0,0,0,.5);
color-scheme: dark;
}
:root[data-theme="night"] {
--bg: #111111; --surface: #1a1a1a; --surface-2: #222222; --surface-3: #2c2c2c;
--text: #e1e1e1; --muted: #a0a0a0; --faint: #3c3c3c; --border: #3c3c3c; --border-soft: #2c2c2c;
--primary: #0073ea; --primary-hover: #1f87f5; --primary-soft: #133774;
--rail: #111111; --nav: #1a1a1a; --hover: #ffffff12; --selected: #133774;
--row-hover: #262626; --row-selected: #1d2c47;
--status-empty: #6b6b6b; --tl-rest: #4d4d4d;
--shadow: 0 6px 20px rgba(0,0,0,.6); --shadow-lg: 0 15px 50px rgba(0,0,0,.7);
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { font-family: var(--font); font-size: 14px; color: var(--text); background: var(--bg); -webkit-font-smoothing: antialiased; overflow: hidden; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
input, select, textarea { font: inherit; color: inherit; }
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3, h4 { margin: 0; font-weight: 600; }
.icon { flex-shrink: 0; display: block; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.mono { font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
.spacer { flex: 1; }
.grow { flex: 1; min-width: 0; }
.row { display: flex; align-items: center; }
.gap { gap: 8px; }
.wrap { flex-wrap: wrap; }
.pad { padding: 16px; }
.pad-x { padding: 4px 12px; }
.clamp2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.scroll-y { overflow-y: auto; max-height: 60vh; }
.link { color: var(--primary); cursor: pointer; text-align: left; }
.link:hover { text-decoration: underline; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; flex-shrink: 0; }
/* ─── Buttons & inputs ─── */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 36px; padding: 0 16px; border-radius: var(--radius-sm);
background: var(--surface-2); color: var(--text); font-weight: 500; white-space: nowrap; transition: background .12s; border: 1px solid var(--border); }
.btn:hover { background: var(--hover); }
.btn.primary { background: var(--primary); color: var(--primary-text); border-color: transparent; }
.btn.primary:hover { background: var(--primary-hover); }
.btn.danger { background: var(--red); color: #fff; border-color: transparent; }
.btn.ghost { background: transparent; border-color: transparent; }
.btn.ghost:hover { background: var(--hover); }
.btn.sm { height: 32px; padding: 0 12px; font-size: 14px; }
.btn:disabled { opacity: .5; cursor: default; }
.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-sm); color: var(--muted); flex-shrink: 0; }
.icon-btn:hover { background: var(--hover); color: var(--text); }
.icon-btn.on { color: var(--primary); }
.icon-btn:disabled { opacity: .35; }
.input { height: 38px; padding: 0 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); width: 100%; outline: none; }
.input:focus { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.input.sm { height: 32px; padding: 0 8px; font-size: 14px; }
textarea.input { height: auto; padding: 8px 12px; resize: vertical; line-height: 1.5; }
select.input[multiple] { height: auto; padding: 4px; }
.field-label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px; }
.check-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; cursor: pointer; }
.chip-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: 16px; border: 1px solid var(--border); }
.chip-btn.on { background: var(--primary-soft); border-color: var(--primary); color: var(--text); }
.warn-note { background: #fdab3d26; border-left: 3px solid var(--orange); padding: 8px 12px; border-radius: 4px; font-size: 13px; }
.error-note { color: var(--red); font-size: 13px; }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
.seg button { padding: 5px 12px; font-size: 13px; }
.seg button.on { background: var(--primary-soft); color: var(--primary); font-weight: 600; }
.switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch span { position: absolute; inset: 0; background: var(--faint); border-radius: 10px; transition: .2s; cursor: pointer; }
.switch span::after { content: ''; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px; background: #fff; border-radius: 50%; transition: .2s; }
.switch input:checked + span { background: var(--green); }
.switch input:checked + span::after { transform: translateX(16px); }
/* ─── Avatars ─── */
.avatar { position: relative; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; color: #fff; font-weight: 600;
flex-shrink: 0; overflow: visible; letter-spacing: .3px; }
.avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.avatar.empty { background: transparent; color: var(--faint); border: 1px dashed var(--faint); }
.avatar.more { background: var(--surface-3); color: var(--text); font-weight: 600; }
.avatar .online-dot { position: absolute; right: -1px; bottom: -1px; width: 9px; height: 9px; background: var(--green); border: 2px solid var(--surface); border-radius: 50%; }
.avatar-stack { display: inline-flex; align-items: center; }
.avatar-stack .avatar + .avatar { margin-left: -8px; box-shadow: 0 0 0 2px var(--surface); }
/* ─── Logo / splash / sign-in ─── */
.logo-mark { display: inline-flex; gap: 3px; align-items: flex-start; height: 22px; }
.logo-mark i { width: 6px; border-radius: 3px; display: block; }
.logo-mark i:nth-child(1) { height: 22px; background: #579bfc; }
.logo-mark i:nth-child(2) { height: 16px; background: #00c875; }
.logo-mark i:nth-child(3) { height: 11px; background: #fdab3d; }
.logo-mark.big { height: 56px; gap: 8px; }
.logo-mark.big i { width: 16px; border-radius: 8px; }
.logo-mark.big i:nth-child(1) { height: 56px; } .logo-mark.big i:nth-child(2) { height: 41px; } .logo-mark.big i:nth-child(3) { height: 28px; }
.splash { height: 100vh; display: grid; place-items: center; }
.signin { min-height: 100vh; display: grid; place-items: center; padding: 16px; overflow: auto; background: radial-gradient(circle at 20% 10%, #579bfc22, transparent 50%), radial-gradient(circle at 80% 90%, #00c87522, transparent 50%), var(--bg); }
.signin-card { width: 100%; max-width: 400px; background: var(--surface); border-radius: 16px; padding: 40px 32px; text-align: center; box-shadow: var(--shadow-lg); display: flex; flex-direction: column; align-items: center; gap: 14px; }
.signin-card h1 { font-size: 28px; }
.gbtn { min-height: 44px; }
.signin-form { display: flex; flex-direction: column; gap: 10px; width: 100%; }
/* ─── Shell ─── */
.app { display: flex; height: 100vh; height: 100dvh; }
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--surface); margin: 8px 8px 0 0; border-radius: 12px 12px 0 0; overflow: hidden; position: relative; }
.sidebar { display: flex; flex-shrink: 0; position: relative; }
.rail { width: var(--rail-w); background: var(--rail); display: flex; flex-direction: column; align-items: center; padding: 12px 0; gap: 4px; }
.logo { width: 40px; height: 40px; display: grid; place-items: center; margin-bottom: 10px; border-radius: 8px; }
.logo:hover { background: var(--hover); }
.rail-btn { position: relative; width: 44px; height: 44px; border-radius: 8px; display: grid; place-items: center; color: var(--muted); }
.rail-btn:hover { background: var(--hover); color: var(--text); }
.rail-btn.active { background: var(--selected); color: var(--primary); }
.rail-label { display: none; }
.rail-badge { position: absolute; top: 3px; right: 2px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--red); color: #fff; font-size: 11px; font-weight: 600; display: grid; place-items: center; border: 2px solid var(--rail); }
.rail-me { margin-top: 8px; border-radius: 50%; }
.nav-panel { width: var(--nav-w); background: var(--nav); margin: 8px 8px 0 0; border-radius: 12px 12px 0 0; display: flex; flex-direction: column; min-height: 0; }
.nav-collapsed .nav-panel { display: none; }
.expand-btn { position: absolute; left: calc(var(--rail-w) - 12px); top: 70px; width: 24px; height: 24px; border-radius: 50%; background: var(--surface); box-shadow: var(--shadow); display: grid; place-items: center; z-index: 5; }
.nav-head { display: flex; align-items: center; gap: 2px; padding: 12px 8px 8px 12px; }
.ws-picker { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-weight: 600; }
.ws-picker:hover { background: var(--hover); }
.ws-icon { width: 28px; height: 28px; border-radius: 6px; display: grid; place-items: center; color: #fff; font-weight: 700; flex-shrink: 0; }
.nav-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
.nav-search { display: flex; align-items: center; gap: 6px; margin: 0 12px 8px; padding: 0 10px; height: 32px; border: 1px solid var(--border); border-radius: 4px; color: var(--muted); }
.nav-search input { border: 0; background: none; outline: none; flex: 1; min-width: 0; color: var(--text); }
.nav-scroll { overflow-y: auto; flex: 1; padding: 0 8px 24px; }
.nav-section { margin-top: 8px; }
.nav-section-title { display: flex; align-items: center; gap: 6px; padding: 6px 8px; font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .4px; }
.nav-board, .nav-folder-row { display: flex; align-items: center; gap: 8px; height: 34px; padding-right: 4px; border-radius: 6px; cursor: pointer; color: var(--text); }
.nav-board:hover, .nav-folder-row:hover { background: var(--hover); }
.nav-board.active { background: var(--selected); }
.nav-board .icon, .nav-folder-row .icon { color: var(--muted); }
.nav-more { opacity: 0; width: 24px; height: 24px; }
.nav-board:hover .nav-more, .nav-folder-row:hover .nav-more { opacity: 1; }
.nav-empty { display: flex; align-items: center; gap: 6px; padding: 8px; color: var(--primary); }
/* ─── Popovers / menus / modals / toasts ─── */
.popover { position: fixed; z-index: 1000; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow-lg); border: 1px solid var(--border-soft); max-height: min(560px, 80vh); overflow: auto; }
.popover.sheet { left: 0; right: 0; bottom: 0; top: auto; border-radius: 16px 16px 0 0; max-height: 80vh; padding-bottom: env(safe-area-inset-bottom); }
.sheet-backdrop { position: fixed; inset: 0; background: #0006; z-index: 999; }
.menu { padding: 8px; min-width: 210px; }
.menu-title { padding: 6px 10px; font-size: 12px; color: var(--muted); }
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border-radius: 4px; text-align: left; white-space: nowrap; }
.menu-item:hover:not(:disabled) { background: var(--hover); }
.menu-item.danger { color: var(--red); }
.menu-item.active { color: var(--primary); font-weight: 600; }
.menu-item:disabled { color: var(--muted); cursor: default; font-size: 12px; }
.menu-item .icon { color: var(--muted); }
.menu-item.danger .icon { color: var(--red); }
.menu-spacer { width: 16px; }
.menu-hint { margin-left: auto; padding-left: 16px; color: var(--muted); font-size: 12px; }
.menu-divider { height: 1px; background: var(--border-soft); margin: 6px 0; }
.col-type-menu .menu { display: grid; grid-template-columns: 1fr 1fr; min-width: 360px; }
.modal-backdrop { position: fixed; inset: 0; background: #0009; z-index: 900; display: grid; place-items: center; padding: 16px; animation: fade .12s; }
.modal { background: var(--surface); border-radius: 12px; box-shadow: var(--shadow-lg); width: 100%; max-width: 560px; max-height: 90vh; overflow: auto; animation: pop .15s; }
.modal.small { max-width: 420px; }
.modal.wide { max-width: 760px; }
.dialog { padding: 24px; }
.dialog-body { color: var(--muted); line-height: 1.5; }
.modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.modal-head h2 { font-size: 22px; }
.modal-head p { margin: 4px 0 0; }
.modal-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 16px; }
.toasts { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 2000; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; }
.toast { background: #323338; color: #fff; padding: 10px 18px; border-radius: 6px; box-shadow: var(--shadow); pointer-events: auto; display: flex; gap: 12px; animation: pop .15s; }
.toast.error { background: var(--red); }
.toast .link { color: #9fd0ff; }
@keyframes fade { from { opacity: 0; } }
@keyframes pop { from { opacity: 0; transform: scale(.97) translateY(4px); } }
.spinner { width: 28px; height: 28px; border: 3px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin .7s linear infinite; margin: 24px auto; }
.row .spinner { width: 16px; height: 16px; border-width: 2px; margin: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.page-loading { flex: 1; display: grid; place-items: center; }
.empty-state { text-align: center; padding: 60px 20px; display: flex; flex-direction: column; align-items: center; gap: 10px; }
.empty-icon { width: 72px; height: 72px; border-radius: 50%; background: var(--surface-2); display: grid; place-items: center; color: var(--muted); }
.pop-pad { padding: 16px; min-width: 260px; max-width: 640px; }
.pop-title { font-weight: 600; margin-bottom: 12px; display: flex; gap: 8px; align-items: baseline; }
/* ─── Inline edit ─── */
.inline-edit { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 4px; border: 1px solid transparent; border-radius: 3px; cursor: text; vertical-align: middle; }
.inline-edit:hover { border-color: var(--border); }
.inline-edit.placeholder { color: var(--muted); }
.inline-input { width: 100%; padding: 2px 4px; border: 1px solid var(--primary); border-radius: 3px; outline: none; background: var(--surface); min-width: 60px; }
/* ─── Board header / tabs / toolbar ─── */
.board-page { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.board-header { padding: 18px 28px 0; }
.board-title-row { display: flex; align-items: center; gap: 8px; }
.board-title { font-size: 24px; font-weight: 600; min-width: 0; }
.board-title .inline-edit { padding: 2px 6px; }
.icon-btn.star.on { color: #ffcb00; }
.board-desc { color: var(--muted); margin: 4px 6px 0; }
.header-people { margin: 0 6px; }
.view-tabs { display: flex; gap: 2px; margin-top: 12px; border-bottom: 1px solid var(--border-soft); overflow-x: auto; scrollbar-width: none; }
.view-tab { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-radius: 6px 6px 0 0; position: relative; color: var(--text); white-space: nowrap; }
.view-tab:hover { background: var(--hover); }
.view-tab.active::after { content: ''; position: absolute; left: 6px; right: 6px; bottom: -1px; height: 2px; background: var(--primary); }
.view-tab .icon { color: var(--muted); }
.tab-dots { display: inline-flex; color: var(--muted); }
.toolbar { display: flex; align-items: center; gap: 4px; padding: 12px 28px; flex-wrap: wrap; }
.split-btn { display: inline-flex; margin-right: 8px; }
.split-btn .btn:first-child { border-radius: 4px 0 0 4px; }
.split-btn .caret { border-radius: 0 4px 4px 0; padding: 0 8px; border-left: 1px solid #ffffff40; }
.tb-btn { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 10px; border-radius: 4px; color: var(--text); }
.tb-btn:hover { background: var(--hover); }
.tb-btn.on { background: var(--primary-soft); }
.tb-btn.subtle { color: var(--muted); font-size: 13px; }
.tb-search { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 8px; border: 1px solid var(--primary); border-radius: 4px; color: var(--muted); }
.tb-search input { border: 0; outline: none; background: none; width: 180px; color: var(--text); }
.tb-search.wide { border-color: var(--border); width: 100%; max-width: 420px; margin-bottom: 16px; }
.tb-search.wide input { width: 100%; }
.board-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.view-options { display: flex; align-items: center; gap: 8px; padding: 0 0 12px; flex-wrap: wrap; }
.view-options .input { width: auto; }
/* filter / person / sort / hide popovers */
.avatar-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.avatar-toggle { border-radius: 50%; padding: 2px; border: 2px solid transparent; }
.avatar-toggle.on { border-color: var(--primary); }
.filter-rule { display: grid; grid-template-columns: 44px 1fr 1fr 1fr 30px; gap: 6px; align-items: center; margin-bottom: 8px; min-width: 560px; }
.quick-filters { display: flex; gap: 24px; margin-top: 12px; border-top: 1px solid var(--border-soft); padding-top: 12px; }
.qf-col { min-width: 160px; }
.qf-title { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.qf-opt { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 6px; border-radius: 4px; text-align: left; }
.qf-opt:hover { background: var(--hover); }
.qf-opt i { width: 14px; height: 14px; border-radius: 3px; }
.qf-opt .muted { margin-left: auto; }
.hide-cols .check-row { padding: 5px 0; }
.type-dot { width: 20px; height: 20px; border-radius: 4px; display: inline-grid; place-items: center; color: #fff; flex-shrink: 0; }
/* ─── Table ─── */
.table-scroll { flex: 1; overflow: auto; padding: 0 28px 120px; position: relative; }
.table { display: flex; flex-direction: column; gap: 28px; padding-top: 4px; }
.group { --g: #579bfc; }
.group-head { display: flex; align-items: center; gap: 2px; height: 40px; position: sticky; left: 0; width: max-content; }
.group-menu-btn { opacity: 0; margin-left: -34px; }
.group-head:hover .group-menu-btn, .gc-name:hover .group-menu-btn { opacity: 1; }
.group-title { font-size: 18px; font-weight: 500; }
.group-title .inline-edit { padding: 2px 6px; }
.group-count { margin-left: 8px; opacity: 0; }
.group-head:hover .group-count { opacity: 1; }
.group-table.drop { outline: 2px dashed var(--primary); outline-offset: 2px; border-radius: 6px; }
.trow { display: flex; min-height: var(--row-h); }
.tcell { flex-shrink: 0; border-right: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; justify-content: center; position: relative; min-width: 0; background: var(--surface); }
.thead .tcell { border-top: 1px solid var(--border-soft); height: 38px; }
.th { padding: 0 24px; font-size: 14px; color: var(--muted); }
.th-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.th-menu { position: absolute; right: 2px; opacity: 0; width: 22px; height: 22px; }
.th:hover .th-menu { opacity: 1; }
.th:hover { background: var(--row-hover); }
.col-resize { position: absolute; right: -3px; top: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 2; }
.col-resize:hover { background: var(--primary); }
.add-col { width: 60px; border-right: 0; justify-content: flex-start; padding-left: 8px; }
.thead .add-col { border-top: 1px solid var(--border-soft); }
.name-cell { justify-content: flex-start; gap: 4px; padding-right: 6px; border-left: 0; }
.sticky { position: sticky; left: 0; z-index: 3; }
.thead .name-cell { padding-left: 0; }
.thead .name-cell .th-title { color: var(--muted); flex: 1; text-align: center; }
.row-bar { width: 6px; align-self: stretch; background: var(--g); flex-shrink: 0; }
.row-bar.first { border-radius: 6px 0 0 0; }
.row-bar.last { border-radius: 0 0 0 6px; }
.row-bar.faded { opacity: .5; }
.row-check, .row-check-spacer { width: 36px; display: grid; place-items: center; align-self: stretch; border-right: 1px solid var(--border-soft); flex-shrink: 0; }
.row-check input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; }
.row-menu { position: absolute; left: -32px; opacity: 0; }
.item-row:hover .row-menu { opacity: 1; }
.item-row:hover .tcell { background: var(--row-hover); }
.item-row.selected .tcell, .item-row.active .tcell { background: var(--row-selected); }
.item-row.drop-above { box-shadow: inset 0 2px 0 var(--primary); }
.item-row.drop-below { box-shadow: inset 0 -2px 0 var(--primary); }
.item-name { flex: 1; min-width: 0; padding-left: 6px; display: flex; }
.item-name .inline-edit { padding: 3px 6px; }
.open-item { opacity: 0; width: 24px; height: 24px; }
.item-row:hover .open-item { opacity: 1; }
.updates-btn { position: relative; color: var(--faint); }
.updates-btn.has { color: var(--primary); }
.updates-btn .badge { position: absolute; bottom: 1px; right: 0; background: var(--primary); color: #fff; font-size: 10px; min-width: 15px; height: 15px; border-radius: 8px; display: grid; place-items: center; padding: 0 3px; font-weight: 600; }
.sub-toggle { width: 34px; height: 26px; gap: 1px; color: var(--faint); }
.sub-toggle.has { color: var(--muted); }
.sub-toggle.open { color: var(--primary); }
.sub-count { font-size: 11px; }
.item-row:not(:hover) .sub-toggle:not(.has):not(.open) { opacity: 0; }
.subitems { margin-left: 46px; border-left: 2px solid var(--g); }
.item-row.sub .name-cell, .add-row.sub .name-cell { width: calc(340px - 46px) !important; }
.sub-indent { width: 12px; }
.add-row .tcell { background: var(--surface); }
.add-row .filler { border-right: 0; }
.add-input { border: 1px solid transparent; outline: none; background: none; padding: 4px 6px; flex: 1; border-radius: 3px; height: 28px; margin-left: 8px; color: var(--text); }
.add-input:hover { border-color: var(--border); }
.add-input:focus { border-color: var(--primary); }
.tfoot .tcell { border-bottom: 0; border-right: 0; background: transparent; height: 38px; }
.tfoot .tcell:not(.name-cell) { border: 1px solid var(--border-soft); border-top: 0; border-left: 0; }
.tfoot .name-cell { background: var(--surface); }
.add-group-btn { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 4px; width: max-content; position: sticky; left: 0; }
.add-group-btn:hover { background: var(--hover); }
.group-collapsed-row { display: flex; border: 1px solid var(--border-soft); border-left: 6px solid var(--g); border-radius: 6px; min-height: 60px; }
.group-collapsed-row.drop { outline: 2px dashed var(--primary); }
.gc-name { display: flex; align-items: center; gap: 6px; padding: 0 8px 0 40px; position: sticky; left: 0; background: var(--surface); z-index: 2; border-right: 1px solid var(--border-soft); }
.gc-name .group-menu-btn { margin-left: auto; }
.gc-cell { border-right: 1px solid var(--border-soft); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 6px 0; }
/* cells */
.cell-status { width: 100%; height: 100%; min-height: var(--row-h); color: #fff; font-weight: 500; position: relative; padding: 0 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: filter .1s; }
.cell-status:hover { filter: brightness(1.08); }
.cell-status .fold { position: absolute; right: 0; top: 0; border-style: solid; border-width: 0 10px 10px 0; border-color: transparent var(--surface) transparent transparent; opacity: 0; transition: opacity .1s; }
.cell-status:hover .fold { opacity: 1; }
.cell-people, .cell-date, .cell-timeline, .cell-files, .cell-text.long, .cell-chips { width: 100%; height: 100%; min-height: var(--row-h); display: flex; align-items: center; justify-content: center; padding: 0 6px; gap: 4px; }
.cell-date { gap: 6px; }
.cell-date.overdue { color: var(--red); }
.cell-date .warn { width: 16px; height: 16px; border-radius: 50%; background: var(--red); color: #fff; font-size: 11px; font-weight: 700; display: grid; place-items: center; }
.today-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); }
.ghost-icon { color: var(--faint); opacity: 0; }
.tcell:hover .ghost-icon { opacity: 1; }
.tl-bar { display: flex; align-items: center; justify-content: center; height: 24px; width: calc(100% - 12px); border-radius: 12px; color: #fff; font-size: 13px; overflow: hidden; white-space: nowrap; }
.tl-bar.empty { background: var(--status-empty); }
.tl-bar.summary-range { background: var(--tl-rest); font-size: 12px; height: 22px; }
.cell-number { width: 100%; display: flex; align-items: center; justify-content: center; gap: 4px; padding: 0 6px; }
.cell-number .inline-edit, .cell-number .inline-input { text-align: center; min-width: 40px; }
.cell-number .inline-edit { width: 100%; min-height: 26px; }
.unit { color: var(--muted); font-size: 12px; }
.cell-text { width: 100%; padding: 0 6px; display: flex; align-items: center; }
.cell-text .inline-edit { width: 100%; min-height: 26px; text-align: center; }
.cell-text.long { justify-content: flex-start; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; display: block; line-height: var(--row-h); text-align: left; padding: 0 10px; }
.cell-check { display: grid; place-items: center; width: 100%; height: 100%; cursor: pointer; }
.cell-check input { width: 18px; height: 18px; accent-color: var(--green); cursor: pointer; }
.cell-check span { display: none; }
.cell-rating { display: flex; gap: 1px; }
.cell-rating button { color: var(--faint); padding: 2px; }
.cell-rating button.on { color: #ffcb00; }
.cell-rating:hover button { color: #ffcb0080; }
.cell-rating button:hover ~ button { color: var(--faint); }
.cell-chips { overflow: hidden; flex-wrap: nowrap; justify-content: flex-start; }
.chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 12px; background: var(--surface-3); font-size: 13px; white-space: nowrap; }
.chip.tag { color: var(--primary); background: var(--primary-soft); }
.chip button { color: var(--muted); display: inline-flex; }
.chip.person { padding-left: 2px; }
.cell-link { width: 100%; display: flex; align-items: center; padding: 0 8px; gap: 4px; overflow: hidden; }
.cell-link a { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cell-edit-btn { color: var(--faint); opacity: 0; margin-left: auto; padding: 4px; }
.tcell:hover .cell-edit-btn, .info-value:hover .cell-edit-btn { opacity: 1; }
.cell-files img { width: 26px; height: 26px; object-fit: cover; border-radius: 3px; }
.file-ext { display: inline-grid; place-items: center; min-width: 26px; height: 26px; padding: 0 3px; border-radius: 3px; background: var(--primary-soft); color: var(--primary); font-size: 9px; font-weight: 700; }
.file-ext.big { width: 100%; height: 100%; font-size: 20px; border-radius: 0; }
.file-more { font-size: 12px; color: var(--muted); }
.cell-meta { display: flex; align-items: center; gap: 6px; font-size: 13px; padding: 0 6px; overflow: hidden; white-space: nowrap; }
.summary { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 6px; }
.battery { display: flex; width: 100%; height: 22px; overflow: hidden; border-radius: 2px; }
.battery span { display: block; height: 100%; }
/* bulk bar */
.bulk-bar { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--surface); box-shadow: var(--shadow-lg); border-radius: 8px; display: flex; align-items: center; z-index: 50; border: 1px solid var(--border-soft); overflow: hidden; animation: pop .15s; }
.bulk-count { background: var(--primary); color: #fff; font-size: 24px; font-weight: 300; width: 64px; align-self: stretch; display: grid; place-items: center; }
.bulk-label { padding: 0 20px; font-size: 16px; white-space: nowrap; }
.bulk-act { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 14px; font-size: 12px; color: var(--muted); }
.bulk-act:hover { color: var(--primary); }
.bulk-act.danger:hover { color: var(--red); }
.bulk-close { margin: 0 12px; border-left: 1px solid var(--border-soft); border-radius: 0; height: 64px; width: 48px; }
/* pickers */
.status-picker { padding: 12px; width: 240px; }
.status-grid { display: flex; flex-direction: column; gap: 6px; }
.status-opt { height: 32px; border-radius: 2px; font-weight: 500; padding: 0 8px; }
.status-opt:hover { filter: brightness(1.08); }
.status-opt.blank { border: 1px dashed var(--border); color: var(--muted); }
.label-editor { padding: 12px; display: flex; flex-direction: column; gap: 6px; min-width: 280px; }
.label-row { display: flex; align-items: center; gap: 6px; }
.label-row .input { flex: 1; }
.swatch { width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; border: 2px solid transparent; }
.swatch.on { border-color: var(--text); }
.swatch-input { width: 30px; height: 30px; padding: 0; border: 0; background: none; cursor: pointer; flex-shrink: 0; }
.done-toggle { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.label-preview { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.label-preview span { padding: 4px 10px; border-radius: 3px; font-size: 13px; }
.color-grid { display: grid; grid-template-columns: repeat(6, 28px); gap: 6px; padding: 12px; }
.color-grid .swatch { width: 28px; height: 28px; }
.picker { padding: 10px; width: 300px; display: flex; flex-direction: column; gap: 8px; }
.picker-list { max-height: 280px; overflow-y: auto; display: flex; flex-direction: column; }
.picker-section { font-size: 12px; color: var(--muted); padding: 4px 6px; }
.picker-row { display: flex; align-items: center; gap: 8px; padding: 6px; border-radius: 4px; text-align: left; }
.picker-row:hover { background: var(--hover); }
.picker-row .small { margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }
.check-box { width: 18px; height: 18px; border: 1px solid var(--border); border-radius: 3px; display: grid; place-items: center; color: transparent; flex-shrink: 0; }
.check-box.on { background: var(--primary); border-color: var(--primary); color: #fff; }
.chips-wrap { display: flex; flex-wrap: wrap; gap: 4px; }
.datepicker { padding: 12px; width: 280px; }
.dp-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.dp-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.dp-dow { text-align: center; font-size: 11px; color: var(--muted); padding: 4px 0; }
.dp-day { height: 34px; border-radius: 50%; font-size: 13px; }
.dp-day:hover { background: var(--hover); }
.dp-day.out { color: var(--faint); }
.dp-day.today { color: var(--primary); font-weight: 700; }
.dp-day.inrange { background: var(--primary-soft); border-radius: 0; }
.dp-day.sel { background: var(--primary); color: #fff; border-radius: 50%; }
.dp-foot { display: flex; gap: 4px; margin-top: 8px; border-top: 1px solid var(--border-soft); padding-top: 8px; }
.dp-time { display: flex; gap: 6px; margin-top: 8px; }
.longtext-editor, .form-pop { padding: 12px; width: 340px; }
.longtext-editor textarea { width: 100%; }
.files-pop { padding: 12px; width: 340px; display: flex; flex-direction: column; gap: 10px; }
.files-list { display: flex; flex-direction: column; gap: 4px; max-height: 300px; overflow: auto; }
.file-row { display: flex; align-items: center; gap: 8px; }
.file-row img { width: 32px; height: 32px; object-fit: cover; border-radius: 3px; }
.file-row > a:first-of-type { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ─── Item panel ─── */
.panel-backdrop { position: fixed; inset: 0; z-index: 80; background: #0003; animation: fade .15s; }
.item-panel { position: fixed; top: 0; right: 0; bottom: 0; width: min(680px, 92vw); background: var(--surface); box-shadow: var(--shadow-lg); z-index: 90; display: flex; flex-direction: column; animation: slide .2s ease-out; }
@keyframes slide { from { transform: translateX(40px); opacity: 0; } }
.panel-head { display: flex; align-items: center; gap: 8px; padding: 12px 16px 0; }
.crumbs { flex: 1; display: flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; white-space: nowrap; }
.panel-title { font-size: 22px; padding: 6px 22px 8px; }
.panel-title .inline-edit { white-space: normal; padding: 2px 4px; }
.panel-tabs { display: flex; gap: 4px; padding: 0 22px; border-bottom: 1px solid var(--border-soft); overflow-x: auto; scrollbar-width: none; }
.panel-tabs button { padding: 10px 12px; position: relative; white-space: nowrap; }
.panel-tabs button.active { font-weight: 600; }
.panel-tabs button.active::after { content: ''; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px; background: var(--primary); }
.panel-body { flex: 1; overflow-y: auto; padding: 20px 22px 40px; background: var(--surface-2); }
.updates { display: flex; flex-direction: column; gap: 16px; }
.composer { position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
.composer:focus-within { border-color: var(--primary); }
.composer textarea { width: 100%; border: 0; outline: none; background: none; resize: vertical; padding: 12px 14px; min-height: 60px; line-height: 1.5; }
.composer.compact textarea { min-height: 40px; }
.composer-actions { display: flex; align-items: center; gap: 4px; padding: 6px 8px; border-top: 1px solid var(--border-soft); }
.composer-files { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 12px 8px; }
.mention-pop { position: absolute; left: 12px; top: 100%; z-index: 5; background: var(--surface); border-radius: 6px; box-shadow: var(--shadow); padding: 4px; min-width: 220px; }
.mention-pop button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 8px; border-radius: 4px; text-align: left; }
.mention-pop button:hover, .mention-pop button:first-child { background: var(--hover); }
.update { background: var(--surface); border-radius: 8px; border: 1px solid var(--border-soft); padding: 14px 16px 0; }
.update.reply { border: 0; padding: 10px 0 0; background: none; }
.update-head { display: flex; align-items: center; gap: 10px; }
.update-meta { flex: 1; display: flex; flex-direction: column; }
.update-body { padding: 10px 0; white-space: pre-wrap; word-break: break-word; line-height: 1.55; }
.mention { color: var(--primary); background: var(--primary-soft); padding: 0 3px; border-radius: 3px; }
.update-files { display: flex; flex-wrap: wrap; gap: 8px; padding-bottom: 10px; }
.update-files img { max-width: 220px; max-height: 160px; border-radius: 6px; display: block; }
.file-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); }
.update-actions { display: flex; border-top: 1px solid var(--border-soft); margin: 0 -16px; }
.update-actions button { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 9px; color: var(--muted); }
.update-actions button:hover { background: var(--hover); }
.update-actions button.on { color: var(--primary); }
.update.reply .update-actions { margin: 0; border: 0; justify-content: flex-start; }
.update.reply .update-actions button { flex: 0; padding: 4px 8px; }
.replies { margin: 0 -16px; padding: 0 16px 12px 56px; background: var(--surface-2); border-radius: 0 0 8px 8px; }
.reply-open { width: 100%; text-align: left; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; color: var(--muted); background: var(--surface); margin-top: 10px; }
.empty-updates { text-align: center; padding: 40px 20px; color: var(--muted); }
.empty-updates h3 { color: var(--text); margin: 10px 0 4px; }
.info-grid { background: var(--surface); border-radius: 8px; border: 1px solid var(--border-soft); }
.info-row { display: grid; grid-template-columns: 180px 1fr; border-bottom: 1px solid var(--border-soft); min-height: 44px; }
.info-row:last-child { border-bottom: 0; }
.info-label { display: flex; align-items: center; gap: 8px; padding: 0 14px; color: var(--muted); border-right: 1px solid var(--border-soft); }
.info-value { display: flex; align-items: center; justify-content: flex-start; min-width: 0; }
.info-value > * { min-height: 44px; }
.info-value .cell-text .inline-edit, .info-value .cell-number .inline-edit { text-align: left; }
.info-value.tc-status .cell-status { min-height: 44px; }
.info-subitems { background: var(--surface); border-radius: 8px; border: 1px solid var(--border-soft); padding: 14px; margin-top: 16px; display: flex; flex-direction: column; gap: 6px; }
.sub-line { display: flex; align-items: center; gap: 8px; min-height: 34px; }
.sub-line .link { flex: 1; }
.sub-status { width: 130px; height: 32px; }
.sub-status .cell-status { min-height: 32px; border-radius: 3px; }
.info-foot { margin-top: 14px; }
.files-tab { display: flex; flex-direction: column; gap: 16px; }
.dropzone { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 28px; border: 2px dashed var(--border); border-radius: 8px; color: var(--muted); cursor: pointer; background: var(--surface); width: 100%; }
.dropzone:hover { border-color: var(--primary); color: var(--primary); }
.file-grid, .file-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px; }
.file-gallery { padding: 0 28px 40px; overflow: auto; }
.file-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
.file-thumb { height: 120px; display: grid; place-items: center; background: var(--surface-3); }
.file-thumb img { width: 100%; height: 100%; object-fit: cover; }
.file-info { padding: 8px 10px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.file-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-actions { display: flex; justify-content: flex-end; padding: 0 4px 4px; }
.activity { display: flex; flex-direction: column; }
.act-row { display: grid; grid-template-columns: 50px 28px minmax(90px, 160px) 1fr; align-items: center; gap: 8px; padding: 8px 4px; border-bottom: 1px solid var(--border-soft); font-size: 13px; }
.act-who, .act-item { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.act-col { color: var(--muted); }
.act-from { text-decoration: line-through; color: var(--muted); }
.act-to { font-weight: 500; }
/* ─── Kanban ─── */
.kanban-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0 28px; }
.kanban { flex: 1; display: flex; gap: 12px; overflow-x: auto; padding-bottom: 20px; align-items: flex-start; }
.lane { width: 270px; flex-shrink: 0; background: var(--surface-2); border-radius: 8px; max-height: 100%; display: flex; flex-direction: column; }
.lane.over { outline: 2px dashed var(--primary); }
.lane-head { padding: 10px 14px; border-radius: 8px 8px 0 0; font-weight: 600; display: flex; justify-content: space-between; }
.lane-body { padding: 8px; display: flex; flex-direction: column; gap: 8px; overflow-y: auto; }
.kcard { background: var(--surface); border-radius: 6px; padding: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.1); cursor: pointer; display: flex; flex-direction: column; gap: 8px; border: 1px solid var(--border-soft); }
.kcard:hover { box-shadow: var(--shadow); }
.kcard-name { font-weight: 500; }
.kcard-field { display: flex; flex-direction: column; gap: 2px; }
.kcard-field .tcell, .kcard-field > div { justify-content: flex-start; }
.kcard-foot { display: flex; align-items: center; justify-content: space-between; }
.lane-add { display: flex; align-items: center; gap: 6px; padding: 8px; color: var(--muted); border-radius: 4px; }
.lane-add:hover { background: var(--hover); }
/* ─── Calendar ─── */
.calendar { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0 28px 16px; overflow: auto; }
.cal-head { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
.cal-head .input { width: auto; }
.cal-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); border-left: 1px solid var(--border-soft); border-top: 1px solid var(--border-soft); flex: 1; grid-template-rows: 30px; grid-auto-rows: minmax(110px, 1fr); }
.cal-dow { padding: 6px 8px; font-size: 12px; color: var(--muted); border-right: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); grid-row: 1; height: 30px; }
.cal-grid > .cal-dow { min-height: 0; }
.cal-day { border-right: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); padding: 4px; display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.cal-day.out { background: var(--surface-2); }
.cal-day.out .cal-num { color: var(--faint); }
.cal-num { font-size: 12px; padding: 2px 4px; align-self: flex-end; border-radius: 10px; }
.cal-day.today .cal-num { background: var(--primary); color: #fff; }
.cal-items { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.cal-item { display: block; text-align: left; font-size: 12px; padding: 3px 6px; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%; }
.agenda-day { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border-soft); }
.agenda-date { width: 90px; flex-shrink: 0; font-weight: 600; font-size: 13px; }
.agenda-date.today { color: var(--primary); }
.agenda-items { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.agenda-items .cal-item { font-size: 14px; padding: 8px 10px; border-radius: 6px; }
/* ─── Timeline ─── */
.timeline { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0 28px 16px; }
.tl-body { flex: 1; min-height: 0; display: flex; border: 1px solid var(--border-soft); border-radius: 8px; overflow: auto; }
.tl-names { width: 220px; flex-shrink: 0; border-right: 1px solid var(--border-soft); position: sticky; left: 0; background: var(--surface); z-index: 2; }
.tl-corner { height: 36px; border-bottom: 1px solid var(--border-soft); position: sticky; top: 0; background: var(--surface); }
.tl-group, .tl-group-row { height: 34px; display: flex; align-items: center; padding: 0 12px; font-weight: 600; background: var(--surface-2); border-bottom: 1px solid var(--border-soft); }
.tl-name { height: 38px; display: flex; align-items: center; padding: 0 12px; border-bottom: 1px solid var(--border-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.tl-name:hover { background: var(--row-hover); }
.tl-scroll { flex: 1; }
.tl-canvas { position: relative; }
.tl-ticks { height: 36px; position: sticky; top: 0; background: var(--surface); z-index: 1; border-bottom: 1px solid var(--border-soft); }
.tl-ticks span { position: absolute; top: 10px; font-size: 12px; color: var(--muted); white-space: nowrap; border-left: 1px solid var(--border-soft); padding-left: 4px; height: 26px; }
.tl-today { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--primary); z-index: 1; opacity: .7; }
.tl-row { height: 38px; position: relative; border-bottom: 1px solid var(--border-soft); }
.tl-item { position: absolute; top: 7px; height: 24px; border-radius: 12px; color: #fff; display: flex; align-items: center; gap: 4px; padding: 0 4px 0 10px; font-size: 12px; cursor: grab; overflow: hidden; touch-action: none; user-select: none; }
.tl-item:active { cursor: grabbing; }
.tl-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-resize { position: absolute; right: 0; top: 0; bottom: 0; width: 8px; cursor: ew-resize; }
.tl-set { position: absolute; top: 9px; }
/* ─── Chart ─── */
.chart-view { padding: 0 28px 40px; overflow: auto; display: flex; flex-direction: column; gap: 16px; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
.kpi, .chart-card, .card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 8px; padding: 18px; }
.kpi { display: flex; flex-direction: column; gap: 4px; }
.kpi-n { font-size: 32px; font-weight: 600; }
.bars { display: flex; flex-direction: column; gap: 10px; }
.bar-row { display: grid; grid-template-columns: minmax(80px, 180px) 1fr 80px; align-items: center; gap: 12px; }
.bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-track { height: 26px; background: var(--surface-2); border-radius: 4px; overflow: hidden; }
.bar { display: block; height: 100%; border-radius: 4px; transition: width .3s; }
.pie-wrap { display: flex; align-items: center; gap: 32px; flex-wrap: wrap; }
.legend { display: flex; flex-direction: column; gap: 6px; }
.legend i { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 8px; vertical-align: middle; }
/* ─── Pages ─── */
.page { flex: 1; overflow-y: auto; padding: 0 32px 60px; }
.page-head { display: flex; align-items: center; gap: 12px; padding: 24px 0 18px; position: sticky; top: 0; background: var(--surface); z-index: 5; }
.page-head h1 { font-size: 26px; }
.page-head p { margin: 4px 0 0; }
.page-head .input { width: auto; }
.home-grid { display: flex; flex-direction: column; gap: 28px; max-width: 1100px; }
.home-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; padding: 12px; }
.stat { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; padding: 14px; border-radius: 8px; text-align: left; }
.stat:hover { background: var(--hover); }
.stat-n { font-size: 30px; font-weight: 600; }
.section-title { display: flex; align-items: center; gap: 6px; font-size: 16px; margin: 18px 0 10px; }
.board-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
.board-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 8px; padding: 12px; display: flex; flex-direction: column; gap: 6px; text-align: left; transition: box-shadow .15s; }
.board-card:hover { box-shadow: var(--shadow); }
.board-card.new { align-items: center; justify-content: center; color: var(--muted); min-height: 150px; border-style: dashed; }
.bc-preview { height: 84px; background: var(--surface-2); border-radius: 6px; padding: 10px; display: flex; flex-direction: column; gap: 6px; }
.bc-preview i { display: block; height: 10px; border-radius: 2px; background: linear-gradient(90deg, #579bfc 0 6px, var(--surface-3) 6px 60%, #00c875 60% 75%, #fdab3d 75%); }
.bc-preview i:nth-child(2) { background: linear-gradient(90deg, #579bfc 0 6px, var(--surface-3) 6px 60%, #fdab3d 60% 75%, #e2445c 75%); }
.bc-name { display: flex; align-items: center; gap: 6px; font-weight: 600; }
.mw-group { margin-bottom: 24px; }
.mw-group h3 { font-size: 17px; margin-bottom: 8px; display: flex; gap: 8px; align-items: baseline; }
.mw-row { display: grid; grid-template-columns: 28px minmax(0, 2fr) minmax(0, 1.4fr) 130px 100px; gap: 10px; align-items: center; min-height: 42px; border-bottom: 1px solid var(--border-soft); }
.mw-row.done .mw-name { text-decoration: line-through; color: var(--muted); }
.mw-check { width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border); color: transparent; display: grid; place-items: center; }
.mw-check:hover { border-color: var(--green); color: var(--green); }
.mw-check.on { background: var(--green); border-color: var(--green); color: #fff; }
.mw-name { text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.mw-name:hover { color: var(--primary); }
.mw-board { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; }
.mw-status { height: 30px; display: grid; place-items: center; font-size: 13px; padding: 0 6px; overflow: hidden; white-space: nowrap; }
.mw-status.empty { background: var(--status-empty); opacity: .5; }
.mw-date { font-size: 13px; text-align: center; }
.mw-date.overdue { color: var(--red); font-weight: 600; }
.notif-list { display: flex; flex-direction: column; margin-top: 16px; max-width: 900px; }
.notif { display: flex; gap: 12px; align-items: flex-start; padding: 14px 12px; border-bottom: 1px solid var(--border-soft); cursor: pointer; border-radius: 6px; }
.notif:hover { background: var(--row-hover); }
.notif.unread { background: var(--row-selected); }
.notif-av { position: relative; }
.notif-kind { position: absolute; right: -4px; bottom: -4px; width: 20px; height: 20px; border-radius: 50%; background: var(--primary); color: #fff; display: grid; place-items: center; border: 2px solid var(--surface); }
.notif-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; line-height: 1.4; }
.read-dot { width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--faint); }
.read-dot.on { background: var(--primary); border-color: var(--primary); }
.big-search { display: flex; align-items: center; gap: 10px; border: 1px solid var(--border); border-radius: 8px; padding: 0 16px; height: 52px; max-width: 760px; color: var(--muted); }
.big-search:focus-within { border-color: var(--primary); }
.big-search input { border: 0; outline: none; background: none; flex: 1; font-size: 17px; color: var(--text); }
.search-page .section-title { margin-top: 28px; }
.result { display: flex; align-items: center; gap: 10px; width: 100%; max-width: 900px; padding: 10px 12px; border-radius: 6px; text-align: left; }
.result:hover { background: var(--hover); }
.people-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 14px; }
.person-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 10px; padding: 20px 14px; display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; }
.person-card.inactive { opacity: .45; }
.person-card a { overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.admin .panel-tabs { padding: 0; margin-bottom: 20px; }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th { text-align: left; font-weight: 500; color: var(--muted); font-size: 13px; padding: 8px; border-bottom: 1px solid var(--border-soft); }
.admin-table td { padding: 8px; border-bottom: 1px solid var(--border-soft); }
.admin-table .input { width: 120px; }
.admin-table tr.inactive td { opacity: .5; }
.import-card { max-width: 820px; display: flex; flex-direction: column; gap: 12px; }
.steps { margin: 0; padding-left: 20px; line-height: 1.7; color: var(--muted); }
.steps b { color: var(--text); }
.import-list { max-height: 340px; overflow-y: auto; border: 1px solid var(--border-soft); border-radius: 6px; padding: 6px 12px; }
.import-log pre { background: var(--surface-2); padding: 12px; border-radius: 6px; max-height: 320px; overflow: auto; font-size: 12px; white-space: pre-wrap; }
.user-menu { min-width: 260px; }
.um-head { display: flex; align-items: center; gap: 10px; padding: 16px 16px 8px; }
.tpl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
.tpl { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; text-align: left; }
.tpl .icon { color: var(--primary); }
.tpl.on { border-color: var(--primary); background: var(--primary-soft); }
.automations .auto-row { display: flex; align-items: center; gap: 10px; padding: 12px; border: 1px solid var(--border-soft); border-radius: 8px; margin-bottom: 8px; }
.auto-row .icon { color: var(--orange); }
.auto-row.off { opacity: .55; }
.auto-text { flex: 1; line-height: 1.5; }
.auto-builder { border: 1px solid var(--primary); border-radius: 8px; padding: 16px; margin-top: 8px; }
.auto-sentence { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 16px; }
.auto-sentence .input { width: auto; }
.auto-sentence .grow { min-width: 200px; }
/* ─── Mobile ─── */
.mobile-tabs { display: none; }
@media (max-width: 768px) {
body { font-size: 15px; }
.app { flex-direction: column; }
.main { margin: 0; border-radius: 0; padding-bottom: calc(60px + env(safe-area-inset-bottom)); padding-top: env(safe-area-inset-top); }
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 200; transform: translateX(-105%); transition: transform .22s ease; box-shadow: var(--shadow-lg); padding-top: env(safe-area-inset-top); background: var(--rail); }
.sidebar.open { transform: none; }
.sidebar.collapsed .nav-panel { display: flex; }
.nav-collapsed .nav-panel { display: flex; }
.expand-btn, .collapse-btn { display: none; }
.nav-panel { width: min(290px, 78vw); margin: 0; border-radius: 0; }
.drawer-backdrop { position: fixed; inset: 0; background: #0007; z-index: 150; }
.nav-board, .nav-folder-row { height: 42px; }
.nav-more { opacity: 1; }
.mobile-tabs { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 100; background: var(--surface); border-top: 1px solid var(--border-soft); padding-bottom: env(safe-area-inset-bottom); }
.mtab { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 8px 0 6px; font-size: 11px; color: var(--muted); position: relative; }
.mtab.active { color: var(--primary); }
.mtab .rail-badge { top: 2px; right: calc(50% - 22px); border-color: var(--surface); }
.board-header { padding: 10px 12px 0; }
.board-title { font-size: 20px; }
.toolbar { padding: 10px 12px; gap: 2px; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
.tb-search input { width: 120px; }
.page { padding: 0 16px 40px; }
.page-head { padding: 14px 0 12px; }
.page-head h1 { font-size: 22px; }
.mw-row { grid-template-columns: 28px 1fr auto; grid-template-areas: "c n d" "c b s"; padding: 8px 0; row-gap: 4px; }
.mw-check { grid-area: c; } .mw-name { grid-area: n; } .mw-board { grid-area: b; } .mw-status { grid-area: s; height: 24px; border-radius: 12px; font-size: 12px; min-width: 90px; } .mw-date { grid-area: d; }
.item-panel { width: 100vw; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
.panel-body { padding: 14px 12px 40px; }
.panel-title { padding: 6px 14px 8px; font-size: 20px; }
.panel-tabs { padding: 0 10px; }
.info-row { grid-template-columns: 120px 1fr; }
.info-label { padding: 0 10px; font-size: 13px; }
.replies { padding-left: 16px; }
.kanban-wrap, .calendar, .timeline, .chart-view { padding-left: 12px; padding-right: 12px; }
.file-gallery { padding: 0 12px 40px; grid-template-columns: repeat(2, 1fr); }
.lane { width: 82vw; }
.tl-names { width: 130px; }
.bulk-label { display: none; }
.filter-rule { min-width: 0; grid-template-columns: 1fr 1fr; }
.filter-rule > .muted { display: none; }
.quick-filters { flex-direction: column; }
.popover.sheet .picker, .popover.sheet .datepicker, .popover.sheet .status-picker, .popover.sheet .longtext-editor, .popover.sheet .form-pop, .popover.sheet .files-pop, .popover.sheet .pop-pad, .popover.sheet .label-editor { width: 100%; max-width: none; min-width: 0; }
.popover.sheet .menu-item { padding: 12px 10px; }
.popover.sheet .dp-day { height: 42px; }
.col-type-menu .menu { min-width: 0; }
.modal-backdrop { align-items: flex-end; padding: 0; }
.modal { border-radius: 16px 16px 0 0; max-height: 92vh; padding-bottom: env(safe-area-inset-bottom); }
.dialog { padding: 20px 16px; }
.act-row { grid-template-columns: 40px 24px 1fr; }
.act-row .act-what { grid-column: 2 / -1; }
.bar-row { grid-template-columns: 90px 1fr 60px; }
.board-cards { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.bc-preview { height: 60px; }
.admin-table th:nth-child(2), .admin-table td:nth-child(2), .admin-table th:nth-child(4), .admin-table td:nth-child(4) { display: none; }
}
/* phone board list */
.mobile-list { flex: 1; overflow-y: auto; padding: 0 12px 30px; display: flex; flex-direction: column; gap: 14px; }
.m-group { --g: #579bfc; }
.m-group-head { display: flex; align-items: center; gap: 6px; width: 100%; padding: 8px 2px; font-weight: 600; font-size: 16px; }
.m-card { display: flex; flex-direction: column; gap: 8px; width: 100%; text-align: left; background: var(--surface); border: 1px solid var(--border-soft); border-left: 4px solid var(--g); border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.m-card:active { background: var(--row-hover); }
.m-card-top { display: flex; align-items: flex-start; gap: 8px; }
.m-name { flex: 1; font-weight: 500; line-height: 1.35; }
.m-card-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.m-status { padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.m-date { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--muted); }
.m-card-meta .muted { display: inline-flex; align-items: center; gap: 3px; }
.m-add { display: flex; align-items: center; gap: 6px; padding: 10px; color: var(--muted); width: 100%; }
.m-add-input { margin-bottom: 8px; }
.m-add-group { align-self: flex-start; }
@media (hover: none) {
.row-menu, .open-item, .th-menu, .group-menu-btn, .nav-more { opacity: 1; }
}
@media print {
.sidebar, .toolbar, .mobile-tabs, .view-tabs { display: none !important; }
body { overflow: visible; }
}
.empty-updates .icon { margin: 0 auto; }
+17
View File
@@ -0,0 +1,17 @@
// Network-first for the app shell so a deploy is picked up on next load; cached copy
// only when offline. API calls and files are never cached here.
const CACHE = 'wb-shell-v1';
self.addEventListener('install', (e) => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
if (e.request.method !== 'GET' || url.origin !== location.origin) return;
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/files/')) return;
e.respondWith(
fetch(e.request).then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(e.request, copy));
return res;
}).catch(() => caches.match(e.request).then((r) => r || caches.match('/')))
);
});
File diff suppressed because one or more lines are too long
+86
View File
@@ -0,0 +1,86 @@
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
const path = require('path');
const express = require('express');
const cookieParser = require('cookie-parser');
const auth = require('./src/auth');
const realtime = require('./src/realtime');
const api = require('./src/api');
const automations = require('./src/automations');
const importer = require('./src/importMonday');
const { get, run, id, now } = require('./src/db');
const PORT = Number(process.env.PORT || 3100);
const app = express();
app.set('trust proxy', true); // behind the NAS reverse proxy, so req.secure reflects https
app.disable('x-powered-by');
app.use(express.json({ limit: '5mb' }));
app.use(cookieParser());
app.use((req, res, next) => {
res.set('X-Content-Type-Options', 'nosniff');
res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Google's sign-in popup needs to postMessage back to this page.
res.set('Cross-Origin-Opener-Policy', 'same-origin-allow-popups');
next();
});
app.get('/healthz', (req, res) => res.json({ ok: true }));
auth.mount(app);
realtime.mount(app, auth.requireUser);
api.mountFiles(app);
// Admin: monday.com import
app.post('/api/admin/import/monday/boards', auth.requireUser, auth.requireAdmin, async (req, res) => {
try { res.json(await importer.listBoards(String(req.body.token || '').trim())); }
catch (e) { res.status(400).json({ error: e.message }); }
});
app.post('/api/admin/import/monday', auth.requireUser, auth.requireAdmin, (req, res) => {
const token = String(req.body.token || '').trim();
if (!token) return res.status(400).json({ error: 'Paste a monday.com API token' });
if (importer.status.running) return res.status(409).json({ error: 'An import is already running' });
importer.importAll(token, { boardIds: req.body.boardIds }); // runs in the background; poll status
res.json({ started: true });
});
app.get('/api/admin/import/monday', auth.requireUser, auth.requireAdmin, (req, res) => res.json(importer.status));
app.use('/api', api.router);
app.use('/api', (req, res) => res.status(404).json({ error: 'Not found' }));
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res, p) => {
if (/\.(html|webmanifest)$|sw\.js$/.test(p)) res.set('Cache-Control', 'no-cache');
},
}));
// SPA routes
app.get(/^\/(?!api|files).*/, (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
// First run: make sure there's a workspace to land in.
if (!get('SELECT 1 FROM workspaces LIMIT 1')) {
run('INSERT INTO workspaces(id, name, description, color, position, created_at) VALUES(?,?,?,?,?,?)',
id(), process.env.ORG_NAME || 'Waters Church', '', '#579bfc', 1, now());
}
automations.scheduleDaily();
// Nightly snapshot of the database into data/backups (keeps 14). VACUUM INTO writes a
// consistent copy while the app keeps running; uploads are plain files beside it.
const fs = require('fs');
const { db, DATA_DIR, getSetting, setSetting } = require('./src/db');
setInterval(() => {
const d = new Date();
const key = d.toISOString().slice(0, 10);
if (d.getHours() < 2 || getSetting('lastBackup') === key) return;
try {
const dir = path.join(DATA_DIR, 'backups');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `boards-${key}.db`);
if (!fs.existsSync(file)) db.prepare('VACUUM INTO ?').run(file);
setSetting('lastBackup', key);
const old = fs.readdirSync(dir).filter((f) => /^boards-.*\.db$/.test(f)).sort().slice(0, -14);
for (const f of old) fs.unlinkSync(path.join(dir, f));
} catch (e) { console.error('backup failed', e.message); }
}, 10 * 60_000).unref();
app.listen(PORT, () => console.log(`Waters Boards listening on :${PORT}`));
+857
View File
@@ -0,0 +1,857 @@
// REST API. All routes require a signed-in user; workspace structure is shared by the
// whole team (this is an internal tool), admin-only routes are marked.
const fs = require('fs');
const path = require('path');
const express = require('express');
const multer = require('multer');
const { db, DATA_DIR, id, now, all, get, run, tx } = require('./db');
const { requireUser, requireAdmin, publicUser } = require('./auth');
const { GROUP_COLORS, LABEL_COLORS, COLUMN_TYPES, TEMPLATES, defaultSettings } = require('./templates');
const M = require('./model');
const automations = require('./automations');
const realtime = require('./realtime');
const UPLOADS = path.join(DATA_DIR, 'uploads');
const upload = multer({
storage: multer.diskStorage({
destination: UPLOADS,
filename: (req, file, cb) => cb(null, id() + path.extname(file.originalname).slice(0, 12)),
}),
limits: { fileSize: 100 * 1024 * 1024 },
});
const r = express.Router();
r.use(requireUser);
const origin = (req) => req.get('X-Client-Id') || null;
const wrap = (fn) => (req, res) => {
try {
const out = fn(req, res);
if (out && typeof out.then === 'function') out.catch((e) => fail(res, e));
} catch (e) { fail(res, e); }
};
function fail(res, e) {
if (!e.status) console.error(e);
res.status(e.status || 500).json({ error: e.status ? e.message : 'Server error' });
}
const httpErr = (status, message) => Object.assign(new Error(message), { status });
const must = (row, what = 'Not found') => { if (!row) throw httpErr(404, what); return row; };
function nextPos(table, where, ...p) {
const r = get(`SELECT MAX(position) m FROM ${table} WHERE ${where}`, ...p);
return (r?.m ?? 0) + 1;
}
function firstPos(table, where, ...p) {
const r = get(`SELECT MIN(position) m FROM ${table} WHERE ${where}`, ...p);
return (r?.m ?? 1) - 1;
}
// ─── Bootstrap / tree ─────────────────────────────────────────────
r.get('/bootstrap', wrap((req, res) => {
res.json({
me: publicUser(req.user),
users: all('SELECT * FROM users ORDER BY name').map(publicUser),
...tree(req.user.id),
unread: get('SELECT COUNT(*) n FROM notifications WHERE user_id = ? AND read = 0', req.user.id).n,
online: realtime.onlineUserIds(),
});
}));
r.get('/tree', wrap((req, res) => res.json(tree(req.user.id))));
function tree(userId) {
return {
workspaces: all('SELECT * FROM workspaces ORDER BY position'),
folders: all('SELECT * FROM folders ORDER BY position'),
boards: all('SELECT id, workspace_id, folder_id, name, position FROM boards WHERE archived = 0 ORDER BY position'),
favorites: all('SELECT board_id FROM favorites WHERE user_id = ?', userId).map((f) => f.board_id),
};
}
// ─── Workspaces & folders ────────────────────────────────────────
r.post('/workspaces', wrap((req, res) => {
const wid = id();
run('INSERT INTO workspaces(id, name, description, color, position, created_at) VALUES(?,?,?,?,?,?)',
wid, req.body.name?.trim() || 'New workspace', req.body.description || '', req.body.color || GROUP_COLORS[0], nextPos('workspaces', '1'), now());
M.emitTree(origin(req));
res.json(get('SELECT * FROM workspaces WHERE id = ?', wid));
}));
r.patch('/workspaces/:id', wrap((req, res) => {
must(get('SELECT 1 FROM workspaces WHERE id = ?', req.params.id));
for (const k of ['name', 'description', 'color', 'position']) {
if (k in req.body) run(`UPDATE workspaces SET ${k} = ? WHERE id = ?`, req.body[k], req.params.id);
}
M.emitTree(origin(req));
res.json({ ok: true });
}));
r.delete('/workspaces/:id', requireAdmin, wrap((req, res) => {
if (get('SELECT COUNT(*) n FROM workspaces').n <= 1) throw httpErr(400, 'You need at least one workspace');
run('DELETE FROM workspaces WHERE id = ?', req.params.id);
M.emitTree(origin(req));
res.json({ ok: true });
}));
r.post('/folders', wrap((req, res) => {
const fid = id();
must(get('SELECT 1 FROM workspaces WHERE id = ?', req.body.workspace_id), 'Workspace not found');
run('INSERT INTO folders(id, workspace_id, parent_id, name, color, position) VALUES(?,?,?,?,?,?)',
fid, req.body.workspace_id, req.body.parent_id || null, req.body.name?.trim() || 'New folder', req.body.color || null,
nextPos('folders', 'workspace_id = ?', req.body.workspace_id));
M.emitTree(origin(req));
res.json(get('SELECT * FROM folders WHERE id = ?', fid));
}));
r.patch('/folders/:id', wrap((req, res) => {
must(get('SELECT 1 FROM folders WHERE id = ?', req.params.id));
for (const k of ['name', 'color', 'position', 'parent_id', 'workspace_id']) {
if (k in req.body) run(`UPDATE folders SET ${k} = ? WHERE id = ?`, req.body[k], req.params.id);
}
if ('workspace_id' in req.body) run('UPDATE boards SET workspace_id = ? WHERE folder_id = ?', req.body.workspace_id, req.params.id);
M.emitTree(origin(req));
res.json({ ok: true });
}));
r.delete('/folders/:id', wrap((req, res) => {
// Boards inside are kept and moved to the folder's parent (or the workspace root).
const f = must(get('SELECT * FROM folders WHERE id = ?', req.params.id));
tx(() => {
run('UPDATE boards SET folder_id = ? WHERE folder_id = ?', f.parent_id, f.id);
run('UPDATE folders SET parent_id = ? WHERE parent_id = ?', f.parent_id, f.id);
run('DELETE FROM folders WHERE id = ?', f.id);
});
M.emitTree(origin(req));
res.json({ ok: true });
}));
// ─── Boards ──────────────────────────────────────────────────────
function createColumn(boardId, title, type, settings, position) {
let t = type;
let s = settings;
if (type === 'priority') { t = 'status'; s = s || defaultSettings('priority'); }
if (!COLUMN_TYPES.includes(t)) throw httpErr(400, 'Unknown column type');
const cid = id();
run('INSERT INTO columns(id, board_id, title, type, settings, position) VALUES(?,?,?,?,?,?)',
cid, boardId, title, t, JSON.stringify(s || defaultSettings(t)), position ?? nextPos('columns', 'board_id = ?', boardId));
return cid;
}
function createGroup(boardId, title, color, position) {
const gid = id();
const n = get('SELECT COUNT(*) n FROM groups WHERE board_id = ?', boardId).n;
run('INSERT INTO groups(id, board_id, title, color, position) VALUES(?,?,?,?,?)',
gid, boardId, title, color || GROUP_COLORS[n % GROUP_COLORS.length], position ?? nextPos('groups', 'board_id = ?', boardId));
return gid;
}
function defaultViews(boardId) {
run('INSERT INTO views(id, board_id, type, name, settings, position) VALUES(?,?,?,?,?,?)', id(), boardId, 'table', 'Main table', '{}', 0);
}
r.post('/boards', wrap((req, res) => {
const { workspace_id, folder_id, name, template = 'blank' } = req.body;
must(get('SELECT 1 FROM workspaces WHERE id = ?', workspace_id), 'Workspace not found');
const t = TEMPLATES[template] || TEMPLATES.blank;
const bid = id();
tx(() => {
run('INSERT INTO boards(id, workspace_id, folder_id, name, description, item_label, position, created_by, created_at) VALUES(?,?,?,?,?,?,?,?,?)',
bid, workspace_id, folder_id || null, name?.trim() || 'New board', '', t.itemLabel || 'Item',
nextPos('boards', 'workspace_id = ?', workspace_id), req.user.id, now());
t.columns.forEach(([title, type], i) => createColumn(bid, title, type, null, i + 1));
const gids = t.groups.map((g, i) => createGroup(bid, g, GROUP_COLORS[i % GROUP_COLORS.length], i + 1));
const seed = t.items || { 0: ['Item 1', 'Item 2', 'Item 3'] };
for (const [gi, names] of Object.entries(seed)) {
names.forEach((n, i) => run('INSERT INTO items(id, board_id, group_id, name, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)',
id(), bid, gids[gi], n, i + 1, req.user.id, now(), now()));
}
defaultViews(bid);
run('INSERT INTO favorites(user_id, board_id) VALUES(?,?) ON CONFLICT DO NOTHING', req.user.id, bid);
});
M.logActivity(bid, null, req.user.id, 'board_created', { name });
M.emitTree(origin(req));
res.json({ id: bid });
}));
r.get('/boards/:id', wrap((req, res) => {
const b = must(M.loadBoard(req.params.id), 'Board not found');
b.favorite = !!get('SELECT 1 FROM favorites WHERE user_id = ? AND board_id = ?', req.user.id, req.params.id);
res.json(b);
}));
r.patch('/boards/:id', wrap((req, res) => {
must(get('SELECT 1 FROM boards WHERE id = ?', req.params.id));
for (const k of ['name', 'description', 'folder_id', 'workspace_id', 'position', 'item_label']) {
if (k in req.body) run(`UPDATE boards SET ${k} = ? WHERE id = ?`, req.body[k], req.params.id);
}
M.emitTree(origin(req));
M.emitBoard(req.params.id, origin(req));
res.json({ ok: true });
}));
r.delete('/boards/:id', wrap((req, res) => {
run('DELETE FROM boards WHERE id = ?', req.params.id);
M.emitTree(origin(req));
M.emitBoard(req.params.id, origin(req));
res.json({ ok: true });
}));
r.post('/boards/:id/favorite', wrap((req, res) => {
if (req.body.on) run('INSERT INTO favorites(user_id, board_id) VALUES(?,?) ON CONFLICT DO NOTHING', req.user.id, req.params.id);
else run('DELETE FROM favorites WHERE user_id = ? AND board_id = ?', req.user.id, req.params.id);
res.json({ ok: true });
}));
r.post('/boards/:id/duplicate', wrap((req, res) => {
const src = must(M.loadBoard(req.params.id));
const withItems = req.body.items !== false;
const bid = id();
tx(() => {
const b = src.board;
run('INSERT INTO boards(id, workspace_id, folder_id, name, description, item_label, position, created_by, created_at) VALUES(?,?,?,?,?,?,?,?,?)',
bid, b.workspace_id, b.folder_id, req.body.name || `${b.name} (copy)`, b.description, b.item_label, b.position + 0.5, req.user.id, now());
const colMap = {};
for (const c of src.columns) {
colMap[c.id] = id();
run('INSERT INTO columns(id, board_id, title, type, settings, width, position) VALUES(?,?,?,?,?,?,?)',
colMap[c.id], bid, c.title, c.type, JSON.stringify(c.settings), c.width, c.position);
}
const grpMap = {};
for (const g of src.groups) {
grpMap[g.id] = id();
run('INSERT INTO groups(id, board_id, title, color, position) VALUES(?,?,?,?,?)', grpMap[g.id], bid, g.title, g.color, g.position);
}
if (withItems) {
const itemMap = {};
const remap = (vals) => {
const out = {};
for (const [k, v] of Object.entries(vals)) if (colMap[k]) out[colMap[k]] = v;
// Files belong to the original item; don't share them between copies.
for (const c of src.columns) if (c.type === 'files') delete out[colMap[c.id]];
return out;
};
const ordered = [...src.items].sort((a, b) => (a.parent_id ? 1 : 0) - (b.parent_id ? 1 : 0));
for (const it of ordered) {
itemMap[it.id] = id();
run('INSERT INTO items(id, board_id, group_id, parent_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
itemMap[it.id], bid, grpMap[it.group_id] || null, it.parent_id ? itemMap[it.parent_id] : null, it.name,
JSON.stringify(remap(it.vals)), it.position, req.user.id, now(), now());
}
}
for (const v of src.views) {
run('INSERT INTO views(id, board_id, type, name, settings, position) VALUES(?,?,?,?,?,?)', id(), bid, v.type, v.name, '{}', v.position);
}
});
M.emitTree(origin(req));
res.json({ id: bid });
}));
r.get('/boards/:id/activity', wrap((req, res) => {
res.json(all(`SELECT a.*, i.name item_name FROM activity a LEFT JOIN items i ON i.id = a.item_id
WHERE a.board_id = ? ORDER BY a.id DESC LIMIT 300`, req.params.id).map((a) => ({ ...a, data: M.parse(a.data, null) })));
}));
r.get('/boards/:id/export.csv', wrap((req, res) => {
const b = must(M.loadBoard(req.params.id));
const esc = (s) => `"${String(s ?? '').replace(/"/g, '""')}"`;
const header = ['Group', b.board.item_label || 'Item', 'Parent item', ...b.columns.map((c) => c.title)];
const lines = [header.map(esc).join(',')];
const groupName = Object.fromEntries(b.groups.map((g) => [g.id, g.title]));
const byId = Object.fromEntries(b.items.map((i) => [i.id, i]));
for (const it of b.items) {
const cells = b.columns.map((c) => {
if (c.type === 'files') return b.files.filter((f) => f.item_id === it.id).map((f) => f.name).join(', ');
if (c.type === 'created') return new Date(it.created_at).toISOString().slice(0, 10);
if (c.type === 'updated') return new Date(it.updated_at).toISOString().slice(0, 10);
if (c.type === 'item_id') return it.id;
return M.formatValue(c, it.vals[c.id]);
});
const parent = it.parent_id ? byId[it.parent_id] : null;
lines.push([groupName[it.group_id || parent?.group_id] || '', it.name, parent?.name || '', ...cells].map(esc).join(','));
}
res.set('Content-Type', 'text/csv; charset=utf-8');
res.set('Content-Disposition', `attachment; filename="${b.board.name.replace(/[^\w\- ]/g, '')}.csv"`);
res.send('' + lines.join('\r\n'));
}));
// CSV import into an existing board: first row is headers; "Name"/first column is the item,
// "Group" picks/creates a group, other headers match columns by title (created as text if new).
r.post('/boards/:id/import.csv', express.text({ type: '*/*', limit: '20mb' }), wrap((req, res) => {
const b = must(M.loadBoard(req.params.id));
const rows = parseCsv(String(req.body || ''));
if (rows.length < 2) throw httpErr(400, 'The CSV has no rows');
const header = rows.shift().map((h) => h.trim());
const gIdx = header.findIndex((h) => /^group$/i.test(h));
const nIdx = Math.max(0, header.findIndex((h) => /^(name|item|title)$/i.test(h)));
let created = 0;
tx(() => {
const cols = header.map((h, i) => {
if (i === gIdx || i === nIdx || !h) return null;
const existing = b.columns.find((c) => c.title.toLowerCase() === h.toLowerCase());
if (existing) return existing;
const cid = createColumn(b.board.id, h, 'text');
const c = { id: cid, type: 'text', settings: {}, title: h };
b.columns.push(c);
return c;
});
const groups = new Map(b.groups.map((g) => [g.title.toLowerCase(), g.id]));
const defaultGroup = b.groups[0]?.id || createGroup(b.board.id, 'Imported');
for (const row of rows) {
if (!row.some((c) => c.trim())) continue;
let gid = defaultGroup;
if (gIdx >= 0 && row[gIdx]?.trim()) {
const key = row[gIdx].trim().toLowerCase();
if (!groups.has(key)) groups.set(key, createGroup(b.board.id, row[gIdx].trim()));
gid = groups.get(key);
}
const vals = {};
cols.forEach((c, i) => { if (c && row[i]?.trim()) vals[c.id] = coerceImported(c, row[i].trim()); });
run('INSERT INTO items(id, board_id, group_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)',
id(), b.board.id, gid, row[nIdx]?.trim() || 'Untitled', JSON.stringify(vals), nextPos('items', 'board_id = ?', b.board.id), req.user.id, now(), now());
created++;
}
});
M.emitBoard(b.board.id, origin(req));
res.json({ created });
}));
function coerceImported(col, text) {
const s = col.settings || {};
switch (col.type) {
case 'number': { const n = Number(text.replace(/[^0-9.\-]/g, '')); return Number.isFinite(n) ? n : null; }
case 'checkbox': return /^(true|yes|1|✓|v)$/i.test(text);
case 'date': { const d = new Date(text); return isNaN(d) ? null : d.toISOString().slice(0, 10); }
case 'status': {
let l = (s.labels || []).find((x) => x.name.toLowerCase() === text.toLowerCase());
if (!l) {
l = { id: id(), name: text, color: LABEL_COLORS[(s.labels || []).length % LABEL_COLORS.length] };
s.labels = [...(s.labels || []), l];
run('UPDATE columns SET settings = ? WHERE id = ?', JSON.stringify(s), col.id);
}
return l.id;
}
case 'people': {
return text.split(/[,;]/).map((n) => n.trim().toLowerCase()).map((n) =>
get('SELECT id FROM users WHERE lower(name) = ? OR lower(email) = ?', n, n)?.id).filter(Boolean);
}
default: return text;
}
}
function parseCsv(text) {
const rows = []; let row = []; let cell = ''; let q = false;
text = text.replace(/^/, '');
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (q) {
if (ch === '"' && text[i + 1] === '"') { cell += '"'; i++; }
else if (ch === '"') q = false;
else cell += ch;
} else if (ch === '"') q = true;
else if (ch === ',') { row.push(cell); cell = ''; }
else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++;
row.push(cell); rows.push(row); row = []; cell = '';
} else cell += ch;
}
if (cell || row.length) { row.push(cell); rows.push(row); }
return rows;
}
// ─── Columns ─────────────────────────────────────────────────────
r.post('/boards/:id/columns', wrap((req, res) => {
must(get('SELECT 1 FROM boards WHERE id = ?', req.params.id));
const { title, type, settings, after } = req.body;
let position;
if (after) {
const a = get('SELECT position FROM columns WHERE id = ?', after);
const n = a && get('SELECT MIN(position) p FROM columns WHERE board_id = ? AND position > ?', req.params.id, a.position);
if (a) position = n?.p != null ? (a.position + n.p) / 2 : a.position + 1;
}
const cid = createColumn(req.params.id, title || defaultTitle(type), type, settings, position);
M.logActivity(req.params.id, null, req.user.id, 'column_created', { title: title || defaultTitle(type) });
M.emitBoard(req.params.id, origin(req));
res.json({ id: cid });
}));
function defaultTitle(type) {
return ({ text: 'Text', long_text: 'Long text', status: 'Status', priority: 'Priority', people: 'Person', date: 'Date', timeline: 'Timeline',
number: 'Numbers', checkbox: 'Check', dropdown: 'Dropdown', link: 'Link', email: 'Email', phone: 'Phone', rating: 'Rating',
files: 'Files', tags: 'Tags', created: 'Creation log', updated: 'Last updated', item_id: 'Item ID' })[type] || 'Column';
}
r.patch('/columns/:id', wrap((req, res) => {
const c = must(get('SELECT * FROM columns WHERE id = ?', req.params.id));
if ('title' in req.body) run('UPDATE columns SET title = ? WHERE id = ?', req.body.title, c.id);
if ('width' in req.body) run('UPDATE columns SET width = ? WHERE id = ?', req.body.width, c.id);
if ('position' in req.body) run('UPDATE columns SET position = ? WHERE id = ?', req.body.position, c.id);
if ('settings' in req.body) run('UPDATE columns SET settings = ? WHERE id = ?', JSON.stringify(req.body.settings), c.id);
if ('type' in req.body && req.body.type !== c.type) changeColumnType(c, req.body.type);
M.emitBoard(c.board_id, origin(req));
res.json({ ok: true });
}));
// Changing type keeps whatever converts cleanly as text; everything else is cleared.
function changeColumnType(c, type) {
if (!COLUMN_TYPES.includes(type)) throw httpErr(400, 'Unknown column type');
const col = M.columnOut(c);
tx(() => {
for (const it of all('SELECT id, vals FROM items WHERE board_id = ?', c.board_id)) {
const vals = M.parse(it.vals, {});
if (!(c.id in vals)) continue;
const text = M.formatValue(col, vals[c.id]);
if (['text', 'long_text'].includes(type)) vals[c.id] = text || null;
else if (type === 'number' && Number.isFinite(Number(text))) vals[c.id] = Number(text);
else delete vals[c.id];
run('UPDATE items SET vals = ? WHERE id = ?', JSON.stringify(vals), it.id);
}
run('UPDATE columns SET type = ?, settings = ? WHERE id = ?', type, JSON.stringify(defaultSettings(type)), c.id);
});
}
r.delete('/columns/:id', wrap((req, res) => {
const c = must(get('SELECT * FROM columns WHERE id = ?', req.params.id));
run('DELETE FROM columns WHERE id = ?', c.id);
M.logActivity(c.board_id, null, req.user.id, 'column_deleted', { title: c.title });
M.emitBoard(c.board_id, origin(req));
res.json({ ok: true });
}));
// ─── Groups ──────────────────────────────────────────────────────
r.post('/boards/:id/groups', wrap((req, res) => {
must(get('SELECT 1 FROM boards WHERE id = ?', req.params.id));
const pos = req.body.top ? firstPos('groups', 'board_id = ?', req.params.id) : undefined;
const gid = createGroup(req.params.id, req.body.title || 'New Group', req.body.color, pos);
M.emitBoard(req.params.id, origin(req));
res.json({ id: gid });
}));
r.patch('/groups/:id', wrap((req, res) => {
const g = must(get('SELECT * FROM groups WHERE id = ?', req.params.id));
for (const k of ['title', 'color', 'position']) if (k in req.body) run(`UPDATE groups SET ${k} = ? WHERE id = ?`, req.body[k], g.id);
if ('collapsed' in req.body) run('UPDATE groups SET collapsed = ? WHERE id = ?', req.body.collapsed ? 1 : 0, g.id);
M.emitBoard(g.board_id, origin(req));
res.json({ ok: true });
}));
r.delete('/groups/:id', wrap((req, res) => {
const g = must(get('SELECT * FROM groups WHERE id = ?', req.params.id));
if (get('SELECT COUNT(*) n FROM groups WHERE board_id = ?', g.board_id).n <= 1) throw httpErr(400, 'A board needs at least one group');
run('DELETE FROM groups WHERE id = ?', g.id);
M.logActivity(g.board_id, null, req.user.id, 'group_deleted', { title: g.title });
M.emitBoard(g.board_id, origin(req));
res.json({ ok: true });
}));
r.post('/groups/:id/duplicate', wrap((req, res) => {
const g = must(get('SELECT * FROM groups WHERE id = ?', req.params.id));
const gid = id();
tx(() => {
run('INSERT INTO groups(id, board_id, title, color, position) VALUES(?,?,?,?,?)', gid, g.board_id, `Duplicate of ${g.title}`, g.color, g.position + 0.5);
for (const it of all('SELECT * FROM items WHERE group_id = ? AND archived = 0 AND parent_id IS NULL', g.id)) {
run('INSERT INTO items(id, board_id, group_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)',
id(), g.board_id, gid, it.name, it.vals, it.position, req.user.id, now(), now());
}
});
M.emitBoard(g.board_id, origin(req));
res.json({ id: gid });
}));
// ─── Items ───────────────────────────────────────────────────────
r.post('/boards/:id/items', wrap((req, res) => {
const boardId = req.params.id;
must(get('SELECT 1 FROM boards WHERE id = ?', boardId), 'Board not found');
const { name, parent_id, vals = {}, top } = req.body;
let group_id = req.body.group_id;
if (parent_id) group_id = null;
else if (!group_id || !get('SELECT 1 FROM groups WHERE id = ? AND board_id = ?', group_id, boardId)) {
group_id = get('SELECT id FROM groups WHERE board_id = ? ORDER BY position LIMIT 1', boardId)?.id || createGroup(boardId, 'Group Title');
}
const where = parent_id ? ['parent_id = ?', parent_id] : ['group_id = ? AND parent_id IS NULL', group_id];
const position = req.body.position ?? (top ? firstPos('items', where[0], where[1]) : nextPos('items', where[0], where[1]));
const iid = id();
run('INSERT INTO items(id, board_id, group_id, parent_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
iid, boardId, group_id, parent_id || null, String(name || 'New item').slice(0, 500), JSON.stringify(vals), position, req.user.id, now(), now());
M.logActivity(boardId, iid, req.user.id, 'item_created', { name, parent_id });
const cols = all('SELECT * FROM columns WHERE board_id = ?', boardId).map(M.columnOut);
for (const c of cols) {
if (c.type === 'people' && Array.isArray(vals[c.id]) && vals[c.id].length) {
M.notify(vals[c.id], { actorId: req.user.id, boardId, itemId: iid, kind: 'assigned', text: `${req.user.name} assigned you to "${name}"` });
}
}
if (!parent_id) automations.run({ type: 'item_created', boardId, itemId: iid });
M.emitBoard(boardId, origin(req));
res.json(M.itemOut(get('SELECT * FROM items WHERE id = ?', iid)));
}));
// The single path every item mutation goes through — the API, bulk actions and automations —
// so activity, notifications and automation triggers are consistent.
function applyPatch(itemId, patch, ctx) {
const row = must(get('SELECT * FROM items WHERE id = ?', itemId), 'Item not found');
const item = M.itemOut(row);
const cols = all('SELECT * FROM columns WHERE board_id = ?', item.board_id).map(M.columnOut);
const colById = Object.fromEntries(cols.map((c) => [c.id, c]));
const events = [];
const actor = ctx.userId;
const actorName = actor ? M.userName(actor) : 'Automation';
if ('name' in patch && patch.name !== item.name) {
run('UPDATE items SET name = ? WHERE id = ?', String(patch.name).slice(0, 500), itemId);
M.logActivity(item.board_id, itemId, actor, 'renamed', { from: item.name, to: patch.name });
}
if ('group_id' in patch && patch.group_id !== item.group_id && !item.parent_id) {
const g = must(get('SELECT * FROM groups WHERE id = ?', patch.group_id), 'Group not found');
const pos = patch.position ?? nextPos('items', 'group_id = ? AND parent_id IS NULL', g.id);
const toBoard = g.board_id;
run('UPDATE items SET group_id = ?, board_id = ?, position = ? WHERE id = ?', g.id, toBoard, pos, itemId);
if (toBoard !== item.board_id) {
run('UPDATE items SET board_id = ? WHERE parent_id = ?', toBoard, itemId);
// Values keyed by column id don't mean anything on another board; keep those whose
// column title + type match a column there.
const target = all('SELECT * FROM columns WHERE board_id = ?', toBoard).map(M.columnOut);
const remapped = {};
for (const [k, v] of Object.entries(item.vals)) {
const src = colById[k];
const dst = src && target.find((t) => t.title === src.title && t.type === src.type);
if (dst) remapped[dst.id] = v;
}
run('UPDATE items SET vals = ? WHERE id = ?', JSON.stringify(remapped), itemId);
item.vals = remapped;
M.logActivity(toBoard, itemId, actor, 'moved_board', { from: item.board_id });
M.emitBoard(toBoard, ctx.origin);
} else {
M.logActivity(item.board_id, itemId, actor, 'moved_group', { to: g.title });
}
} else if ('position' in patch) {
run('UPDATE items SET position = ? WHERE id = ?', patch.position, itemId);
}
if (patch.vals && typeof patch.vals === 'object') {
const vals = { ...item.vals };
for (const [cid, v] of Object.entries(patch.vals)) {
const col = colById[cid];
if (!col) continue;
const old = vals[cid];
if (JSON.stringify(old ?? null) === JSON.stringify(v ?? null)) continue;
if (v === null || v === '' || (Array.isArray(v) && !v.length)) delete vals[cid]; else vals[cid] = v;
M.logActivity(item.board_id, itemId, actor, 'value', {
column: col.title, type: col.type, from: M.formatValue(col, old), to: M.formatValue(col, v),
});
events.push({ type: 'value_changed', boardId: item.board_id, itemId, columnId: cid, oldVal: old, newVal: v });
if (col.type === 'people') {
const added = (Array.isArray(v) ? v : []).filter((u) => !(Array.isArray(old) ? old : []).includes(u));
M.notify(added, { actorId: actor, boardId: item.board_id, itemId, kind: 'assigned', text: `${actorName} assigned you to "${patch.name || item.name}"` });
}
}
run('UPDATE items SET vals = ? WHERE id = ?', JSON.stringify(vals), itemId);
}
if (patch.archived) {
run('UPDATE items SET archived = 1 WHERE id = ? OR parent_id = ?', itemId, itemId);
M.logActivity(item.board_id, itemId, actor, 'archived', { name: item.name });
}
run('UPDATE items SET updated_at = ? WHERE id = ?', now(), itemId);
M.emitBoard(item.board_id, ctx.origin);
for (const ev of events) automations.run(ev, ctx.depth || 0);
return M.itemOut(get('SELECT * FROM items WHERE id = ?', itemId));
}
automations.setPatcher(applyPatch);
r.patch('/items/:id', wrap((req, res) => {
res.json(applyPatch(req.params.id, req.body, { userId: req.user.id, origin: origin(req) }));
}));
r.delete('/items/:id', wrap((req, res) => {
const it = must(get('SELECT * FROM items WHERE id = ?', req.params.id));
deleteItem(it);
M.logActivity(it.board_id, null, req.user.id, 'item_deleted', { name: it.name });
M.emitBoard(it.board_id, origin(req));
res.json({ ok: true });
}));
function deleteItem(it) {
const files = all('SELECT stored FROM files WHERE item_id IN (SELECT id FROM items WHERE id = ? OR parent_id = ?)', it.id, it.id);
run('DELETE FROM items WHERE id = ?', it.id);
for (const f of files) fs.unlink(path.join(UPLOADS, f.stored), () => {});
}
r.post('/items/:id/duplicate', wrap((req, res) => {
const it = must(get('SELECT * FROM items WHERE id = ?', req.params.id));
const nid = id();
tx(() => {
run('INSERT INTO items(id, board_id, group_id, parent_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
nid, it.board_id, it.group_id, it.parent_id, `${it.name} (copy)`, stripFiles(it), it.position + 0.01, req.user.id, now(), now());
if (req.body.subitems !== false) {
for (const s of all('SELECT * FROM items WHERE parent_id = ? AND archived = 0', it.id)) {
run('INSERT INTO items(id, board_id, parent_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)',
id(), s.board_id, nid, s.name, stripFiles(s), s.position, req.user.id, now(), now());
}
}
});
M.emitBoard(it.board_id, origin(req));
res.json({ id: nid });
}));
function stripFiles(it) {
const vals = M.parse(it.vals, {});
for (const c of all("SELECT id FROM columns WHERE board_id = ? AND type = 'files'", it.board_id)) delete vals[c.id];
return JSON.stringify(vals);
}
r.post('/items/bulk', wrap((req, res) => {
const { ids = [], action, group_id, vals } = req.body;
const boards = new Set();
for (const iid of ids) {
const it = get('SELECT * FROM items WHERE id = ?', iid);
if (!it) continue;
boards.add(it.board_id);
const ctx = { userId: req.user.id, origin: origin(req) };
if (action === 'delete') { deleteItem(it); M.logActivity(it.board_id, null, req.user.id, 'item_deleted', { name: it.name }); }
else if (action === 'archive') applyPatch(iid, { archived: true }, ctx);
else if (action === 'move') applyPatch(iid, { group_id }, ctx);
else if (action === 'set') applyPatch(iid, { vals }, ctx);
else if (action === 'duplicate') {
run('INSERT INTO items(id, board_id, group_id, parent_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
id(), it.board_id, it.group_id, it.parent_id, `${it.name} (copy)`, stripFiles(it), it.position + 0.01, req.user.id, now(), now());
}
}
for (const b of boards) M.emitBoard(b, origin(req));
res.json({ ok: true });
}));
r.get('/items/:id', wrap((req, res) => {
const row = must(get('SELECT * FROM items WHERE id = ?', req.params.id), 'Item not found');
const item = M.itemOut(row);
const updates = all('SELECT * FROM updates WHERE item_id = ? ORDER BY created_at DESC', item.id).map((u) => ({
...u,
likes: all('SELECT user_id FROM update_likes WHERE update_id = ?', u.id).map((l) => l.user_id),
files: all('SELECT id, name, mime, size FROM files WHERE update_id = ?', u.id),
}));
const files = all('SELECT id, column_id, name, mime, size, uploaded_by, created_at FROM files WHERE item_id = ? ORDER BY created_at DESC', item.id);
const activity = all('SELECT * FROM activity WHERE item_id = ? ORDER BY id DESC LIMIT 200', item.id).map((a) => ({ ...a, data: M.parse(a.data, null) }));
const board = get('SELECT id, name, workspace_id FROM boards WHERE id = ?', item.board_id);
res.json({ item, updates, files, activity, board });
}));
// ─── Updates (comments) ──────────────────────────────────────────
r.post('/items/:id/updates', upload.array('files', 10), wrap((req, res) => {
const it = must(get('SELECT * FROM items WHERE id = ?', req.params.id));
const body = String(req.body.body || '').trim();
if (!body && !req.files?.length) throw httpErr(400, 'Write something first');
const uid = id();
run('INSERT INTO updates(id, item_id, parent_id, user_id, body, created_at) VALUES(?,?,?,?,?,?)',
uid, it.id, req.body.parent_id || null, req.user.id, body, now());
for (const f of req.files || []) {
run('INSERT INTO files(id, item_id, update_id, name, stored, mime, size, uploaded_by, created_at) VALUES(?,?,?,?,?,?,?,?,?)',
id(), it.id, uid, f.originalname, f.filename, f.mimetype, f.size, req.user.id, now());
}
run('UPDATE items SET updated_at = ? WHERE id = ?', now(), it.id);
// Who hears about it: anyone @mentioned, the people on the item, and earlier participants.
let mentions = [];
try { mentions = JSON.parse(req.body.mentions || '[]'); } catch { /* ignore */ }
const cols = all('SELECT * FROM columns WHERE board_id = ?', it.board_id).map(M.columnOut);
const preview = body.replace(/\s+/g, ' ').slice(0, 120);
M.notify(mentions, { actorId: req.user.id, boardId: it.board_id, itemId: it.id, kind: 'mention', text: `${req.user.name} mentioned you on "${it.name}": ${preview}` });
const others = [...M.peopleOnItem(M.itemOut(it), cols), ...all('SELECT DISTINCT user_id FROM updates WHERE item_id = ?', it.id).map((u) => u.user_id)]
.filter((u) => !mentions.includes(u));
M.notify(others, { actorId: req.user.id, boardId: it.board_id, itemId: it.id, kind: req.body.parent_id ? 'reply' : 'update', text: `${req.user.name} ${req.body.parent_id ? 'replied' : 'wrote an update'} on "${it.name}": ${preview}` });
M.logActivity(it.board_id, it.id, req.user.id, 'update_posted', {});
M.emitBoard(it.board_id, origin(req));
res.json({ id: uid });
}));
r.patch('/updates/:id', wrap((req, res) => {
const u = must(get('SELECT * FROM updates WHERE id = ?', req.params.id));
if (u.user_id !== req.user.id && req.user.role !== 'admin') throw httpErr(403, 'You can only edit your own updates');
run('UPDATE updates SET body = ?, edited_at = ? WHERE id = ?', String(req.body.body || ''), now(), u.id);
const it = get('SELECT board_id FROM items WHERE id = ?', u.item_id);
M.emitBoard(it.board_id, origin(req));
res.json({ ok: true });
}));
r.delete('/updates/:id', wrap((req, res) => {
const u = must(get('SELECT * FROM updates WHERE id = ?', req.params.id));
if (u.user_id !== req.user.id && req.user.role !== 'admin') throw httpErr(403, 'You can only delete your own updates');
run('DELETE FROM updates WHERE id = ?', u.id);
const it = get('SELECT board_id FROM items WHERE id = ?', u.item_id);
M.emitBoard(it.board_id, origin(req));
res.json({ ok: true });
}));
r.post('/updates/:id/like', wrap((req, res) => {
const u = must(get('SELECT * FROM updates WHERE id = ?', req.params.id));
if (get('SELECT 1 FROM update_likes WHERE update_id = ? AND user_id = ?', u.id, req.user.id)) {
run('DELETE FROM update_likes WHERE update_id = ? AND user_id = ?', u.id, req.user.id);
} else {
run('INSERT INTO update_likes(update_id, user_id) VALUES(?,?)', u.id, req.user.id);
const it = get('SELECT * FROM items WHERE id = ?', u.item_id);
M.notify([u.user_id], { actorId: req.user.id, boardId: it.board_id, itemId: it.id, kind: 'like', text: `${req.user.name} liked your update on "${it.name}"` });
}
const it = get('SELECT board_id FROM items WHERE id = ?', u.item_id);
M.emitBoard(it.board_id, origin(req));
res.json({ ok: true });
}));
// ─── Files ───────────────────────────────────────────────────────
r.post('/items/:id/files', upload.array('files', 20), wrap((req, res) => {
const it = must(get('SELECT * FROM items WHERE id = ?', req.params.id));
const colId = req.body.column_id || null;
const ids = [];
for (const f of req.files || []) {
const fid = id();
ids.push(fid);
run('INSERT INTO files(id, item_id, column_id, name, stored, mime, size, uploaded_by, created_at) VALUES(?,?,?,?,?,?,?,?,?)',
fid, it.id, colId, f.originalname, f.filename, f.mimetype, f.size, req.user.id, now());
}
if (colId) {
const vals = M.parse(it.vals, {});
applyPatch(it.id, { vals: { [colId]: [...(Array.isArray(vals[colId]) ? vals[colId] : []), ...ids] } }, { userId: req.user.id, origin: origin(req) });
} else {
M.logActivity(it.board_id, it.id, req.user.id, 'file_added', { names: (req.files || []).map((f) => f.originalname) });
M.emitBoard(it.board_id, origin(req));
}
res.json({ ids });
}));
r.delete('/files/:id', wrap((req, res) => {
const f = must(get('SELECT * FROM files WHERE id = ?', req.params.id));
run('DELETE FROM files WHERE id = ?', f.id);
fs.unlink(path.join(UPLOADS, f.stored), () => {});
const it = get('SELECT * FROM items WHERE id = ?', f.item_id);
if (it && f.column_id) {
const vals = M.parse(it.vals, {});
const list = (Array.isArray(vals[f.column_id]) ? vals[f.column_id] : []).filter((x) => x !== f.id);
applyPatch(it.id, { vals: { [f.column_id]: list } }, { userId: req.user.id, origin: origin(req) });
} else if (it) M.emitBoard(it.board_id, origin(req));
res.json({ ok: true });
}));
// ─── Views ───────────────────────────────────────────────────────
r.post('/boards/:id/views', wrap((req, res) => {
const vid = id();
run('INSERT INTO views(id, board_id, type, name, settings, position) VALUES(?,?,?,?,?,?)',
vid, req.params.id, req.body.type, req.body.name || req.body.type, JSON.stringify(req.body.settings || {}), nextPos('views', 'board_id = ?', req.params.id));
M.emitBoard(req.params.id, origin(req));
res.json({ id: vid });
}));
r.patch('/views/:id', wrap((req, res) => {
const v = must(get('SELECT * FROM views WHERE id = ?', req.params.id));
if ('name' in req.body) run('UPDATE views SET name = ? WHERE id = ?', req.body.name, v.id);
if ('position' in req.body) run('UPDATE views SET position = ? WHERE id = ?', req.body.position, v.id);
if ('settings' in req.body) run('UPDATE views SET settings = ? WHERE id = ?', JSON.stringify(req.body.settings), v.id);
M.emitBoard(v.board_id, origin(req));
res.json({ ok: true });
}));
r.delete('/views/:id', wrap((req, res) => {
const v = must(get('SELECT * FROM views WHERE id = ?', req.params.id));
if (get('SELECT COUNT(*) n FROM views WHERE board_id = ?', v.board_id).n <= 1) throw httpErr(400, 'A board needs at least one view');
run('DELETE FROM views WHERE id = ?', v.id);
M.emitBoard(v.board_id, origin(req));
res.json({ ok: true });
}));
// ─── Automations ─────────────────────────────────────────────────
r.post('/boards/:id/automations', wrap((req, res) => {
const aid = id();
run('INSERT INTO automations(id, board_id, trigger_def, action_def, enabled, created_by, created_at) VALUES(?,?,?,?,1,?,?)',
aid, req.params.id, JSON.stringify(req.body.trigger), JSON.stringify(req.body.action), req.user.id, now());
M.emitBoard(req.params.id, origin(req));
res.json({ id: aid });
}));
r.patch('/automations/:id', wrap((req, res) => {
const a = must(get('SELECT * FROM automations WHERE id = ?', req.params.id));
if ('enabled' in req.body) run('UPDATE automations SET enabled = ? WHERE id = ?', req.body.enabled ? 1 : 0, a.id);
if ('trigger' in req.body) run('UPDATE automations SET trigger_def = ? WHERE id = ?', JSON.stringify(req.body.trigger), a.id);
if ('action' in req.body) run('UPDATE automations SET action_def = ? WHERE id = ?', JSON.stringify(req.body.action), a.id);
M.emitBoard(a.board_id, origin(req));
res.json({ ok: true });
}));
r.delete('/automations/:id', wrap((req, res) => {
const a = must(get('SELECT * FROM automations WHERE id = ?', req.params.id));
run('DELETE FROM automations WHERE id = ?', a.id);
M.emitBoard(a.board_id, origin(req));
res.json({ ok: true });
}));
// ─── Notifications / My work / Search ────────────────────────────
r.get('/notifications', wrap((req, res) => {
res.json(all(`SELECT n.*, b.name board_name, i.name item_name FROM notifications n
LEFT JOIN boards b ON b.id = n.board_id LEFT JOIN items i ON i.id = n.item_id
WHERE n.user_id = ? ORDER BY n.created_at DESC LIMIT 200`, req.user.id));
}));
r.post('/notifications/read', wrap((req, res) => {
if (req.body.all) run('UPDATE notifications SET read = 1 WHERE user_id = ?', req.user.id);
else for (const nid of req.body.ids || []) run('UPDATE notifications SET read = ? WHERE id = ? AND user_id = ?', req.body.unread ? 0 : 1, nid, req.user.id);
res.json({ ok: true, unread: get('SELECT COUNT(*) n FROM notifications WHERE user_id = ? AND read = 0', req.user.id).n });
}));
r.delete('/notifications/:id', wrap((req, res) => {
run('DELETE FROM notifications WHERE id = ? AND user_id = ?', req.params.id, req.user.id);
res.json({ ok: true });
}));
// Items where I'm in any people column, with their first date + status for the My Work page.
r.get('/mywork', wrap((req, res) => {
const uid = req.user.id;
const target = req.query.user || uid;
const cols = all("SELECT * FROM columns WHERE type IN ('people','date','timeline','status')").map(M.columnOut);
const byBoard = {};
for (const c of cols) (byBoard[c.board_id] ||= []).push(c);
const rows = all(`SELECT i.*, b.name board_name FROM items i JOIN boards b ON b.id = i.board_id
WHERE i.archived = 0 AND b.archived = 0 AND i.vals LIKE ?`, `%${target}%`);
const out = [];
for (const row of rows) {
const it = M.itemOut(row);
const bc = byBoard[it.board_id] || [];
if (!bc.some((c) => c.type === 'people' && Array.isArray(it.vals[c.id]) && it.vals[c.id].includes(target))) continue;
const dateCol = bc.find((c) => (c.type === 'date' || c.type === 'timeline') && it.vals[c.id]);
const statusCol = bc.find((c) => c.type === 'status' && !/priority/i.test(c.title)) || bc.find((c) => c.type === 'status');
const dv = dateCol ? it.vals[dateCol.id] : null;
const label = statusCol && (statusCol.settings.labels || []).find((l) => l.id === it.vals[statusCol.id]);
const parent = it.parent_id ? get('SELECT name, group_id FROM items WHERE id = ?', it.parent_id) : null;
const group = get('SELECT title, color FROM groups WHERE id = ?', it.group_id || parent?.group_id);
out.push({
id: it.id, name: it.name, board_id: it.board_id, board_name: row.board_name, parent_name: parent?.name || null,
group: group?.title, group_color: group?.color,
date: typeof dv === 'object' && dv ? dv.to || dv.from : dv, date_column: dateCol?.id,
status: label ? { name: label.name, color: label.color, done: !!label.done || /^done$/i.test(label.name) } : null,
status_column: statusCol?.id,
});
}
res.json(out);
}));
r.get('/search', wrap((req, res) => {
const q = String(req.query.q || '').trim().toLowerCase();
if (q.length < 2) return res.json({ boards: [], items: [], updates: [] });
const like = `%${q}%`;
const boards = all('SELECT id, name, workspace_id FROM boards WHERE archived = 0 AND lower(name) LIKE ? LIMIT 20', like);
const items = all(`SELECT i.id, i.name, i.board_id, i.parent_id, b.name board_name, g.title group_title, g.color group_color
FROM items i JOIN boards b ON b.id = i.board_id LEFT JOIN groups g ON g.id = i.group_id
WHERE i.archived = 0 AND b.archived = 0 AND (lower(i.name) LIKE ? OR lower(i.vals) LIKE ?) LIMIT 60`, like, like);
const updates = all(`SELECT u.id, u.body, u.item_id, u.created_at, i.name item_name, i.board_id, b.name board_name
FROM updates u JOIN items i ON i.id = u.item_id JOIN boards b ON b.id = i.board_id
WHERE lower(u.body) LIKE ? ORDER BY u.created_at DESC LIMIT 30`, like);
res.json({ boards, items, updates });
}));
// ─── People / admin ──────────────────────────────────────────────
r.get('/users', wrap((req, res) => res.json(all('SELECT * FROM users ORDER BY name').map((u) => ({ ...publicUser(u), last_seen: u.last_seen })))));
r.patch('/me', wrap((req, res) => {
if ('name' in req.body && req.body.name.trim()) run('UPDATE users SET name = ? WHERE id = ?', req.body.name.trim(), req.user.id);
if ('title' in req.body) run('UPDATE users SET title = ? WHERE id = ?', req.body.title, req.user.id);
res.json(publicUser(get('SELECT * FROM users WHERE id = ?', req.user.id)));
}));
r.post('/users', requireAdmin, wrap((req, res) => {
// Pre-register a teammate so they can be assigned before their first sign-in.
const email = String(req.body.email || '').trim().toLowerCase();
if (!/^[^@\s]+@[^@\s]+$/.test(email)) throw httpErr(400, 'Enter a valid email');
if (get('SELECT 1 FROM users WHERE email = ?', email)) throw httpErr(400, 'That person already exists');
run('INSERT INTO users(id, email, name, role, created_at) VALUES(?,?,?,?,?)', id(), email, req.body.name || email.split('@')[0], 'member', now());
res.json({ ok: true });
}));
r.patch('/users/:id', requireAdmin, wrap((req, res) => {
const u = must(get('SELECT * FROM users WHERE id = ?', req.params.id));
if (u.id === req.user.id && ('role' in req.body || 'active' in req.body)) throw httpErr(400, "You can't change your own role or access");
if ('role' in req.body && ['admin', 'member'].includes(req.body.role)) run('UPDATE users SET role = ? WHERE id = ?', req.body.role, u.id);
if ('active' in req.body) {
run('UPDATE users SET active = ? WHERE id = ?', req.body.active ? 1 : 0, u.id);
if (!req.body.active) run('DELETE FROM sessions WHERE user_id = ?', u.id);
}
if ('name' in req.body) run('UPDATE users SET name = ? WHERE id = ?', req.body.name, u.id);
res.json({ ok: true });
}));
// ─── File download (needs a session, so not under express.static) ───
function mountFiles(app) {
app.get('/files/:id/:name?', requireUser, (req, res) => {
const f = get('SELECT * FROM files WHERE id = ?', req.params.id);
if (!f) return res.status(404).end();
const p = path.join(UPLOADS, f.stored);
if (!fs.existsSync(p)) return res.status(404).end();
res.set('Content-Type', f.mime || 'application/octet-stream');
const inline = /^(image|video|audio)\/|application\/pdf|text\/plain/.test(f.mime || '') && req.query.download === undefined;
res.set('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(f.name)}`);
res.set('Cache-Control', 'private, max-age=86400');
fs.createReadStream(p).pipe(res);
});
}
module.exports = { router: r, mountFiles, applyPatch, createColumn, createGroup, defaultViews, httpErr };
+150
View File
@@ -0,0 +1,150 @@
// Sign-in: Google (the normal path, restricted to company domains), plus two fallbacks —
// a password for the admin accounts (so you are never locked out if Google is misconfigured)
// and DEV_LOGIN for local development only.
const crypto = require('crypto');
const { OAuth2Client } = require('google-auth-library');
const { id, now, get, run, all } = require('./db');
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
const ALLOWED_DOMAINS = (process.env.ALLOWED_DOMAINS || 'waterschurch.org')
.split(',').map((d) => d.trim().toLowerCase()).filter(Boolean);
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '')
.split(',').map((e) => e.trim().toLowerCase()).filter(Boolean);
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
const DEV_LOGIN = process.env.DEV_LOGIN === '1' && process.env.NODE_ENV !== 'production';
const SESSION_TTL = 60 * 24 * 3600 * 1000;
const COOKIE = 'wb_session';
const google = GOOGLE_CLIENT_ID ? new OAuth2Client(GOOGLE_CLIENT_ID) : null;
function domainAllowed(email) {
const domain = String(email).split('@')[1]?.toLowerCase();
return !!domain && ALLOWED_DOMAINS.includes(domain);
}
function publicUser(u) {
if (!u) return null;
return { id: u.id, email: u.email, name: u.name, avatar: u.avatar, role: u.role, title: u.title, active: !!u.active };
}
// Find-or-create by email. Users created by the monday import (placeholders) are claimed
// here the first time that person signs in, so their assignments carry over.
function upsertUser({ email, name, avatar }) {
email = email.toLowerCase();
let u = get('SELECT * FROM users WHERE email = ?', email);
const isFirst = !get('SELECT 1 FROM users WHERE role = ? AND active = 1 LIMIT 1', 'admin');
if (!u) {
const role = ADMIN_EMAILS.includes(email) || isFirst ? 'admin' : 'member';
run('INSERT INTO users(id, email, name, avatar, role, created_at, last_seen) VALUES(?,?,?,?,?,?,?)',
id(), email, name || email.split('@')[0], avatar || null, role, now(), now());
u = get('SELECT * FROM users WHERE email = ?', email);
} else {
run('UPDATE users SET name = COALESCE(?, name), avatar = COALESCE(?, avatar), last_seen = ? WHERE id = ?',
name || null, avatar || null, now(), u.id);
if (ADMIN_EMAILS.includes(email) && u.role !== 'admin') run('UPDATE users SET role = ? WHERE id = ?', 'admin', u.id);
u = get('SELECT * FROM users WHERE id = ?', u.id);
}
return u;
}
function startSession(res, req, user) {
if (!user.active) throw Object.assign(new Error('This account has been deactivated'), { status: 403 });
const token = crypto.randomBytes(32).toString('base64url');
run('INSERT INTO sessions(token, user_id, expires) VALUES(?,?,?)', token, user.id, now() + SESSION_TTL);
res.cookie(COOKIE, token, {
httpOnly: true, sameSite: 'lax', secure: req.secure, maxAge: SESSION_TTL, path: '/',
});
return token;
}
function sessionUser(req) {
const token = req.cookies?.[COOKIE] || (req.headers.authorization || '').replace(/^Bearer /, '');
if (!token) return null;
const s = get('SELECT * FROM sessions WHERE token = ?', token);
if (!s || s.expires < now()) { if (s) run('DELETE FROM sessions WHERE token = ?', token); return null; }
const u = get('SELECT * FROM users WHERE id = ? AND active = 1', s.user_id);
if (!u) return null;
// Sliding expiry, but only touch the row about once an hour.
if (s.expires - now() < SESSION_TTL - 3600_000) {
run('UPDATE sessions SET expires = ? WHERE token = ?', now() + SESSION_TTL, token);
run('UPDATE users SET last_seen = ? WHERE id = ?', now(), u.id);
}
req.sessionToken = token;
return u;
}
function requireUser(req, res, next) {
const u = sessionUser(req);
if (!u) return res.status(401).json({ error: 'Not signed in' });
req.user = u;
next();
}
function requireAdmin(req, res, next) {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
next();
}
function timingSafeEqualStr(a, b) {
const ha = crypto.createHash('sha256').update(String(a)).digest();
const hb = crypto.createHash('sha256').update(String(b)).digest();
return crypto.timingSafeEqual(ha, hb);
}
function mount(app) {
app.get('/api/auth/config', (req, res) => {
res.json({
googleClientId: GOOGLE_CLIENT_ID || null,
allowedDomains: ALLOWED_DOMAINS,
passwordLogin: !!ADMIN_PASSWORD,
devLogin: DEV_LOGIN,
});
});
app.post('/api/auth/google', async (req, res) => {
if (!google) return res.status(400).json({ error: 'Google sign-in is not configured on this server' });
try {
const ticket = await google.verifyIdToken({ idToken: req.body.credential, audience: GOOGLE_CLIENT_ID });
const p = ticket.getPayload();
if (!p.email_verified) return res.status(403).json({ error: 'Google has not verified this email address' });
if (!domainAllowed(p.email)) {
return res.status(403).json({ error: `Only ${ALLOWED_DOMAINS.map((d) => '@' + d).join(' / ')} accounts can sign in` });
}
const user = upsertUser({ email: p.email, name: p.name, avatar: p.picture });
startSession(res, req, user);
res.json({ user: publicUser(user) });
} catch (e) {
res.status(e.status || 401).json({ error: e.status ? e.message : 'Google sign-in could not be verified' });
}
});
app.post('/api/auth/password', (req, res) => {
const email = String(req.body.email || '').trim().toLowerCase();
if (!ADMIN_PASSWORD || !ADMIN_EMAILS.includes(email) || !timingSafeEqualStr(req.body.password || '', ADMIN_PASSWORD)) {
return res.status(401).json({ error: 'Incorrect email or password' });
}
const user = upsertUser({ email });
startSession(res, req, user);
res.json({ user: publicUser(user) });
});
app.post('/api/auth/dev', (req, res) => {
if (!DEV_LOGIN) return res.status(404).end();
const email = String(req.body.email || '').trim().toLowerCase();
if (!domainAllowed(email)) return res.status(403).json({ error: 'Email domain not allowed' });
const user = upsertUser({ email, name: req.body.name });
startSession(res, req, user);
res.json({ user: publicUser(user) });
});
app.post('/api/auth/logout', (req, res) => {
const token = req.cookies?.[COOKIE];
if (token) run('DELETE FROM sessions WHERE token = ?', token);
res.clearCookie(COOKIE, { path: '/' });
res.json({ ok: true });
});
// Purge expired sessions occasionally.
setInterval(() => run('DELETE FROM sessions WHERE expires < ?', now()), 6 * 3600_000).unref();
}
module.exports = { mount, requireUser, requireAdmin, sessionUser, publicUser, upsertUser, ALLOWED_DOMAINS, all };
+109
View File
@@ -0,0 +1,109 @@
// Board automations: "When <trigger>, then <action>" recipes.
//
// Triggers: status_change {column_id, to?} — a status column changes (optionally to one label)
// column_change {column_id} — any change to a column
// item_created {}
// date_arrives {column_id} — checked daily at 7am server time
// Actions: move_to_group {group_id} · set_status {column_id, label} · assign {column_id, user_id}
// notify {to: 'people_column'|'user', column_id?, user_id?, text?}
// set_date_today {column_id} · archive {} · clear_column {column_id}
const { all, get, now } = require('./db');
const { parse, itemOut, columnOut, notify, userName } = require('./model');
let applyPatch = null; // injected by api.js to avoid a require cycle
function setPatcher(fn) { applyPatch = fn; }
function matches(trigger, ev) {
if (trigger.type === 'item_created') return ev.type === 'item_created';
if (ev.type !== 'value_changed' || trigger.column_id !== ev.columnId) return false;
if (trigger.type === 'column_change') return true;
if (trigger.type === 'status_change') return !trigger.to || trigger.to === ev.newVal;
return false;
}
function run(ev, depth = 0) {
if (depth > 3 || !applyPatch) return;
const rules = all('SELECT * FROM automations WHERE board_id = ? AND enabled = 1', ev.boardId);
for (const r of rules) {
const trigger = parse(r.trigger_def, {});
if (!matches(trigger, ev)) continue;
try { act(parse(r.action_def, {}), ev.itemId, ev.boardId, depth, trigger); }
catch (e) { console.error('automation failed', r.id, e.message); }
}
}
function act(action, itemId, boardId, depth, trigger) {
const row = get('SELECT * FROM items WHERE id = ?', itemId);
if (!row) return;
const item = itemOut(row);
const board = get('SELECT name FROM boards WHERE id = ?', boardId);
const sys = { userId: null, origin: null, automation: true, depth: depth + 1 };
switch (action.type) {
case 'move_to_group':
if (get('SELECT 1 FROM groups WHERE id = ? AND board_id = ?', action.group_id, boardId)) applyPatch(itemId, { group_id: action.group_id }, sys);
break;
case 'set_status':
applyPatch(itemId, { vals: { [action.column_id]: action.label } }, sys);
break;
case 'assign': {
const cur = Array.isArray(item.vals[action.column_id]) ? item.vals[action.column_id] : [];
if (!cur.includes(action.user_id)) applyPatch(itemId, { vals: { [action.column_id]: [...cur, action.user_id] } }, sys);
break;
}
case 'set_date_today':
applyPatch(itemId, { vals: { [action.column_id]: new Date().toISOString().slice(0, 10) } }, sys);
break;
case 'clear_column':
applyPatch(itemId, { vals: { [action.column_id]: null } }, sys);
break;
case 'archive':
applyPatch(itemId, { archived: true }, sys);
break;
case 'notify': {
let targets = [];
if (action.to === 'user') targets = [action.user_id];
else if (Array.isArray(item.vals[action.column_id])) targets = item.vals[action.column_id];
const text = action.text?.trim() || defaultNotifyText(trigger, item, board);
notify(targets, { actorId: null, boardId, itemId, kind: 'automation', text });
break;
}
}
}
function defaultNotifyText(trigger, item, board) {
if (trigger.type === 'date_arrives') return `"${item.name}" is due today (${board?.name})`;
if (trigger.type === 'item_created') return `New item "${item.name}" was added to ${board?.name}`;
return `"${item.name}" was updated on ${board?.name}`;
}
// Daily sweep for date_arrives triggers.
function dailyDateSweep() {
const today = new Date().toISOString().slice(0, 10);
const rules = all('SELECT * FROM automations WHERE enabled = 1');
for (const r of rules) {
const trigger = parse(r.trigger_def, {});
if (trigger.type !== 'date_arrives') continue;
const items = all('SELECT * FROM items WHERE board_id = ? AND archived = 0', r.board_id).map(itemOut);
for (const it of items) {
const v = it.vals[trigger.column_id];
const d = typeof v === 'object' && v ? v.from : v;
if (typeof d === 'string' && d.slice(0, 10) === today) {
try { act(parse(r.action_def, {}), it.id, r.board_id, 0, trigger); } catch (e) { console.error(e); }
}
}
}
}
function scheduleDaily() {
const { getSetting, setSetting } = require('./db');
setInterval(() => {
const d = new Date();
const key = d.toISOString().slice(0, 10);
if (d.getHours() >= 7 && getSetting('lastDateSweep') !== key) {
setSetting('lastDateSweep', key);
dailyDateSweep();
}
}, 60_000).unref();
}
module.exports = { run, setPatcher, scheduleDaily, describe: { columnOut, userName, now } };
+200
View File
@@ -0,0 +1,200 @@
// SQLite storage via Node's built-in node:sqlite — no native module to compile on the NAS.
// Everything lives in one file under DATA_DIR so a backup is a single copy.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { DatabaseSync } = require('node:sqlite');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
fs.mkdirSync(path.join(DATA_DIR, 'uploads'), { recursive: true });
const db = new DatabaseSync(path.join(DATA_DIR, 'boards.db'));
db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
avatar TEXT,
role TEXT NOT NULL DEFAULT 'member', -- admin | member
active INTEGER NOT NULL DEFAULT 1,
title TEXT,
created_at INTEGER NOT NULL,
last_seen INTEGER
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
color TEXT,
position REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT,
position REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT,
item_label TEXT NOT NULL DEFAULT 'Item',
position REAL NOT NULL DEFAULT 0,
created_by TEXT,
created_at INTEGER NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
external_id TEXT
);
CREATE TABLE IF NOT EXISTS columns (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
title TEXT NOT NULL,
type TEXT NOT NULL,
settings TEXT NOT NULL DEFAULT '{}',
width INTEGER,
position REAL NOT NULL DEFAULT 0,
external_id TEXT
);
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
title TEXT NOT NULL,
color TEXT NOT NULL,
position REAL NOT NULL DEFAULT 0,
collapsed INTEGER NOT NULL DEFAULT 0,
external_id TEXT
);
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
group_id TEXT REFERENCES groups(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
name TEXT NOT NULL,
vals TEXT NOT NULL DEFAULT '{}',
position REAL NOT NULL DEFAULT 0,
created_by TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
external_id TEXT
);
CREATE INDEX IF NOT EXISTS items_board ON items(board_id);
CREATE INDEX IF NOT EXISTS items_parent ON items(parent_id);
CREATE TABLE IF NOT EXISTS updates (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES updates(id) ON DELETE CASCADE,
user_id TEXT,
author_name TEXT,
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
edited_at INTEGER
);
CREATE INDEX IF NOT EXISTS updates_item ON updates(item_id);
CREATE TABLE IF NOT EXISTS update_likes (
update_id TEXT NOT NULL REFERENCES updates(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
PRIMARY KEY (update_id, user_id)
);
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
item_id TEXT REFERENCES items(id) ON DELETE CASCADE,
column_id TEXT,
update_id TEXT,
name TEXT NOT NULL,
stored TEXT NOT NULL,
mime TEXT,
size INTEGER,
uploaded_by TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
board_id TEXT,
item_id TEXT,
user_id TEXT,
action TEXT NOT NULL,
data TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS activity_item ON activity(item_id);
CREATE INDEX IF NOT EXISTS activity_board ON activity(board_id);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_id TEXT,
board_id TEXT,
item_id TEXT,
kind TEXT NOT NULL,
text TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS notif_user ON notifications(user_id, read);
CREATE TABLE IF NOT EXISTS favorites (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, board_id)
);
CREATE TABLE IF NOT EXISTS views (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- table | kanban | calendar | timeline | chart | files
name TEXT NOT NULL,
settings TEXT NOT NULL DEFAULT '{}',
position REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS automations (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
trigger_def TEXT NOT NULL,
action_def TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_by TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
`);
// Additive migrations for databases created by earlier versions.
for (const sql of ['ALTER TABLE folders ADD COLUMN external_id TEXT']) {
try { db.exec(sql); } catch { /* already applied */ }
}
const id = () => crypto.randomBytes(9).toString('base64url');
const now = () => Date.now();
function all(sql, ...p) { return db.prepare(sql).all(...p); }
function get(sql, ...p) { return db.prepare(sql).get(...p); }
function run(sql, ...p) { return db.prepare(sql).run(...p); }
function tx(fn) {
db.exec('BEGIN');
try { const r = fn(); db.exec('COMMIT'); return r; }
catch (e) { db.exec('ROLLBACK'); throw e; }
}
function getSetting(key, fallback = null) {
const r = get('SELECT value FROM settings WHERE key = ?', key);
return r ? JSON.parse(r.value) : fallback;
}
function setSetting(key, value) {
run('INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', key, JSON.stringify(value));
}
module.exports = { db, DATA_DIR, id, now, all, get, run, tx, getSetting, setSetting };
+410
View File
@@ -0,0 +1,410 @@
// One-time migration from monday.com through its public GraphQL API (api.monday.com/v2),
// using an API token the account owner generates (avatar ▸ Developers ▸ My access tokens).
// It reads data the account already owns — workspaces, folders, boards, columns, groups,
// items, subitems, updates and files — and recreates it here.
//
// Re-running is safe: a board that was already imported (matched by its monday id) is skipped.
const fs = require('fs');
const path = require('path');
const { DATA_DIR, id, now, all, get, run, tx, setSetting } = require('./db');
const { LABEL_COLORS, GROUP_COLORS } = require('./templates');
const M = require('./model');
const API = process.env.MONDAY_API_URL || 'https://api.monday.com/v2';
const UPLOADS = path.join(DATA_DIR, 'uploads');
const status = { running: false, log: [], startedAt: null, finishedAt: null, error: null, counts: {} };
const log = (msg) => { status.log.push(`${new Date().toLocaleTimeString()} ${msg}`); if (status.log.length > 500) status.log.shift(); console.log('[import]', msg); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function gql(token, query, variables = {}, attempt = 0) {
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: token, 'API-Version': '2024-10' },
body: JSON.stringify({ query, variables }),
});
const body = await res.json().catch(() => ({}));
const errs = body.errors || (body.error_message ? [{ message: body.error_message }] : null);
if (res.status === 429 || (errs && /complexity|rate limit|budget/i.test(JSON.stringify(errs)))) {
if (attempt > 8) throw new Error('monday.com kept rate-limiting the import — try again later');
const wait = Math.min(60, 5 * (attempt + 1));
log(`Rate limited by monday.com, waiting ${wait}s…`);
await sleep(wait * 1000);
return gql(token, query, variables, attempt + 1);
}
if (res.status === 401 || res.status === 403) throw new Error('monday.com rejected the API token');
if (errs) throw new Error(errs.map((e) => e.message).join('; '));
return body.data;
}
const VALUE_FIELDS = 'id type text value';
const ITEM_FIELDS = `
id name created_at updated_at state creator_id group { id }
column_values { ${VALUE_FIELDS} }
assets { id name public_url file_extension file_size }
subitems { id name created_at creator_id column_values { ${VALUE_FIELDS} column { title } } assets { id name public_url file_extension file_size } }
updates(limit: 100) {
id body created_at creator { email name }
assets { id name public_url file_extension file_size }
replies { id body created_at creator { email name } }
}`;
function htmlToText(html) {
return String(html || '')
.replace(/<br\s*\/?>/gi, '\n').replace(/<\/(p|div|li|h\d)>/gi, '\n').replace(/<li>/gi, '• ')
.replace(/<a [^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/gi, (m, href, t) => (t && t !== href ? `${t} (${href})` : href))
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/\n{3,}/g, '\n\n').trim();
}
const parseJSON = (s) => { try { return s ? JSON.parse(s) : null; } catch { return null; } };
// monday column type → { type, settings, convert(columnValue) }
function mapColumn(col, ctx) {
const st = parseJSON(col.settings_str) || {};
const t = col.type;
if (t === 'status' || t === 'color') {
let labels = [];
if (Array.isArray(st.labels)) {
labels = st.labels.map((l) => ({ id: 'm' + l.id, name: l.name ?? l.label ?? '', color: l.color || st.labels_colors?.[l.id]?.color }));
} else {
labels = Object.entries(st.labels || {}).map(([k, name]) => ({ id: 'm' + k, name, color: st.labels_colors?.[k]?.color }));
}
const done = new Set((st.done_colors || []).map((d) => 'm' + d));
labels = labels.filter((l) => l.name !== '' && l.name != null).map((l, i) => ({
...l, color: l.color || LABEL_COLORS[i % LABEL_COLORS.length], ...(done.has(l.id) || /^done$/i.test(l.name) ? { done: true } : {}),
}));
return { type: 'status', settings: { labels }, convert: (cv) => { const v = parseJSON(cv.value); return v && v.index != null ? 'm' + v.index : null; } };
}
if (t === 'dropdown') {
const labels = (st.labels || []).map((l, i) => ({ id: 'm' + l.id, name: l.name, color: LABEL_COLORS[i % LABEL_COLORS.length] }));
return { type: 'dropdown', settings: { labels }, convert: (cv) => { const v = parseJSON(cv.value); return v?.ids?.length ? v.ids.map((x) => 'm' + x) : null; } };
}
if (t === 'people' || t === 'multiple-person' || t === 'person') {
return { type: 'people', settings: {}, convert: (cv) => {
const v = parseJSON(cv.value);
const ids = (v?.personsAndTeams || []).filter((p) => p.kind !== 'team').map((p) => ctx.userMap[p.id]).filter(Boolean);
return ids.length ? ids : null;
} };
}
if (t === 'date') {
return { type: 'date', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return v?.date ? (v.time ? `${v.date}T${v.time.slice(0, 5)}` : v.date) : null; } };
}
if (t === 'timeline' || t === 'timerange') {
return { type: 'timeline', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return v?.from ? { from: v.from, to: v.to || v.from } : null; } };
}
if (t === 'numbers' || t === 'numeric') {
return { type: 'number', settings: { unit: st.unit?.symbol || '' }, convert: (cv) => (cv.text !== '' && cv.text != null && Number.isFinite(Number(cv.text)) ? Number(cv.text) : null) };
}
if (t === 'checkbox' || t === 'boolean') return { type: 'checkbox', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return v?.checked === true || v?.checked === 'true' ? true : null; } };
if (t === 'link') return { type: 'link', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return v?.url ? { url: v.url, text: v.text || '' } : null; } };
if (t === 'email') return { type: 'email', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return v?.email || cv.text || null; } };
if (t === 'phone') return { type: 'phone', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return v?.phone || cv.text || null; } };
if (t === 'rating') return { type: 'rating', settings: { max: st.max || 5 }, convert: (cv) => { const v = parseJSON(cv.value); return v?.rating || null; } };
if (t === 'long_text' || t === 'long-text') return { type: 'long_text', settings: {}, convert: (cv) => cv.text || null };
if (t === 'file' || t === 'files') return { type: 'files', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return (v?.files || []).map((f) => f.assetId).filter(Boolean); }, isFiles: true };
if (t === 'tags') return { type: 'tags', settings: {}, convert: (cv) => (cv.text ? cv.text.split(',').map((s) => s.trim()).filter(Boolean) : null) };
if (t === 'creation_log' || t === 'pulse-log') return { type: 'created', settings: {}, convert: () => null };
if (t === 'last_updated' || t === 'pulse-updated') return { type: 'updated', settings: {}, convert: () => null };
if (t === 'item_id' || t === 'pulse-id') return { type: 'item_id', settings: {}, convert: () => null };
// Everything else (mirror, formula, connect boards, location, hour, …) keeps its text.
return { type: 'text', settings: { importedFrom: t }, convert: (cv) => cv.text || null };
}
const SKIP_TYPES = new Set(['name', 'subtasks', 'subitems', 'button', 'doc', 'direct_doc']);
async function downloadAsset(asset, itemId, columnId, updateId, uploadedBy) {
const existing = get('SELECT id FROM files WHERE stored LIKE ?', `monday-${asset.id}.%`);
if (existing) return existing.id;
if (!asset.public_url) return null;
try {
const res = await fetch(asset.public_url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
const ext = (asset.file_extension || path.extname(asset.name) || '').replace(/^\.?/, '.').slice(0, 12);
const stored = `monday-${asset.id}${ext}`;
fs.writeFileSync(path.join(UPLOADS, stored), buf);
const fid = id();
run('INSERT INTO files(id, item_id, column_id, update_id, name, stored, mime, size, uploaded_by, created_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
fid, itemId, columnId, updateId, asset.name, stored, mimeFor(ext), buf.length, uploadedBy, now());
status.counts.files = (status.counts.files || 0) + 1;
return fid;
} catch (e) {
log(` could not download "${asset.name}": ${e.message}`);
return null;
}
}
function mimeFor(ext) {
const m = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic',
'.pdf': 'application/pdf', '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', '.txt': 'text/plain', '.csv': 'text/csv',
'.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.svg': 'image/svg+xml' };
return m[String(ext).toLowerCase()] || 'application/octet-stream';
}
async function importAll(token, opts = {}) {
if (status.running) throw new Error('An import is already running');
Object.assign(status, { running: true, log: [], startedAt: now(), finishedAt: null, error: null, counts: {} });
const ctx = { userMap: {}, emailById: {} };
try {
const me = await gql(token, '{ me { name email account { name } } }');
log(`Connected to monday.com account "${me.me.account?.name}" as ${me.me.name}`);
// People: placeholders by email, claimed when that person first signs in with Google.
const { users } = await gql(token, '{ users(limit: 1000) { id name email photo_thumb_small enabled is_guest } }');
for (const u of users) {
if (!u.email) continue;
const email = u.email.toLowerCase();
let row = get('SELECT id FROM users WHERE email = ?', email);
if (!row) {
const uid = id();
run('INSERT INTO users(id, email, name, avatar, role, active, created_at) VALUES(?,?,?,?,?,?,?)',
uid, email, u.name, u.photo_thumb_small || null, 'member', u.enabled === false ? 0 : 1, now());
row = { id: uid };
}
ctx.userMap[u.id] = row.id;
}
log(`Matched ${users.length} people`);
// Workspaces
const { workspaces } = await gql(token, '{ workspaces(limit: 100) { id name description } }');
const wsMap = {};
for (const w of workspaces) {
let row = get('SELECT id FROM workspaces WHERE name = ?', w.name);
if (!row) {
const wid = id();
run('INSERT INTO workspaces(id, name, description, color, position, created_at) VALUES(?,?,?,?,?,?)',
wid, w.name, w.description || '', GROUP_COLORS[Object.keys(wsMap).length % GROUP_COLORS.length], Object.keys(wsMap).length + 1, now());
row = { id: wid };
}
wsMap[w.id] = row.id;
}
const mainWs = () => {
if (!wsMap.main) {
let row = get('SELECT id FROM workspaces WHERE name = ?', 'Main workspace');
if (!row) { row = { id: id() }; run('INSERT INTO workspaces(id, name, position, created_at) VALUES(?,?,?,?)', row.id, 'Main workspace', 0, now()); }
wsMap.main = row.id;
}
return wsMap.main;
};
log(`${workspaces.length} workspaces`);
// Folders (two passes so parents exist before children)
const folderMap = {};
const allFolders = [];
const wsIds = workspaces.map((w) => Number(w.id));
for (let page = 1; page < 50; page++) {
const d = await gql(token, `query($ws: [ID], $page: Int) { folders(limit: 100, page: $page, workspace_ids: $ws) { id name color parent { id } workspace { id } } }`, { ws: wsIds, page });
allFolders.push(...d.folders);
if (d.folders.length < 100) break;
}
// Reuse folders from an earlier run (matched by monday id) so re-running doesn't duplicate them.
for (const [i, f] of allFolders.entries()) {
const existing = get('SELECT id FROM folders WHERE external_id = ?', 'monday:' + f.id);
if (existing) { folderMap[f.id] = existing.id; continue; }
const fid = id();
folderMap[f.id] = fid;
run('INSERT INTO folders(id, workspace_id, parent_id, name, color, position, external_id) VALUES(?,?,?,?,?,?,?)',
fid, f.workspace ? wsMap[f.workspace.id] || mainWs() : mainWs(), null, f.name, null, i + 1, 'monday:' + f.id);
}
for (const f of allFolders) if (f.parent && folderMap[f.parent.id]) run('UPDATE folders SET parent_id = ? WHERE id = ?', folderMap[f.parent.id], folderMap[f.id]);
log(`${allFolders.length} folders`);
// Boards
const boards = [];
for (let page = 1; page < 100; page++) {
const d = await gql(token, `query($page: Int) { boards(limit: 50, page: $page, state: active) {
id name description type workspace_id board_folder_id item_terminology
columns { id title type settings_str width }
groups { id title color position archived }
} }`, { page });
boards.push(...d.boards.filter((b) => (b.type || 'board') === 'board'));
if (d.boards.length < 50) break;
}
const wanted = opts.boardIds?.length ? boards.filter((b) => opts.boardIds.includes(String(b.id))) : boards;
log(`${wanted.length} boards to import`);
for (const [bi, b] of wanted.entries()) {
if (get('SELECT 1 FROM boards WHERE external_id = ?', 'monday:' + b.id)) { log(`Skipping "${b.name}" (already imported)`); continue; }
log(`(${bi + 1}/${wanted.length}) ${b.name}`);
await importBoard(token, b, ctx, {
workspaceId: b.workspace_id ? wsMap[b.workspace_id] || mainWs() : mainWs(),
folderId: b.board_folder_id ? folderMap[b.board_folder_id] || null : null,
position: bi + 1,
});
status.counts.boards = (status.counts.boards || 0) + 1;
M.emitTree(null);
}
// Tidy: drop imported folders left empty (e.g. only held boards you chose not to import).
for (let changed = true; changed;) {
changed = false;
for (const fid of Object.values(folderMap)) {
if (!get('SELECT 1 FROM folders WHERE id = ?', fid)) continue;
const hasContent = get('SELECT 1 FROM boards WHERE folder_id = ? UNION SELECT 1 FROM folders WHERE parent_id = ? LIMIT 1', fid, fid);
if (!hasContent) { run('DELETE FROM folders WHERE id = ?', fid); changed = true; }
}
}
setSetting('mondayImport', { at: now(), counts: status.counts });
log(`Done — ${status.counts.boards || 0} boards, ${status.counts.items || 0} items, ${status.counts.updates || 0} updates, ${status.counts.files || 0} files`);
} catch (e) {
status.error = e.message;
log(`FAILED: ${e.message}`);
} finally {
status.running = false;
status.finishedAt = now();
M.emitTree(null);
}
}
async function importBoard(token, b, ctx, where) {
const bid = id();
const colInfo = {}; // monday column id → { ourId, map }
tx(() => {
run('INSERT INTO boards(id, workspace_id, folder_id, name, description, item_label, position, created_at, external_id) VALUES(?,?,?,?,?,?,?,?,?)',
bid, where.workspaceId, where.folderId, b.name, b.description || '', b.item_terminology || 'Item', where.position, now(), 'monday:' + b.id);
let pos = 1;
for (const c of b.columns) {
if (SKIP_TYPES.has(c.type)) continue;
const map = mapColumn(c, ctx);
const cid = id();
run('INSERT INTO columns(id, board_id, title, type, settings, width, position, external_id) VALUES(?,?,?,?,?,?,?,?)',
cid, bid, c.title, map.type, JSON.stringify(map.settings), c.width || null, pos++, c.id);
colInfo[c.id] = { ourId: cid, map, title: c.title };
}
b.groups.filter((g) => !g.archived).forEach((g, i) => {
run('INSERT INTO groups(id, board_id, title, color, position, external_id) VALUES(?,?,?,?,?,?)',
id(), bid, g.title, g.color || GROUP_COLORS[i % GROUP_COLORS.length], Number(g.position) || i + 1, g.id);
});
run('INSERT INTO views(id, board_id, type, name, settings, position) VALUES(?,?,?,?,?,?)', id(), bid, 'table', 'Main table', '{}', 0);
});
const groupMap = Object.fromEntries(all('SELECT id, external_id FROM groups WHERE board_id = ?', bid).map((g) => [g.external_id, g.id]));
// Pages of items
let cursor = null;
let position = 0;
let first = true;
while (first || cursor) {
const d = first
? await gql(token, `query($id: [ID!]) { boards(ids: $id) { items_page(limit: 25) { cursor items { ${ITEM_FIELDS} } } } }`, { id: [b.id] })
: await gql(token, `query($c: String!) { next_items_page(limit: 25, cursor: $c) { cursor items { ${ITEM_FIELDS} } } }`, { c: cursor });
const page = first ? d.boards[0].items_page : d.next_items_page;
first = false;
cursor = page.cursor;
for (const it of page.items) {
await importItem(it, { bid, groupMap, colInfo, ctx, position: ++position });
}
}
}
function valuesFor(columnValues, colInfo) {
const vals = {};
const assetCols = [];
const unmatched = [];
for (const cv of columnValues || []) {
const info = colInfo[cv.id];
if (!info) { if (cv.text) unmatched.push(cv); continue; }
const v = info.map.convert(cv);
if (info.map.isFiles) { if (v?.length) assetCols.push({ col: info.ourId, assetIds: v.map(String) }); continue; }
if (v !== null && v !== undefined && !(Array.isArray(v) && !v.length)) vals[info.ourId] = v;
}
return { vals, assetCols, unmatched };
}
async function importItem(it, { bid, groupMap, colInfo, ctx, position }) {
const iid = id();
const createdAt = Date.parse(it.created_at) || now();
const createdBy = ctx.userMap[it.creator_id] || null;
const { vals, assetCols } = valuesFor(it.column_values, colInfo);
run('INSERT INTO items(id, board_id, group_id, name, vals, position, created_by, created_at, updated_at, archived, external_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
iid, bid, groupMap[it.group?.id] || Object.values(groupMap)[0], it.name, JSON.stringify(vals), position, createdBy,
createdAt, Date.parse(it.updated_at) || createdAt, it.state === 'archived' ? 1 : 0, 'monday:' + it.id);
status.counts.items = (status.counts.items || 0) + 1;
// Files that belong to a file column
const assetsById = Object.fromEntries((it.assets || []).map((a) => [String(a.id), a]));
const used = new Set();
for (const { col, assetIds } of assetCols) {
const ids = [];
for (const aid of assetIds) {
used.add(aid);
const asset = assetsById[aid];
if (!asset) continue;
const fid = await downloadAsset(asset, iid, col, null, createdBy);
if (fid) ids.push(fid);
}
if (ids.length) { vals[col] = ids; }
}
if (assetCols.length) run('UPDATE items SET vals = ? WHERE id = ?', JSON.stringify(vals), iid);
// Subitems: monday gives them their own columns. A value lands in the parent board's
// column with the same title and type; anything without a match is kept as a note.
const byTitle = {};
for (const info of Object.values(colInfo)) byTitle[info.title.toLowerCase()] = info;
for (const [si, s] of (it.subitems || []).entries()) {
const sid = id();
const svals = {};
const notes = [];
for (const cv of s.column_values || []) {
if (SKIP_TYPES.has(cv.type) || !cv.text) continue;
const title = cv.column?.title || cv.id;
const info = byTitle[title.toLowerCase()];
const v = info && !info.map.isFiles ? subitemValue(info, cv, ctx) : null;
if (v !== null && v !== undefined) svals[info.ourId] = v;
else notes.push(`${title}: ${cv.text}`);
}
run('INSERT INTO items(id, board_id, parent_id, name, vals, position, created_by, created_at, updated_at, external_id) VALUES(?,?,?,?,?,?,?,?,?,?)',
sid, bid, iid, s.name, JSON.stringify(svals), si + 1, ctx.userMap[s.creator_id] || null, Date.parse(s.created_at) || now(), now(), 'monday:' + s.id);
if (notes.length) {
run('INSERT INTO updates(id, item_id, author_name, body, created_at) VALUES(?,?,?,?,?)', id(), sid, 'monday.com import', `Imported values:\n${notes.join('\n')}`, now());
}
for (const a of s.assets || []) await downloadAsset(a, sid, null, null, null);
status.counts.items++;
}
// Updates and replies, oldest thread first is irrelevant — they're sorted by date on display.
for (const u of it.updates || []) {
const uid = id();
const author = u.creator?.email ? get('SELECT id FROM users WHERE email = ?', u.creator.email.toLowerCase())?.id : null;
run('INSERT INTO updates(id, item_id, user_id, author_name, body, created_at) VALUES(?,?,?,?,?,?)',
uid, iid, author, u.creator?.name || null, htmlToText(u.body), Date.parse(u.created_at) || now());
status.counts.updates = (status.counts.updates || 0) + 1;
for (const a of u.assets || []) { used.add(String(a.id)); await downloadAsset(a, iid, null, uid, author); }
for (const rep of u.replies || []) {
const ra = rep.creator?.email ? get('SELECT id FROM users WHERE email = ?', rep.creator.email.toLowerCase())?.id : null;
run('INSERT INTO updates(id, item_id, parent_id, user_id, author_name, body, created_at) VALUES(?,?,?,?,?,?,?)',
id(), iid, uid, ra, rep.creator?.name || null, htmlToText(rep.body), Date.parse(rep.created_at) || now());
}
}
// Any remaining files on the item (attached outside a file column or update).
for (const a of it.assets || []) if (!used.has(String(a.id))) await downloadAsset(a, iid, null, null, createdBy);
}
// Subitem status/dropdown indexes refer to the subitem board's labels, so match by label name.
function subitemValue(info, cv, ctx) {
const t = info.map.type;
if (t === 'status' || t === 'dropdown') {
const names = t === 'status' ? [cv.text] : cv.text.split(',').map((x) => x.trim());
const ids = names.map((n) => (info.map.settings.labels || []).find((l) => l.name.toLowerCase() === n.toLowerCase())?.id).filter(Boolean);
if (!ids.length) return null;
return t === 'status' ? ids[0] : ids;
}
const subMap = mapColumn({ type: cv.type, settings_str: null }, ctx);
if (subMap.type !== t) return null;
return subMap.convert(cv);
}
async function listBoards(token) {
const out = [];
for (let page = 1; page < 100; page++) {
const d = await gql(token, 'query($page: Int) { boards(limit: 100, page: $page, state: active) { id name type items_count workspace { name } } }', { page });
out.push(...d.boards.filter((b) => (b.type || 'board') === 'board'));
if (d.boards.length < 100) break;
}
return out.map((b) => ({ id: b.id, name: b.name, workspace: b.workspace?.name || 'Main workspace', items: b.items_count,
imported: !!get('SELECT 1 FROM boards WHERE external_id = ?', 'monday:' + b.id) }));
}
module.exports = { importAll, listBoards, status, htmlToText, mapColumn };
+85
View File
@@ -0,0 +1,85 @@
// Shared domain helpers: loading boards, value formatting, activity, notifications.
const { id, now, all, get, run } = require('./db');
const { emit } = require('./realtime');
const parse = (s, d) => { try { return s ? JSON.parse(s) : d; } catch { return d; } };
function itemOut(r) {
return {
id: r.id, board_id: r.board_id, group_id: r.group_id, parent_id: r.parent_id, name: r.name,
vals: parse(r.vals, {}), position: r.position, created_by: r.created_by,
created_at: r.created_at, updated_at: r.updated_at,
};
}
function columnOut(c) {
return { id: c.id, board_id: c.board_id, title: c.title, type: c.type, settings: parse(c.settings, {}), width: c.width, position: c.position };
}
function loadBoard(boardId) {
const board = get('SELECT * FROM boards WHERE id = ? AND archived = 0', boardId);
if (!board) return null;
const columns = all('SELECT * FROM columns WHERE board_id = ? ORDER BY position', boardId).map(columnOut);
const groups = all('SELECT * FROM groups WHERE board_id = ? ORDER BY position', boardId)
.map((g) => ({ ...g, collapsed: !!g.collapsed }));
const items = all('SELECT * FROM items WHERE board_id = ? AND archived = 0 ORDER BY position', boardId).map(itemOut);
const views = all('SELECT * FROM views WHERE board_id = ? ORDER BY position', boardId)
.map((v) => ({ ...v, settings: parse(v.settings, {}) }));
const files = all(`SELECT f.id, f.item_id, f.column_id, f.name, f.mime, f.size, f.created_at FROM files f
JOIN items i ON i.id = f.item_id WHERE i.board_id = ? AND f.update_id IS NULL`, boardId);
const counts = all(`SELECT u.item_id, COUNT(*) n FROM updates u JOIN items i ON i.id = u.item_id
WHERE i.board_id = ? GROUP BY u.item_id`, boardId);
const updateCounts = Object.fromEntries(counts.map((c) => [c.item_id, c.n]));
const automations = all('SELECT * FROM automations WHERE board_id = ? ORDER BY created_at', boardId)
.map((a) => ({ ...a, trigger: parse(a.trigger_def, {}), action: parse(a.action_def, {}), enabled: !!a.enabled }));
return { board, columns, groups, items, views, files, updateCounts, automations };
}
function userName(uid) {
return get('SELECT name FROM users WHERE id = ?', uid)?.name || 'Someone';
}
// Human-readable rendering of a stored value — used for activity, search, CSV and notifications.
function formatValue(col, v) {
if (v === undefined || v === null || v === '') return '';
const s = col.settings || {};
switch (col.type) {
case 'status': return (s.labels || []).find((l) => l.id === v)?.name || '';
case 'dropdown': return (Array.isArray(v) ? v : []).map((x) => (s.labels || []).find((l) => l.id === x)?.name).filter(Boolean).join(', ');
case 'people': return (Array.isArray(v) ? v : []).map(userName).join(', ');
case 'timeline': return v.from ? `${v.from} – ${v.to || v.from}` : '';
case 'checkbox': return v ? '✓' : '';
case 'link': return typeof v === 'object' ? (v.text || v.url || '') : String(v);
case 'tags': return (Array.isArray(v) ? v : []).join(', ');
case 'files': return Array.isArray(v) ? `${v.length} file(s)` : '';
case 'rating': return v ? '★'.repeat(v) : '';
default: return String(v);
}
}
function logActivity(boardId, itemId, userId, action, data) {
run('INSERT INTO activity(board_id, item_id, user_id, action, data, created_at) VALUES(?,?,?,?,?,?)',
boardId, itemId, userId, action, data ? JSON.stringify(data) : null, now());
}
function notify(userIds, { actorId, boardId, itemId, kind, text }) {
const seen = new Set();
for (const uid of userIds) {
if (!uid || uid === actorId || seen.has(uid)) continue;
seen.add(uid);
if (!get('SELECT 1 FROM users WHERE id = ? AND active = 1', uid)) continue;
run('INSERT INTO notifications(id, user_id, actor_id, board_id, item_id, kind, text, created_at) VALUES(?,?,?,?,?,?,?,?)',
id(), uid, actorId || null, boardId || null, itemId || null, kind, text, now());
emit({ type: 'notification' }, { userId: uid });
}
}
function emitBoard(boardId, origin) { emit({ type: 'board', boardId }, { origin }); }
function emitTree(origin) { emit({ type: 'tree' }, { origin }); }
function peopleOnItem(item, columns) {
const ids = [];
for (const c of columns) if (c.type === 'people' && Array.isArray(item.vals[c.id])) ids.push(...item.vals[c.id]);
return ids;
}
module.exports = { parse, itemOut, columnOut, loadBoard, formatValue, logActivity, notify, emitBoard, emitTree, userName, peopleOnItem };
+37
View File
@@ -0,0 +1,37 @@
// Server-sent events: every open tab holds one stream. Changes are broadcast as small
// "something changed" hints and the client refetches what it is looking at — simpler and
// more robust than shipping patches, and plenty fast for an internal team.
const clients = new Set();
function mount(app, requireUser) {
app.get('/api/events', requireUser, (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
res.write('retry: 3000\n\n');
const client = { res, userId: req.user.id, clientId: String(req.query.client || '') };
clients.add(client);
const ping = setInterval(() => res.write(': ping\n\n'), 25000);
req.on('close', () => { clearInterval(ping); clients.delete(client); });
});
}
// opts.userId limits delivery to one user; opts.origin is the clientId that caused the
// change, which the client uses to skip refetching its own edits.
function emit(event, opts = {}) {
const payload = `data: ${JSON.stringify({ ...event, origin: opts.origin || null })}\n\n`;
for (const c of clients) {
if (opts.userId && c.userId !== opts.userId) continue;
try { c.res.write(payload); } catch { /* closed */ }
}
}
function onlineUserIds() {
return [...new Set([...clients].map((c) => c.userId))];
}
module.exports = { mount, emit, onlineUserIds };
+69
View File
@@ -0,0 +1,69 @@
// Default column settings and the starter templates offered when creating a board.
const GROUP_COLORS = ['#579bfc', '#00c875', '#fdab3d', '#e2445c', '#a25ddc', '#037f4c', '#ff642e', '#66ccff', '#ff158a', '#9cd326', '#784bd1', '#cab641', '#bb3354', '#175a63', '#ff5ac4', '#7f5347'];
const LABEL_COLORS = ['#00c875', '#fdab3d', '#df2f4a', '#579bfc', '#a25ddc', '#c4c4c4', '#037f4c', '#ff642e', '#9cd326', '#ffcb00', '#bb3354', '#784bd1', '#66ccff', '#ff158a', '#7f5347', '#225091', '#175a63', '#cab641', '#ff7575', '#fdbc64'];
function statusSettings(kind = 'status') {
if (kind === 'priority') {
return { labels: [
{ id: 'p1', name: 'Critical ⚠️', color: '#333333' },
{ id: 'p2', name: 'High', color: '#401694' },
{ id: 'p3', name: 'Medium', color: '#5559df' },
{ id: 'p4', name: 'Low', color: '#579bfc' },
] };
}
return {
labels: [
{ id: 's1', name: 'Working on it', color: '#fdab3d' },
{ id: 's2', name: 'Done', color: '#00c875', done: true },
{ id: 's3', name: 'Stuck', color: '#df2f4a' },
],
};
}
function defaultSettings(type) {
switch (type) {
case 'status': return statusSettings();
case 'priority': return statusSettings('priority');
case 'dropdown': return { labels: [] };
case 'number': return { unit: '', decimals: null };
case 'rating': return { max: 5 };
default: return {};
}
}
const COLUMN_TYPES = ['text', 'long_text', 'status', 'people', 'date', 'timeline', 'number', 'checkbox',
'dropdown', 'link', 'email', 'phone', 'rating', 'files', 'tags', 'created', 'updated', 'item_id'];
const TEMPLATES = {
blank: {
label: 'Blank board',
columns: [['Owner', 'people'], ['Status', 'status'], ['Due date', 'date']],
groups: ['Group Title'],
},
project: {
label: 'Project tracker',
columns: [['Owner', 'people'], ['Status', 'status'], ['Priority', 'priority'], ['Timeline', 'timeline'], ['Due date', 'date'], ['Notes', 'text']],
groups: ['This week', 'Next week', 'Backlog'],
items: { 0: ['Kick-off meeting', 'Plan the schedule'], 2: ['Ideas'] },
},
inventory: {
label: 'Equipment inventory',
itemLabel: 'Item',
columns: [['Model', 'text'], ['Serial #', 'text'], ['Location', 'dropdown'], ['Condition', 'status'], ['Purchase date', 'date'], ['Cost', 'number'], ['Notes', 'long_text'], ['Files', 'files']],
groups: ['Audio', 'Video', 'Lighting'],
},
content: {
label: 'Content calendar',
columns: [['Owner', 'people'], ['Status', 'status'], ['Publish date', 'date'], ['Channel', 'dropdown'], ['Caption', 'long_text'], ['Files', 'files']],
groups: ['Planned', 'In progress', 'Published'],
},
requests: {
label: 'Requests',
itemLabel: 'Request',
columns: [['Requested by', 'people'], ['Assigned', 'people'], ['Status', 'status'], ['Priority', 'priority'], ['Needed by', 'date'], ['Details', 'long_text']],
groups: ['New requests', 'In progress', 'Completed'],
},
};
module.exports = { GROUP_COLORS, LABEL_COLORS, COLUMN_TYPES, TEMPLATES, defaultSettings };