// 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`
`; } // ─── 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``; 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]); }}>