Files
2026-05-21 15:49:36 +10:00

197 lines
8.1 KiB
React

// Shared primitives for Dashy
const USERS = [
{ id: 'lani', name: 'Lani', role: 'Front of house', hue: 28, initials: 'LA' },
{ id: 'kirra', name: 'Kirra', role: 'Workshop lead', hue: 145, initials: 'KI' },
{ id: 'ayron', name: 'Ayron', role: 'Tech', hue: 215, initials: 'AY' },
{ id: 'rod', name: 'ROD', role: 'Owner', hue: 280, initials: 'RO' },
];
const PRIORITY = {
high: { label: 'High', color: 'var(--prio-high)', dot: 'var(--prio-high)' },
med: { label: 'Med', color: 'var(--prio-med)', dot: 'var(--prio-med)' },
low: { label: 'Low', color: 'var(--prio-low)', dot: 'var(--prio-low)' },
};
const SOURCES = {
manual: { label: 'Manual', glyph: '·' },
imessage: { label: 'iMessage', glyph: '✦' },
email: { label: 'Email', glyph: '✉' },
automation:{ label: 'Auto', glyph: '⚙' },
};
function Avatar({ user, size = 28, ring = false }) {
if (!user) return null;
const s = { width: size, height: size, fontSize: Math.round(size * 0.42) };
if (user.photo) {
return (
<span
className={"avatar avatar--photo" + (ring ? " avatar--ring" : "")}
style={{
...s,
backgroundImage: `url(${user.photo})`,
boxShadow: ring ? `0 0 0 2px var(--bg), 0 0 0 3px oklch(70% 0.10 ${user.hue})` : undefined,
}}
title={user.name}
/>
);
}
return (
<span
className={"avatar" + (ring ? " avatar--ring" : "")}
style={{
...s,
background: `oklch(94% 0.04 ${user.hue})`,
color: `oklch(32% 0.10 ${user.hue})`,
boxShadow: ring ? `0 0 0 2px var(--bg), 0 0 0 3px oklch(70% 0.10 ${user.hue})` : undefined,
}}
title={user.name}
>
{user.initials}
</span>
);
}
function PriorityDot({ priority, withLabel = false }) {
const p = PRIORITY[priority];
if (!p) return null;
return (
<span className="prio">
<span className="prio__dot" style={{ background: p.dot }} />
{withLabel && <span className="prio__label">{p.label}</span>}
</span>
);
}
function SourceTag({ source }) {
const s = SOURCES[source];
if (!s || source === 'manual') return null;
return (
<span className="source-tag" data-source={source}>
<span className="source-tag__glyph">{s.glyph}</span>
{s.label}
</span>
);
}
function getSafeTimezone() {
const tz = window.workspace ? window.workspace.timezone : undefined;
if (!tz) return undefined;
try {
Intl.DateTimeFormat(undefined, { timeZone: tz });
return tz;
} catch (e) {
return undefined; // Fallback to browser time if invalid
}
}
function relTime(iso) {
if (typeof iso === 'string' && !iso.endsWith('Z') && !iso.includes('+')) iso += 'Z';
const now = new Date();
const then = new Date(iso);
const diff = (now - then) / 1000;
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
if (diff < 86400*7) return Math.floor(diff/86400) + 'd ago';
return then.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: getSafeTimezone() });
}
function fmtDateTime(iso) {
if (typeof iso === 'string' && !iso.endsWith('Z') && !iso.includes('+')) iso += 'Z';
const d = new Date(iso);
const tz = getSafeTimezone();
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: tz }) +
' · ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: tz });
}
function findUser(id) {
const live = window.dbUsers;
if (live) return live.find(u => u.id === id);
return USERS.find(u => u.id === id);
}
function TaskCard({ task, onOpen, density = 'cozy', dragging = false, onDragStart, onDragEnd, onDragOver }) {
const author = findUser(task.addedBy);
const isAuto = task.status === 'unsuccessful' || task.status === 'billing';
return (
<article
className={"card" + (dragging ? " card--drag" : "") + (isAuto ? " card--flagged" : "")}
style={dragging ? { opacity: 0, pointerEvents: 'none' } : {}}
data-density={density}
data-priority={task.priority}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/dashy-task', task.id);
onDragStart && onDragStart(task);
}}
onDragEnd={() => onDragEnd && onDragEnd()}
onDragOver={onDragOver}
onClick={() => onOpen && onOpen(task)}
data-comment-anchor={"task-" + task.id}
>
<span className="card__grip" aria-hidden="true">
<span /><span /><span /><span /><span /><span />
</span>
<header className="card__head">
<h3 className="card__title">{task.title}</h3>
<PriorityDot priority={task.priority} />
</header>
{task.description && (
<p className="card__desc">{task.description}</p>
)}
{task.tags && task.tags.length > 0 && (
<div className="card__tags">
{task.tags.map(t => <span key={t} className="chip">{t}</span>)}
{task.source && task.source !== 'manual' && <SourceTag source={task.source} />}
</div>
)}
{isAuto && (
<div className="card__alert">
{task.status === 'unsuccessful'
? 'Auto-marked unsuccessful — needs review'
: 'Switched to billing form'}
</div>
)}
<footer className="card__foot">
<span className="card__author">
<Avatar user={author} size={20} />
<span>{author && author.name}</span>
</span>
<span className="card__time">{relTime(task.addedAt)}</span>
</footer>
</article>
);
}
function IconBtn({ children, onClick, label, variant = 'ghost' }) {
return (
<button className={"icon-btn icon-btn--" + variant} onClick={onClick} aria-label={label} title={label}>
{children}
</button>
);
}
// Tiny inline icons
const Icon = {
Plus: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M8 3v10M3 8h10" strokeLinecap="round"/></svg>,
Bell: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M4 7a4 4 0 1 1 8 0v3l1 2H3l1-2V7Z" strokeLinejoin="round"/><path d="M6.5 13a1.5 1.5 0 0 0 3 0"/></svg>,
Search: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3" strokeLinecap="round"/></svg>,
Logs: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M3 4h10M3 8h10M3 12h6" strokeLinecap="round"/></svg>,
Close: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" strokeLinecap="round"/></svg>,
Check: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
Arrow: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M3 8h10m-4-4 4 4-4 4" strokeLinecap="round" strokeLinejoin="round"/></svg>,
Refresh: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M13.6 2.4A7.8 7.8 0 1 0 14.8 9m-1.2-6.6v4.2h-4.2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
Dot: () => <svg viewBox="0 0 16 16" width="14" height="14"><circle cx="8" cy="8" r="2.5" fill="currentColor"/></svg>,
Pin: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M10 2 6 6H3l4 4-3 4 4-3 4 4v-3l4-4-2-2-2-4Z" strokeLinejoin="round"/></svg>,
iMessage: () => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M2.5 7.5c0-2.8 2.5-5 5.5-5s5.5 2.2 5.5 5-2.5 5-5.5 5c-.6 0-1.2-.1-1.7-.2L3 13.5l.7-2.5c-.7-.9-1.2-1.9-1.2-3Z" strokeLinejoin="round"/></svg>,
};
Object.assign(window, {
USERS, PRIORITY, SOURCES,
Avatar, PriorityDot, SourceTag, TaskCard, IconBtn, Icon,
relTime, fmtDateTime, findUser,
});