- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Boardly — Website Relaunch 1.0.0, shown as text in the browser.
Source is browser-viewable only. There is no download or archive endpoint, and no additional license is granted beyond inspection and research.
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { initials, labelById, memberById } from "./data";
/* ---------- Wordmark ---------- */
export function Wordmark() {
return (
<a className="wordmark" href="#" onClick={(e) => e.preventDefault()} aria-label="Boardly home">
<svg className="wordmark-mark" viewBox="0 0 28 28" width="28" height="28" aria-hidden="true">
<rect x="1" y="1" width="26" height="26" rx="7" fill="var(--accent)" />
<rect x="6.5" y="7" width="4.5" height="14" rx="1.5" fill="#fff" opacity="0.95" />
<rect x="12.75" y="7" width="4.5" height="9" rx="1.5" fill="#fff" opacity="0.75" />
<rect x="19" y="7" width="4.5" height="5.5" rx="1.5" fill="#fff" opacity="0.55" />
</svg>
<span className="wordmark-text">Boardly</span>
</a>
);
}
/* ---------- Icons (all inline, 16px grid) ---------- */
type IconProps = { size?: number };
const base = (size: number) => ({
width: size,
height: size,
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.6,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
focusable: false,
});
export const Icon = {
Search: ({ size = 16 }: IconProps) => (
<svg {...base(size)}>
<circle cx="7" cy="7" r="4.5" />
<path d="M10.5 10.5 14 14" />
</svg>
),
Close: ({ size = 16 }: IconProps) => (
<svg {...base(size)}>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
),
Plus: ({ size = 16 }: IconProps) => (
<svg {...base(size)}>
<path d="M8 3v10M3 8h10" />
</svg>
),
Move: ({ size = 16 }: IconProps) => (
<svg {...base(size)}>
<path d="M2.5 8h11M10.5 5l3 3-3 3M5.5 5l-3 3 3 3" />
</svg>
),
Undo: ({ size = 16 }: IconProps) => (
<svg {...base(size)}>
<path d="M6 4 3 7l3 3" />
<path d="M3 7h6.5a3.5 3.5 0 0 1 0 7H7" />
</svg>
),
Reset: ({ size = 16 }: IconProps) => (
<svg {...base(size)}>
<path d="M13 8A5 5 0 1 1 8 3c1.9 0 3.4.9 4.2 2.2" />
<path d="M12.5 2.5v3h-3" />
</svg>
),
Calendar: ({ size = 16 }: IconProps) => (
<svg {...base(size)}>
<rect x="2.5" y="3.5" width="11" height="10" rx="1.5" />
<path d="M2.5 7h11M5.5 2v3M10.5 2v3" />
</svg>
),
Check: ({ size = 16 }: IconProps) => (
<svg {...base(size)}>
<path d="M3 8.5 6.5 12 13 4.5" />
</svg>
),
Checklist: ({ size = 16 }: IconProps) => (
<svg {...base(size)}>
<path d="M2.5 4.5 3.7 5.7 6 3.2M8 4.5h5.5M2.5 10.5l1.2 1.2L6 9.2M8 10.5h5.5" />
</svg>
),
Alert: ({ size = 16 }: IconProps) => (
<svg {...base(size)}>
<path d="M8 2.5 14 13H2z" />
<path d="M8 6.5v3M8 11.3v.2" />
</svg>
),
Trash: ({ size = 16 }: IconProps) => (
<svg {...base(size)}>
<path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" />
</svg>
),
Grip: ({ size = 16 }: IconProps) => (
<svg {...base(size)} strokeWidth={0} fill="currentColor">
<circle cx="6" cy="4" r="1.2" />
<circle cx="10" cy="4" r="1.2" />
<circle cx="6" cy="8" r="1.2" />
<circle cx="10" cy="8" r="1.2" />
<circle cx="6" cy="12" r="1.2" />
<circle cx="10" cy="12" r="1.2" />
</svg>
),
Dots: ({ size = 16 }: IconProps) => (
<svg {...base(size)} strokeWidth={0} fill="currentColor">
<circle cx="3.5" cy="8" r="1.4" />
<circle cx="8" cy="8" r="1.4" />
<circle cx="12.5" cy="8" r="1.4" />
</svg>
),
Info: ({ size = 16 }: IconProps) => (
<svg {...base(size)}>
<circle cx="8" cy="8" r="6" />
<path d="M8 7.5V11M8 5.2v.2" />
</svg>
),
Flag: ({ size = 16 }: IconProps) => (
<svg {...base(size)}>
<path d="M3.5 14V2.5h8.5l-2 3 2 3H3.5" />
</svg>
),
};
/* ---------- Avatar ---------- */
export function Avatar({ memberId, size = 24 }: { memberId: string | null; size?: number }) {
if (!memberId || !memberById[memberId]) {
return (
<span className="avatar avatar-empty" style={{ width: size, height: size }} aria-hidden="true">
<svg viewBox="0 0 16 16" width={size * 0.6} height={size * 0.6} fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="8" cy="5.5" r="2.8" />
<path d="M2.8 14a5.2 5.2 0 0 1 10.4 0" />
</svg>
</span>
);
}
const m = memberById[memberId];
return (
<span
className={`avatar avatar-${memberId}`}
style={{ width: size, height: size, fontSize: Math.round(size * 0.42) }}
aria-hidden="true"
>
{initials(m.name)}
</span>
);
}
/* ---------- Label chip ---------- */
export function LabelChip({ id, muted = false }: { id: string; muted?: boolean }) {
const label = labelById[id];
if (!label) return null;
return (
<span className={`label label-${id}${muted ? " label-muted" : ""}`}>
<span className="label-dot" aria-hidden="true" />
{label.name}
</span>
);
}
/* ---------- Menu ---------- */
export interface MenuItem {
id: string;
label: ReactNode;
disabled?: boolean;
hint?: string;
danger?: boolean;
onSelect: () => void;
}
interface MenuProps {
label: string; // accessible name of the trigger
icon: ReactNode;
items: MenuItem[];
heading?: string;
className?: string;
align?: "left" | "right";
compactText?: string;
}
/** Small popover menu: button trigger, arrow-key navigation, Escape to close.
* The list is portaled to <body> with fixed positioning so scroll containers never clip it. */
export function Menu({ label, icon, items, heading, className = "", align = "right", compactText }: MenuProps) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top: number; left?: number; right?: number }>({ top: 0 });
const rootRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const id = useId();
const place = () => {
const r = btnRef.current?.getBoundingClientRect();
if (!r) return;
const menuH = listRef.current?.offsetHeight ?? 0;
const below = r.bottom + 4;
const top = menuH && below + menuH > window.innerHeight - 8 ? Math.max(8, r.top - 4 - menuH) : below;
if (align === "right") setPos({ top, right: Math.max(8, window.innerWidth - r.right) });
else setPos({ top, left: Math.max(8, r.left) });
};
useEffect(() => {
if (!open) return;
place();
const onDown = (e: PointerEvent) => {
const t = e.target as Node;
if (!rootRef.current?.contains(t) && !listRef.current?.contains(t)) setOpen(false);
};
const onScroll = (e: Event) => {
if (listRef.current?.contains(e.target as Node)) return;
setOpen(false);
};
document.addEventListener("pointerdown", onDown);
document.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onScroll);
const first = listRef.current?.querySelector<HTMLElement>("[role=menuitem]:not([aria-disabled=true])");
first?.focus();
return () => {
document.removeEventListener("pointerdown", onDown);
document.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onScroll);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const close = (refocus = true) => {
setOpen(false);
if (refocus) btnRef.current?.focus();
};
const onKeyDown = (e: React.KeyboardEvent) => {
const els = Array.from(
listRef.current?.querySelectorAll<HTMLElement>("[role=menuitem]:not([aria-disabled=true])") ?? [],
);
const i = els.indexOf(document.activeElement as HTMLElement);
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
e.stopPropagation();
els[(i + 1) % els.length]?.focus();
} else if (e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
els[(i - 1 + els.length) % els.length]?.focus();
} else if (e.key === "Home") {
e.preventDefault();
els[0]?.focus();
} else if (e.key === "End") {
e.preventDefault();
els[els.length - 1]?.focus();
} else if (e.key === "Tab") {
close(false);
}
};
return (
<div className={`menu ${className}`} ref={rootRef} onKeyDown={onKeyDown}>
<button
ref={btnRef}
type="button"
className={`menu-trigger${compactText ? " menu-trigger-text" : ""}`}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? id : undefined}
onClick={(e) => {
e.stopPropagation();
setOpen((o) => !o);
}}
onPointerDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "ArrowDown" && !open) {
e.preventDefault();
e.stopPropagation();
setOpen(true);
}
e.stopPropagation();
}}
>
{icon}
{compactText && <span>{compactText}</span>}
</button>
{open &&
createPortal(
<div
className="menu-list"
role="menu"
id={id}
aria-label={label}
ref={listRef}
style={{ top: pos.top, left: pos.left, right: pos.right }}
onPointerDown={(e) => e.stopPropagation()}
>
{heading && (
<div className="menu-heading" aria-hidden="true">
{heading}
</div>
)}
{items.map((it) => (
<button
key={it.id}
type="button"
role="menuitem"
className={`menu-item${it.danger ? " menu-item-danger" : ""}`}
aria-disabled={it.disabled || undefined}
title={it.hint}
onClick={(e) => {
e.stopPropagation();
if (it.disabled) return;
close();
it.onSelect();
}}
>
<span className="menu-item-label">{it.label}</span>
{it.hint && <span className="menu-item-hint">{it.hint}</span>}
</button>
))}
</div>,
document.body,
)}
</div>
);
}
/* ---------- Visually hidden ---------- */
export function VisuallyHidden({ children, id }: { children: ReactNode; id?: string }) {
return (
<span className="sr-only" id={id}>
{children}
</span>
);
}