// 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``;
}
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`
<${Icon} name="x" size=${20} />
${board.board.name}
${group ? html`<${Icon} name="chevRight" size=${12} />${group.title} ` : null}
${parent ? html`<${Icon} name="chevRight" size=${12} /> navigate(boardPath(boardId, { viewId, itemId: parent.id }))}>${parent.name} ` : null}
<${Icon} name="dots" size=${20} />
<${InlineEdit} value=${item.name} onSave=${(n) => n && A.renameItem(boardId, item.id, n)} />
setTab('updates')}>Updates${nUpdates ? ` / ${nUpdates}` : ''}
setTab('info')}>Info${subs.length ? ` / ${subs.length}` : ''}
setTab('files')}>Files${nFiles ? ` / ${nFiles}` : ''}
setTab('activity')}>Activity
${!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} />`}
`;
}
// ─── 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`
<${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`
<${Icon} name="message" size=${40} />
No updates yet
Share progress, mention a teammate with @, or attach files.
` : null}
`;
}
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` setOpen(true)}>Write a reply… `;
const sugg = suggest !== null ? state.users.filter((u) => u.active && u.name.toLowerCase().includes(suggest.toLowerCase())).slice(0, 6) : [];
return html` e.preventDefault()} onDrop=${(e) => { e.preventDefault(); setFiles([...files, ...e.dataTransfer.files]); }}>
`;
}
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`
${editing ? html`<${EditUpdate} u=${u} done=${() => { setEditing(false); reload(); }} />`
: html`<${RichText} text=${u.body} />
`}
${u.files?.length ? html`` : null}
{ await api.post(`/api/updates/${u.id}/like`); reload(); }}>
<${Icon} name="thumb" /> ${liked ? 'Liked' : 'Like'}${u.likes?.length ? ` · ${u.likes.length}` : ''}
${!isReply ? html` setReplying(true)}><${Icon} name="reply" /> Reply ` : null}
${replies.length || replying ? html`
${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}
` : null}
`;
}
function EditUpdate({ u, done }) {
const [v, setV] = useState(u.body);
return html``;
}
// ─── 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`
${board.columns.map((c) => html`
<${Icon} name=${COLUMN_TYPES[c.type]?.icon || 'edit'} size=${11} /> ${c.title}
<${Cell} board=${board} col=${c} item=${item} />
`)}
${!item.parent_id ? html`
Subitems ${subs.length}
${subs.map((s) => {
const status = board.columns.find((c) => c.type === 'status');
return html`
navigate(boardPath(boardId, { viewId, itemId: s.id }))}>${s.name}
${status ? html`
<${Cell} board=${board} col=${status} item=${s} />
` : null}
`;
})}
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 }); } }} />
` : null}
`;
}
// ─── 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` e.preventDefault()} onDrop=${(e) => { e.preventDefault(); upload(e.dataTransfer.files); }}>
input.current.click()}><${Icon} name="upload" size=${24} />${busy ? 'Uploading…' : 'Drag files here or click to upload'}
upload(e.target.files)} />
${detail.files.map((f) => html`
`)}
`;
}
// ─── 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 ${d.from} to ${d.to} `;
case 'moved_group': return html`moved to group ${d.to} `;
case 'moved_board': return 'moved from another board';
case 'value': return html`${d.column} ${d.from ? html`${d.from} → ` : ''}${d.to || '(cleared)'} `;
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`
${detail.activity.map((a) => html`
${timeAgo(a.created_at)}
<${Avatar} user=${state.usersById[a.user_id] || (a.user_id ? null : { name: 'Automation', email: 'auto' })} size=${24} />
${state.usersById[a.user_id]?.name || 'Automation'}
${activityText(a)}
`)}
${!detail.activity.length ? html`
No activity yet.
` : null}
`;
}