// Shared UI pieces: icons, avatars, popovers, modals, menus, inline editing, formatting. import { html, useState, useEffect, useRef, useLayoutEffect } from '../vendor/preact-htm.mjs'; import { state, update } from './store.js'; // ─── Icons (24px stroke set) ───────────────────────────── const P = { home: 'M3 11l9-8 9 8M5 10v10h5v-6h4v6h5V10', work: 'M9 11l3 3 8-8M20 12v7a2 2 0 01-2 2H6a2 2 0 01-2-2V5a2 2 0 012-2h9', bell: 'M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 01-3.4 0', inbox: 'M22 12h-6l-2 3h-4l-2-3H2M5.5 5.1L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.5-6.9A2 2 0 0016.8 4H7.2a2 2 0 00-1.7 1.1z', search: 'M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.3-4.3', plus: 'M12 5v14M5 12h14', board: 'M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2zM3 9h18M9 21V9', folder: 'M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z', folderOpen: 'M6 14l1.5-2.9A2 2 0 019.2 10H20a2 2 0 011.9 2.5l-1.5 6A2 2 0 0118.5 20H4a2 2 0 01-2-2V5a2 2 0 012-2h3.9a2 2 0 011.7.9l.8 1.2a2 2 0 001.7.9H18a2 2 0 012 2v2', star: 'M12 2l3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21l1.2-6.8-5-4.9 6.9-1z', chevDown: 'M6 9l6 6 6-6', chevRight: 'M9 18l6-6-6-6', chevLeft: 'M15 18l-6-6 6-6', chevUp: 'M18 15l-6-6-6 6', dots: 'M12 13a1 1 0 100-2 1 1 0 000 2zM19 13a1 1 0 100-2 1 1 0 000 2zM5 13a1 1 0 100-2 1 1 0 000 2z', x: 'M18 6L6 18M6 6l12 12', check: 'M20 6L9 17l-5-5', trash: 'M3 6h18M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2', edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z', copy: 'M20 9h-9a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-9a2 2 0 00-2-2zM5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1', filter: 'M22 3H2l8 9.5V19l4 2v-8.5z', sort: 'M3 6h13M3 12h9M3 18h5M17 8v12M14 17l3 3 3-3', eye: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12zM12 15a3 3 0 100-6 3 3 0 000 6z', eyeOff: 'M17.9 17.9A10 10 0 0112 20c-7 0-11-8-11-8a18.5 18.5 0 015.1-5.9M9.9 4.2A9 9 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.2 3.2M1 1l22 22M14.1 14.1a3 3 0 01-4.2-4.2', person: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z', people: 'M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.9M16 3.1a4 4 0 010 7.8', group: 'M4 6h16M4 12h16M4 18h10', table: 'M3 3h18v18H3zM3 9h18M3 15h18M9 3v18', kanban: 'M4 3h4v18H4zM10 3h4v12h-4zM16 3h4v8h-4z', calendar: 'M19 4H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zM16 2v4M8 2v4M3 10h18', timeline: 'M3 6h10M7 12h12M5 18h8', chart: 'M18 20V10M12 20V4M6 20v-6', file: 'M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8zM14 2v6h6', paperclip: 'M21.4 11.1l-9.2 9.2a6 6 0 01-8.5-8.5l9.2-9.2a4 4 0 015.7 5.7l-9.2 9.2a2 2 0 01-2.8-2.8l8.5-8.5', bolt: 'M13 2L3 14h9l-1 8 10-12h-9z', gear: 'M12 15a3 3 0 100-6 3 3 0 000 6zM19.4 15a1.7 1.7 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.8-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 11-4 0v-.1a1.7 1.7 0 00-1.1-1.5 1.7 1.7 0 00-1.8.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.7 1.7 0 00.3-1.8 1.7 1.7 0 00-1.5-1H3a2 2 0 110-4h.1a1.7 1.7 0 001.5-1.1 1.7 1.7 0 00-.3-1.8l-.1-.1a2 2 0 112.8-2.8l.1.1a1.7 1.7 0 001.8.3H9a1.7 1.7 0 001-1.5V3a2 2 0 114 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.8-.3l.1-.1a2 2 0 112.8 2.8l-.1.1a1.7 1.7 0 00-.3 1.8V9a1.7 1.7 0 001.5 1H21a2 2 0 110 4h-.1a1.7 1.7 0 00-1.5 1z', menu: 'M3 12h18M3 6h18M3 18h18', logout: 'M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9', sun: 'M12 17a5 5 0 100-10 5 5 0 000 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4', moon: 'M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z', message: 'M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z', messagePlus: 'M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2zM12 7v6M9 10h6', activity: 'M22 12h-4l-3 9L9 3l-3 9H2', info: 'M12 22a10 10 0 100-20 10 10 0 000 20zM12 16v-4M12 8h.01', download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3', upload: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12', link: 'M10 13a5 5 0 007.5.5l3-3a5 5 0 00-7-7l-1.7 1.7M14 11a5 5 0 00-7.5-.5l-3 3a5 5 0 007 7l1.7-1.7', thumb: 'M7 22V11M2 13v7a2 2 0 002 2h12.4a2 2 0 002-1.7l1.4-9A2 2 0 0017.8 9H14V4a3 3 0 00-3-3l-4 9', reply: 'M9 17l-5-5 5-5M20 18v-2a4 4 0 00-4-4H4', grip: 'M9 5h.01M9 12h.01M9 19h.01M15 5h.01M15 12h.01M15 19h.01', expand: 'M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7', archive: 'M21 8v13H3V8M1 3h22v5H1zM10 12h4', move: 'M5 9l-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20', workspace: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z', shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', import: 'M12 3v12M7 10l5 5 5-5M5 21h14', subitem: 'M6 3v12a3 3 0 003 3h9M15 15l3 3-3 3', pin: 'M12 17v5M9 10.8V5h6v5.8l3 3.2H6z', refresh: 'M23 4v6h-6M1 20v-6h6M3.5 9a9 9 0 0114.8-3.4L23 10M1 14l4.7 4.4A9 9 0 0020.5 15', undo: 'M3 7v6h6M21 17a9 9 0 00-15-6.7L3 13', }; export function Icon({ name, size = 16, fill = false, class: cls = '' }) { return html``; } // ─── Avatars ───────────────────────────────────────────── const AV_COLORS = ['#579bfc', '#00c875', '#fdab3d', '#e2445c', '#a25ddc', '#037f4c', '#ff642e', '#66ccff', '#ff158a', '#784bd1', '#cab641', '#175a63']; export function initials(name = '?') { const p = name.trim().split(/\s+/); return ((p[0]?.[0] || '') + (p.length > 1 ? p[p.length - 1][0] : '')).toUpperCase() || '?'; } function hashColor(s = '') { let h = 0; for (const c of s) h = (h * 31 + c.charCodeAt(0)) | 0; return AV_COLORS[Math.abs(h) % AV_COLORS.length]; } export function Avatar({ user, size = 28, ring = false }) { if (!user) return html`<${Icon} name="person" size=${size * 0.6} />`; const online = state.online.includes(user.id); return html` ${user.avatar ? html`` : initials(user.name)} ${online && size >= 28 ? html`` : null} `; } export function AvatarStack({ ids = [], size = 26, max = 3 }) { const users = ids.map((i) => state.usersById[i]).filter(Boolean); if (!users.length) return html`<${Avatar} size=${size} />`; const shown = users.slice(0, users.length > max ? max - 1 : max); return html` ${shown.map((u) => html`<${Avatar} key=${u.id} user=${u} size=${size} />`)} ${users.length > shown.length ? html`+${users.length - shown.length}` : null} `; } // ─── Popovers & modals (rendered by in app.js) ────── export function openPopover(anchor, Component, props = {}, opts = {}) { const rect = anchor?.getBoundingClientRect ? anchor.getBoundingClientRect() : anchor; update({ popover: { rect, Component, props, opts, key: Math.random() } }); } export function closePopover() { if (state.popover) { const cb = state.popover.props?.onClose; update({ popover: null }); cb?.(); } } export function openModal(Component, props = {}, opts = {}) { update({ modal: { Component, props, opts, key: Math.random() } }); } export function closeModal() { update({ modal: null }); } export function PopoverHost() { const p = state.popover; const ref = useRef(); const [pos, setPos] = useState(null); useLayoutEffect(() => { if (!p || !ref.current) return setPos(null); const el = ref.current; const { rect, opts } = p; const w = el.offsetWidth; const h = el.offsetHeight; const vw = window.innerWidth; const vh = window.innerHeight; if (vw < 600 && !opts.inline) return setPos({ sheet: true }); let left = opts.align === 'right' ? rect.right - w : opts.align === 'center' ? rect.left + rect.width / 2 - w / 2 : rect.left; let top = rect.bottom + 4; if (top + h > vh - 8) top = Math.max(8, rect.top - h - 4); left = Math.max(8, Math.min(left, vw - w - 8)); setPos({ left, top }); }, [p?.key]); useEffect(() => { if (!p) return; const onKey = (e) => { if (e.key === 'Escape') closePopover(); }; const onDown = (e) => { if (ref.current && !ref.current.contains(e.target) && !e.target.closest?.('.modal, .popover-keep')) closePopover(); }; const onScroll = (e) => { if (!ref.current?.contains(e.target) && !p.opts.keepOnScroll) closePopover(); }; document.addEventListener('keydown', onKey); setTimeout(() => document.addEventListener('pointerdown', onDown), 0); window.addEventListener('resize', closePopover); document.addEventListener('scroll', onScroll, true); return () => { document.removeEventListener('keydown', onKey); document.removeEventListener('pointerdown', onDown); window.removeEventListener('resize', closePopover); document.removeEventListener('scroll', onScroll, true); }; }, [p?.key]); if (!p) return null; const { Component, props } = p; const sheet = pos?.sheet; return html` ${sheet ? html`
` : null}
<${Component} key=${p.key} ...${props} />
`; } export function ModalHost() { const m = state.modal; useEffect(() => { if (!m) return; const onKey = (e) => { if (e.key === 'Escape' && !state.popover) closeModal(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [m?.key]); if (!m) return null; const { Component, props, opts } = m; return html``; } export function ModalHeader({ title, sub }) { return html``; } // A list menu: [{label, icon, onClick, danger, divider, sub}] export function Menu({ items, title }) { return html`