175 lines
7.2 KiB
React
175 lines
7.2 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 relTime(iso) {
|
|
const now = new Date('2026-05-08T10:30:00');
|
|
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' });
|
|
}
|
|
|
|
function fmtDateTime(iso) {
|
|
const d = new Date(iso);
|
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
|
|
' · ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
}
|
|
|
|
function findUser(id) { return USERS.find(u => u.id === id); }
|
|
|
|
function TaskCard({ task, onOpen, density = 'cozy', dragging = false, onDragStart, onDragEnd }) {
|
|
const author = findUser(task.addedBy);
|
|
const isAuto = task.status === 'unsuccessful' || task.status === 'billing';
|
|
return (
|
|
<article
|
|
className={"card" + (dragging ? " card--drag" : "") + (isAuto ? " card--flagged" : "")}
|
|
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()}
|
|
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>,
|
|
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,
|
|
});
|