Waters Boards: self-hosted work management to replace monday.com
Deploy Waters Boards to NAS / deploy (push) Canceled after 0s
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:
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
Generated
+1188
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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(() => {});
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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('/')))
|
||||
);
|
||||
});
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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}`));
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 } };
|
||||
@@ -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 };
|
||||
@@ -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(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user