331 lines
19 KiB
JavaScript
331 lines
19 KiB
JavaScript
// 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`<svg class="icon ${cls}" width=${size} height=${size} viewBox="0 0 24 24" fill=${fill ? 'currentColor' : 'none'}
|
||
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d=${P[name] || ''} /></svg>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 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`<span class="avatar empty" style=${{ width: size, height: size }}><${Icon} name="person" size=${size * 0.6} /></span>`;
|
||
|
|
const online = state.online.includes(user.id);
|
||
|
|
return html`<span class="avatar ${ring ? 'ring' : ''}" title=${user.name} style=${{ width: size, height: size, fontSize: size * 0.4, background: user.avatar ? 'transparent' : hashColor(user.email || user.name) }}>
|
||
|
|
${user.avatar ? html`<img src=${user.avatar} alt="" referrerpolicy="no-referrer" />` : initials(user.name)}
|
||
|
|
${online && size >= 28 ? html`<i class="online-dot" />` : null}
|
||
|
|
</span>`;
|
||
|
|
}
|
||
|
|
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`<span class="avatar-stack">
|
||
|
|
${shown.map((u) => html`<${Avatar} key=${u.id} user=${u} size=${size} />`)}
|
||
|
|
${users.length > shown.length ? html`<span class="avatar more" style=${{ width: size, height: size, fontSize: size * 0.38 }}>+${users.length - shown.length}</span>` : null}
|
||
|
|
</span>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Popovers & modals (rendered by <Overlays/> 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`<div class="sheet-backdrop" />` : null}
|
||
|
|
<div ref=${ref} class="popover ${sheet ? 'sheet' : ''} ${p.opts.class || ''}" style=${sheet ? {} : { left: pos?.left ?? -9999, top: pos?.top ?? -9999, minWidth: p.opts.matchWidth ? p.rect.width : undefined }}>
|
||
|
|
<${Component} key=${p.key} ...${props} />
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
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`<div class="modal-backdrop" onPointerDown=${(e) => { if (e.target === e.currentTarget) closeModal(); }}>
|
||
|
|
<div class="modal ${opts.class || ''}" role="dialog">
|
||
|
|
<${Component} key=${m.key} ...${props} />
|
||
|
|
</div>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ModalHeader({ title, sub }) {
|
||
|
|
return html`<div class="modal-head"><div><h2>${title}</h2>${sub ? html`<p class="muted">${sub}</p>` : null}</div>
|
||
|
|
<button class="icon-btn" onClick=${closeModal} aria-label="Close"><${Icon} name="x" /></button></div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// A list menu: [{label, icon, onClick, danger, divider, sub}]
|
||
|
|
export function Menu({ items, title }) {
|
||
|
|
return html`<div class="menu">
|
||
|
|
${title ? html`<div class="menu-title">${title}</div>` : null}
|
||
|
|
${items.filter(Boolean).map((it, i) => it.divider ? html`<div key=${i} class="menu-divider" />` : html`
|
||
|
|
<button key=${i} class="menu-item ${it.danger ? 'danger' : ''} ${it.active ? 'active' : ''}" disabled=${it.disabled}
|
||
|
|
onClick=${(e) => { if (!it.keepOpen) closePopover(); it.onClick?.(e); }}>
|
||
|
|
${it.icon ? html`<${Icon} name=${it.icon} />` : html`<span class="menu-spacer" />`}
|
||
|
|
<span>${it.label}</span>
|
||
|
|
${it.hint ? html`<span class="menu-hint">${it.hint}</span>` : null}
|
||
|
|
</button>`)}
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
export function menu(e, items, opts = {}) {
|
||
|
|
e.stopPropagation(); e.preventDefault?.();
|
||
|
|
openPopover(e.currentTarget, Menu, { items }, opts);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Confirm & prompt as modals returning promises.
|
||
|
|
export function confirmDialog({ title, body, confirm = 'Delete', danger = true }) {
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
const Done = (v) => { closeModal(); resolve(v); };
|
||
|
|
openModal(() => html`<div class="dialog">
|
||
|
|
<${ModalHeader} title=${title} />
|
||
|
|
${body ? html`<p class="dialog-body">${body}</p>` : null}
|
||
|
|
<div class="modal-actions">
|
||
|
|
<button class="btn ghost" onClick=${() => Done(false)}>Cancel</button>
|
||
|
|
<button class="btn ${danger ? 'danger' : 'primary'}" onClick=${() => Done(true)} autofocus>${confirm}</button>
|
||
|
|
</div></div>`, {}, { class: 'small' });
|
||
|
|
});
|
||
|
|
}
|
||
|
|
export function promptDialog({ title, label, value = '', placeholder = '', confirm = 'Save' }) {
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
function Prompt() {
|
||
|
|
const [v, setV] = useState(value);
|
||
|
|
const submit = (e) => { e.preventDefault(); if (!v.trim()) return; closeModal(); resolve(v.trim()); };
|
||
|
|
return html`<form class="dialog" onSubmit=${submit}>
|
||
|
|
<${ModalHeader} title=${title} />
|
||
|
|
${label ? html`<label class="field-label">${label}</label>` : null}
|
||
|
|
<input class="input" value=${v} placeholder=${placeholder} onInput=${(e) => setV(e.target.value)} ref=${(el) => el && setTimeout(() => { el.focus(); el.select(); }, 0)} />
|
||
|
|
<div class="modal-actions">
|
||
|
|
<button type="button" class="btn ghost" onClick=${() => { closeModal(); resolve(null); }}>Cancel</button>
|
||
|
|
<button class="btn primary" disabled=${!v.trim()}>${confirm}</button>
|
||
|
|
</div></form>`;
|
||
|
|
}
|
||
|
|
openModal(Prompt, {}, { class: 'small' });
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Click-to-edit text. Commits on Enter/blur, cancels on Escape.
|
||
|
|
export function InlineEdit({ value, onSave, class: cls = '', placeholder = '', multiline = false, autoEdit = false, onDone, title }) {
|
||
|
|
const [editing, setEditing] = useState(autoEdit);
|
||
|
|
const [v, setV] = useState(value || '');
|
||
|
|
useEffect(() => { if (!editing) setV(value || ''); }, [value, editing]);
|
||
|
|
const commit = () => {
|
||
|
|
setEditing(false); onDone?.();
|
||
|
|
const next = v.trim();
|
||
|
|
if (next !== (value || '') && (next || placeholder === '')) onSave(next);
|
||
|
|
else setV(value || '');
|
||
|
|
};
|
||
|
|
if (!editing) {
|
||
|
|
return html`<span class="inline-edit ${cls} ${!value ? 'placeholder' : ''}" title=${title || value} onClick=${(e) => { e.stopPropagation(); setEditing(true); }}>${value || placeholder}</span>`;
|
||
|
|
}
|
||
|
|
const Tag = multiline ? 'textarea' : 'input';
|
||
|
|
return html`<${Tag} class="inline-input ${cls}" value=${v} placeholder=${placeholder}
|
||
|
|
ref=${(el) => { if (el && !el.dataset.f) { el.dataset.f = 1; el.focus(); el.select?.(); } }}
|
||
|
|
onClick=${(e) => e.stopPropagation()}
|
||
|
|
onInput=${(e) => setV(e.target.value)} onBlur=${commit}
|
||
|
|
onKeyDown=${(e) => {
|
||
|
|
if (e.key === 'Enter' && (!multiline || !e.shiftKey)) { e.preventDefault(); e.target.blur(); }
|
||
|
|
if (e.key === 'Escape') { setV(value || ''); setEditing(false); onDone?.(); }
|
||
|
|
}} />`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Formatting ──────────────────────────────────────────
|
||
|
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||
|
|
export function parseDate(s) {
|
||
|
|
if (!s) return null;
|
||
|
|
const [d, t] = String(s).split('T');
|
||
|
|
const [y, m, day] = d.split('-').map(Number);
|
||
|
|
if (!y || !m || !day) return null;
|
||
|
|
const dt = new Date(y, m - 1, day);
|
||
|
|
if (t) { const [hh, mm] = t.split(':').map(Number); dt.setHours(hh || 0, mm || 0); }
|
||
|
|
return dt;
|
||
|
|
}
|
||
|
|
export function ymd(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
|
||
|
|
export function todayStr() { return ymd(new Date()); }
|
||
|
|
export function fmtDate(s, { year } = {}) {
|
||
|
|
const d = parseDate(s);
|
||
|
|
if (!d) return '';
|
||
|
|
const showYear = year ?? d.getFullYear() !== new Date().getFullYear();
|
||
|
|
let out = `${MONTHS[d.getMonth()]} ${d.getDate()}${showYear ? `, ${d.getFullYear()}` : ''}`;
|
||
|
|
if (String(s).includes('T')) out += `, ${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
export function fmtRange(v) {
|
||
|
|
if (!v?.from) return '';
|
||
|
|
const a = parseDate(v.from); const b = parseDate(v.to || v.from);
|
||
|
|
if (!a || !b) return '';
|
||
|
|
const sameYear = a.getFullYear() === b.getFullYear() && a.getFullYear() === new Date().getFullYear();
|
||
|
|
if (v.from === v.to) return fmtDate(v.from);
|
||
|
|
if (a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear()) return `${MONTHS[a.getMonth()]} ${a.getDate()} - ${b.getDate()}${sameYear ? '' : `, ${a.getFullYear()}`}`;
|
||
|
|
return `${MONTHS[a.getMonth()]} ${a.getDate()} - ${MONTHS[b.getMonth()]} ${b.getDate()}${sameYear ? '' : `, ${b.getFullYear()}`}`;
|
||
|
|
}
|
||
|
|
export function timeAgo(ts) {
|
||
|
|
const s = Math.round((Date.now() - ts) / 1000);
|
||
|
|
if (s < 60) return 'just now';
|
||
|
|
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
||
|
|
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
||
|
|
if (s < 86400 * 7) return `${Math.floor(s / 86400)}d`;
|
||
|
|
return fmtDate(ymd(new Date(ts)));
|
||
|
|
}
|
||
|
|
export function fmtSize(n) {
|
||
|
|
if (!n) return '';
|
||
|
|
if (n < 1024) return `${n} B`;
|
||
|
|
if (n < 1024 ** 2) return `${(n / 1024).toFixed(0)} KB`;
|
||
|
|
return `${(n / 1024 ** 2).toFixed(1)} MB`;
|
||
|
|
}
|
||
|
|
export function fileUrl(f, download) { return `/files/${f.id}/${encodeURIComponent(f.name)}${download ? '?download' : ''}`; }
|
||
|
|
export function isImage(f) { return /^image\//.test(f.mime || '') || /\.(png|jpe?g|gif|webp|svg)$/i.test(f.name); }
|
||
|
|
|
||
|
|
// Readable text on a colored background.
|
||
|
|
export function textOn(hex) {
|
||
|
|
const c = String(hex || '').replace('#', '');
|
||
|
|
if (c.length < 6) return '#fff';
|
||
|
|
const [r, g, b] = [0, 2, 4].map((i) => parseInt(c.slice(i, i + 2), 16));
|
||
|
|
return (r * 299 + g * 587 + b * 114) / 1000 > 170 ? '#323338' : '#fff';
|
||
|
|
}
|
||
|
|
|
||
|
|
export const isMobile = () => window.matchMedia('(max-width: 768px)').matches;
|
||
|
|
export function useMobile() {
|
||
|
|
const [m, setM] = useState(isMobile());
|
||
|
|
useEffect(() => {
|
||
|
|
const mq = window.matchMedia('(max-width: 768px)');
|
||
|
|
const fn = () => setM(mq.matches);
|
||
|
|
mq.addEventListener('change', fn);
|
||
|
|
return () => mq.removeEventListener('change', fn);
|
||
|
|
}, []);
|
||
|
|
return m;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Render text with links and @mentions highlighted.
|
||
|
|
export function RichText({ text }) {
|
||
|
|
const parts = String(text || '').split(/(https?:\/\/[^\s<>()]+|@\[[^\]]+\]\([\w-]+\))/g);
|
||
|
|
return parts.map((p, i) => {
|
||
|
|
if (/^https?:\/\//.test(p)) return html`<a key=${i} href=${p} target="_blank" rel="noopener">${p}</a>`;
|
||
|
|
const m = p.match(/^@\[([^\]]+)\]\(([\w-]+)\)$/);
|
||
|
|
if (m) return html`<span key=${i} class="mention">@${m[1]}</span>`;
|
||
|
|
return p;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export const GROUP_COLORS = ['#579bfc', '#00c875', '#fdab3d', '#e2445c', '#a25ddc', '#037f4c', '#ff642e', '#66ccff', '#ff158a', '#9cd326', '#784bd1', '#cab641', '#bb3354', '#175a63', '#ff5ac4', '#7f5347', '#c4c4c4', '#333333'];
|
||
|
|
export const LABEL_COLORS = ['#00c875', '#fdab3d', '#df2f4a', '#579bfc', '#a25ddc', '#c4c4c4', '#037f4c', '#ff642e', '#9cd326', '#ffcb00', '#bb3354', '#784bd1', '#66ccff', '#ff158a', '#7f5347', '#225091', '#175a63', '#cab641', '#ff7575', '#fdbc64', '#333333', '#401694', '#5559df', '#68a1bd'];
|
||
|
|
|
||
|
|
export function ColorPicker({ colors = GROUP_COLORS, value, onPick }) {
|
||
|
|
return html`<div class="color-grid">${colors.map((c) => html`
|
||
|
|
<button key=${c} class="swatch ${c === value ? 'on' : ''}" style=${{ background: c }} onClick=${() => { onPick(c); closePopover(); }} aria-label=${c} />`)}</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function Spinner() { return html`<div class="spinner" aria-label="Loading" />`; }
|
||
|
|
export function Empty({ icon = 'board', title, children }) {
|
||
|
|
return html`<div class="empty-state"><div class="empty-icon"><${Icon} name=${icon} size=${36} /></div><h3>${title}</h3>${children}</div>`;
|
||
|
|
}
|