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:
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
Reference in New Issue
Block a user