273 lines
18 KiB
JavaScript
273 lines
18 KiB
JavaScript
// 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>`;
|
||
|
|
}
|