389 lines
26 KiB
JavaScript
389 lines
26 KiB
JavaScript
// 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 };
|