Files

389 lines
26 KiB
JavaScript
Raw Permalink Normal View History

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