Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 4ce232ee856d7de1ba5f2b6d
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Editor.tsx
// A page's private editor (/e/<handle>#k=<key>): profile, links, social icons and look on the left,
// a live phone preview on the right, views and clicks on top. Changes save together with Save.
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { set, useQuery } from "@bounded-sh/client";
import { explain } from "./lib/bounded";
import { claimPage, savePage, savedKey } from "./lib/mine";
import { shrinkPhoto } from "./lib/photo";
import { Dialog, copy, toast } from "./ui";
import { HOST, ICONS, THEME_BG, pageHtml } from "./page";
import { qrMatrix, qrSvg, type QrMatrix } from "../functions/lib/qr";
import {
BUTTON_STYLES, MAX_BIO, MAX_LINKS, MAX_NAME, MAX_TITLE, SOCIALS, THEMES,
checkBio, checkName, checkSocial, checkUrl, editUrl, fromDoc, isAvatar, pageUrl, randomLinkId, toDoc,
type ButtonStyle, type Link, type Page, type PageDoc, type SocialKind, type Socials, type Theme,
} from "../functions/lib/bio";
type Opened = { state: "opening" } | { state: "ready"; doc: PageDoc; key: string } | { state: "nokey" | "badkey" | "missing" } | { state: "error"; message: string };
export function EditPage({ handle }: { handle: string }) {
const [opened, setOpened] = useState<Opened>({ state: "opening" });
const [attempt, setAttempt] = useState(0);
useEffect(() => { document.title = `${HOST}/${handle} | Bio`; }, [handle]);
useEffect(() => {
const key = new URLSearchParams(location.hash.slice(1)).get("k") || savedKey(handle) || "";
if (!key) { setOpened({ state: "nokey" }); return; }
let live = true;
setOpened({ state: "opening" });
claimPage(handle, key).then(
(result) => {
if (!live) return;
if (typeof result === "string") { setOpened({ state: result }); return; }
savePage(handle, key);
setOpened({ state: "ready", doc: result, key });
},
(err) => { if (live) setOpened({ state: "error", message: explain(err) }); },
);
return () => { live = false; };
}, [handle, attempt]);
if (opened.state === "opening") return <Shell handle={handle} />;
if (opened.state === "ready") return <Editor handle={handle} ownerKey={opened.key} initial={opened.doc} />;
if (opened.state === "error") {
return (
<div className="wrap page-pad narrow">
<div className="error-state">
<h1>This page did not open</h1>
<p>{opened.message}</p>
<button className="btn" type="button" onClick={() => setAttempt((n) => n + 1)}>Try again</button>
</div>
</div>
);
}
const copyFor = {
nokey: ["This edit link is missing its key", "Open the full edit link you saved when you made the page. It ends with #k= and a long code."],
badkey: ["This edit link does not work", "The edit link may have been cut short. Check that you copied all of it."],
missing: ["There is no page here yet", `${HOST}/${handle} is free. You can claim it now.`],
}[opened.state];
return (
<div className="wrap page-pad narrow">
<div className="empty-state">
<img className="art" src="/art/card-bio.webp" alt="" width="180" height="157" />
<h1>{copyFor[0]}</h1>
<p>{copyFor[1]}</p>
<a className="btn" href={opened.state === "missing" ? `/?claim=${handle}` : "/"}>{opened.state === "missing" ? "Claim it" : "Make a page"}</a>
</div>
</div>
);
}
/** The editor's frame while the page opens: the same layout with placeholders, so nothing jumps when it arrives. */
function Shell({ handle }: { handle: string }) {
return (
<div className="editor tab-edit" aria-busy="true">
<Head handle={handle} />
<Tabs tab="edit" />
<div className="wrap ed-body">
<div className="ed-form">
{[3, 4].map((lines) => (
<div key={lines} className="card ed-card">
<span className="skeleton skeleton-text" style={{ width: 90 }} />
{Array.from({ length: lines }, (_, i) => <span key={i} className="skeleton" style={{ height: 46 }} />)}
</div>
))}
</div>
<aside className="ed-preview" aria-hidden="true">
<div className="phone"><div className="phone-screen skeleton" /></div>
</aside>
</div>
</div>
);
}
function Head({ handle, views, clicks, onShare }: { handle: string; views?: number; clicks?: number | null; onShare?: () => void }) {
const url = pageUrl(handle);
const stat = (n: number | null | undefined) => (n == null ? <span className="skeleton ed-stat-wait" /> : n.toLocaleString());
return (
<header className="ed-head">
<div className="wrap ed-head-inner">
<div className="ed-head-main">
<p className="eyebrow">Your page</p>
<h1 className="ed-title"><a href={url} target="_blank" rel="noopener"><span className="ed-host">{HOST}/</span>{handle}</a></h1>
</div>
<dl className="ed-stats">
<div><dt>Views</dt><dd>{stat(views)}</dd></div>
<div><dt>Link clicks</dt><dd>{stat(clicks)}</dd></div>
</dl>
<div className="ed-head-actions">
<button className="btn" type="button" onClick={onShare} disabled={!onShare}>Share</button>
<a className="btn btn-line" href={url} target="_blank" rel="noopener">View page</a>
</div>
</div>
</header>
);
}
function Tabs({ tab, onTab }: { tab: "edit" | "preview"; onTab?: (t: "edit" | "preview") => void }) {
return (
<div className="ed-tabs">
<div className="wrap">
<div className="seg" role="tablist" aria-label="Editor view">
{(["edit", "preview"] as const).map((t) => (
<button key={t} type="button" role="tab" aria-selected={tab === t} onClick={() => onTab?.(t)}>{t === "edit" ? "Edit" : "Preview"}</button>
))}
</div>
</div>
</div>
);
}
/** What the form holds while editing: social icons as typed, only the ones on the page or just added. */
interface Draft extends Omit<Page, "socials"> { socials: Partial<Record<SocialKind, string>> }
interface Problems { name?: string; bio?: string; links: Record<string, string>; socials: Partial<Record<SocialKind, string>> }
/** Shows a stored social the short way people type it: "@maya" rather than "https://x.com/maya". */
function socialText(kind: SocialKind, value: string): string {
const def = SOCIALS.find((s) => s.kind === kind)!;
if (!def.base || !value.startsWith(def.base)) return value;
return (/^[@+]/.test(def.placeholder) ? def.placeholder[0] : "") + value.slice(def.base.length);
}
const toDraft = (p: Page): Draft => ({
...p,
socials: Object.fromEntries(SOCIALS.filter(({ kind }) => p.socials[kind]).map(({ kind }) => [kind, socialText(kind, p.socials[kind]!)])),
});
/** Turns the form into a page, collecting what needs fixing. */
function check(d: Draft): { page: Page; problems: Problems; ok: boolean } {
const problems: Problems = { links: {}, socials: {} };
const name = checkName(d.name);
if ("error" in name) problems.name = name.error;
const bio = checkBio(d.bio);
if ("error" in bio) problems.bio = bio.error;
const links: Link[] = d.links.map((l) => {
const title = l.title.trim();
if (l.header) {
if (!title) problems.links[l.id] = "Add the header text.";
return { ...l, title };
}
const u = checkUrl(l.url);
if ("error" in u) { problems.links[l.id] = u.error; return { ...l, title }; }
return { ...l, title, url: u.url };
});
const socials: Socials = {};
for (const { kind } of SOCIALS) {
const text = d.socials[kind]?.trim();
if (!text) continue;
const c = checkSocial(kind, text);
if ("error" in c) problems.socials[kind] = c.error;
else socials[kind] = c.value;
}
const ok = !problems.name && !problems.bio && !Object.keys(problems.links).length && !Object.keys(problems.socials).length;
return { page: { ...d, name: d.name.trim(), bio: d.bio.trim(), links, socials }, problems, ok };
}
function Editor({ handle, ownerKey, initial }: { handle: string; ownerKey: string; initial: PageDoc }) {
const { data: live } = useQuery(`pages/${handle}`) as { data: PageDoc | null | undefined };
const { data: clickRows } = useQuery(`pages/${handle}/clicks`) as { data: { id: string; count: number }[] | undefined };
const [doc, setDoc] = useState(initial);
useEffect(() => { if (live) setDoc(live); }, [live]);
const [draft, setDraft] = useState<Draft | null>(null);
const [showProblems, setShowProblems] = useState(false);
const [busy, setBusy] = useState(false);
const [tab, setTab] = useState<"edit" | "preview">("edit");
const [sharing, setSharing] = useState(false);
const editScroll = useRef(0);
const saved = useMemo(() => toDraft(fromDoc(doc)), [doc]);
const value = draft ?? saved;
const dirty = Boolean(draft && JSON.stringify(draft) !== JSON.stringify(saved));
const [bar, setBar] = useState<"hidden" | "shown" | "leaving">("hidden");
useEffect(() => { setBar((b) => (dirty ? "shown" : b === "shown" ? "leaving" : b)); }, [dirty]);
const checked = check(value);
const problems = showProblems ? checked.problems : ({ links: {}, socials: {} } as Problems);
const clicks = clickRows ? new Map(clickRows.map((r) => [r.id, Number(r.count) || 0])) : null;
const totalClicks = clicks ? value.links.reduce((sum, l) => sum + (clicks.get(l.id) ?? 0), 0) : null;
const edit = (patch: Partial<Draft>) => setDraft((d) => ({ ...(d ?? saved), ...patch }));
const save = async () => {
if (!checked.ok) {
setShowProblems(true);
switchTab("edit");
toast("Fix the marked fields first.");
requestAnimationFrame(() => document.querySelector(".field-error")?.closest(".field, .ed-link, .ed-social")?.scrollIntoView({ block: "center", behavior: "smooth" }));
return;
}
setBusy(true);
try {
const fields = toDoc(checked.page);
await set(`pages/${handle}`, fields);
setDoc((d) => ({ ...d, ...fields }));
setDraft(null);
setShowProblems(false);
toast("Saved. Your page is up to date.");
} catch (err) {
toast(explain(err));
} finally {
setBusy(false);
}
};
const discard = () => { setDraft(null); setShowProblems(false); };
// On phones the tabs swap the form for the preview: the preview opens at its top, and the form comes back where it was left.
const switchTab = (t: "edit" | "preview") => {
if (t === tab) return;
const stuck = (document.querySelector(".ed-head")?.getBoundingClientRect().bottom ?? 0) + scrollY - (document.querySelector<HTMLElement>(".site-head")?.offsetHeight ?? 0);
if (t === "preview") editScroll.current = scrollY;
const to = t === "edit" ? editScroll.current : Math.min(scrollY, stuck);
setTab(t);
requestAnimationFrame(() => scrollTo(0, to));
};
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ""; };
const onKey = (e: globalThis.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { e.preventDefault(); if (!busy) save(); }
};
window.addEventListener("beforeunload", warn);
window.addEventListener("keydown", onKey);
return () => { window.removeEventListener("beforeunload", warn); window.removeEventListener("keydown", onKey); };
});
const onPhoto = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
edit({ avatar: await shrinkPhoto(file) });
} catch (err) {
toast((err as Error).message);
}
};
return (
<div className={`editor tab-${tab}`}>
<Head handle={handle} views={doc.views ?? 0} clicks={totalClicks} onShare={() => setSharing(true)} />
<Tabs tab={tab} onTab={switchTab} />
<div className="wrap ed-body">
<div className="ed-form">
<section className="card ed-card" aria-labelledby="ed-profile">
<h2 className="h3" id="ed-profile">Profile</h2>
<div className="ed-photo">
<span className="ed-photo-img" aria-hidden="true">
{isAvatar(value.avatar) ? <img src={value.avatar} alt="" /> : (value.name.trim() || handle).charAt(0).toUpperCase()}
</span>
<div className="ed-photo-side">
<div className="row" style={{ "--gap": "8px" } as CSSProperties}>
<label className="btn btn-sm btn-line file-btn">
{value.avatar ? "Change photo" : "Upload photo"}
<input type="file" accept="image/*" onChange={onPhoto} />
</label>
{value.avatar && <button className="btn btn-sm btn-quiet" type="button" onClick={() => edit({ avatar: "" })}>Remove</button>}
</div>
<small className="hint">Square works best. It is cropped and stored with your page.</small>
</div>
</div>
<label className="field">
<span>Name</span>
<input className="input" value={value.name} maxLength={MAX_NAME} onChange={(e) => edit({ name: e.target.value })} placeholder="Your name"
aria-invalid={problems.name ? true : undefined} />
{problems.name && <small className="field-error">{problems.name}</small>}
</label>
<label className="field">
<span className="spread"><span>Bio</span><span className="count" aria-hidden="true">{value.bio.length}/{MAX_BIO}</span></span>
<textarea className="textarea ed-bio" rows={2} value={value.bio} maxLength={MAX_BIO} placeholder="One line about you"
onChange={(e) => edit({ bio: e.target.value.replace(/\s*\n\s*/g, " ") })} onKeyDown={(e) => { if (e.key === "Enter") e.preventDefault(); }}
aria-invalid={problems.bio ? true : undefined} />
{problems.bio && <small className="field-error">{problems.bio}</small>}
</label>
</section>
<LinksCard links={value.links} problems={problems.links} clicks={clicks} onChange={(links) => edit({ links })} />
<SocialsCard socials={value.socials} problems={problems.socials} onChange={(socials) => edit({ socials })} />
<section className="card ed-card" aria-labelledby="ed-look">
<h2 className="h3" id="ed-look">Look</h2>
<div className="field">
<span id="theme-label">Theme</span>
<div className="themes" role="radiogroup" aria-labelledby="theme-label">
{THEMES.map((t: Theme) => (
<button key={t} type="button" role="radio" aria-checked={value.theme === t} className={`theme-swatch sw-${t}`} onClick={() => edit({ theme: t })}>
<span className="sw-card" style={{ background: THEME_BG[t] }}><i /><i /><i /><i /></span>
<span className="sw-name">{t[0].toUpperCase() + t.slice(1)}</span>
</button>
))}
</div>
</div>
<div className="field">
<span id="style-label">Buttons</span>
<div className="seg" role="radiogroup" aria-labelledby="style-label">
{BUTTON_STYLES.map((s: ButtonStyle) => (
<button key={s} type="button" role="radio" aria-checked={value.buttonStyle === s} onClick={() => edit({ buttonStyle: s })}>
<span className={`seg-sample seg-${s}`} aria-hidden="true" />{s === "filled" ? "Filled" : "Outline"}
</button>
))}
</div>
</div>
<label className="check">
<input type="checkbox" checked={value.showCredit} onChange={(e) => edit({ showCredit: e.target.checked })} />
Show "Made with Bio" at the bottom
</label>
</section>
<section className="panel ed-key" aria-labelledby="ed-key">
<h2 className="h3" id="ed-key">Your private edit link</h2>
<p>The address of this editor is your edit link. Keep it somewhere safe: it opens this editor on any device, and anyone who has it can change your page.</p>
<button className="btn btn-sm btn-line" type="button" onClick={() => copy(editUrl(handle, ownerKey), "Edit link copied")}>Copy edit link</button>
</section>
</div>
<aside className="ed-preview" aria-label="Preview">
<div className="phone">
<div className="phone-screen" aria-hidden="true" onClick={(e) => { if ((e.target as Element).closest("a")) e.preventDefault(); }}
dangerouslySetInnerHTML={{ __html: pageHtml(checked.page, handle, true) }} />
</div>
<p className="hint center">{dirty ? "Preview of your unsaved changes" : "This is your live page"}</p>
</aside>
</div>
{bar !== "hidden" && (
<div className={`savebar${bar === "leaving" ? " leaving" : ""}`} role="region" aria-label="Unsaved changes"
onAnimationEnd={(e) => { if (bar === "leaving" && e.target === e.currentTarget) setBar("hidden"); }}>
<div className="wrap savebar-inner">
<span>Unsaved changes</span>
<div className="row" style={{ "--gap": "8px" } as CSSProperties}>
<button className="btn btn-quiet" type="button" onClick={discard} disabled={!dirty || busy}>Discard</button>
<button className="btn" type="button" onClick={save} disabled={!dirty} aria-busy={busy || undefined}>Save</button>
</div>
</div>
</div>
)}
{sharing && <ShareDialog handle={handle} name={saved.name || handle} onClose={() => setSharing(false)} />}
</div>
);
}
const GripIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor">
{[3.5, 8, 12.5].map((y) => [5.5, 10.5].map((x) => <circle key={`${x}${y}`} cx={x} cy={y} r="1.3" />))}
</svg>
);
const TrashIcon = () => (
<svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3.5 5.5h13M8 5.5V4h4v1.5M5.5 5.5l.8 10.5h7.4l.8-10.5M8.5 8.5v5M11.5 8.5v5" />
</svg>
);
interface Drag { from: number; to: number; startY: number; y: number; startScroll: number; dy: number; els: HTMLElement[]; tops: number[]; heights: number[]; gap: number; raf: number }
/** The page's links and headers: add, edit, hide, remove, and drag (or arrow keys on the handle) to reorder. */
function LinksCard({ links, problems, clicks, onChange }: {
links: Link[]; problems: Record<string, string>; clicks: Map<string, number> | null; onChange: (links: Link[]) => void;
}) {
const listRef = useRef<HTMLOListElement>(null);
const tops = useRef(new Map<string, number>());
const drag = useRef<Drag | null>(null);
const focus = useRef<string | null>(null);
const [fresh, setFresh] = useState<string[]>([]);
const [removing, setRemoving] = useState<string[]>([]);
const [said, setSaid] = useState("");
// Items slide to their new places when the list changes (FLIP), unless the person prefers less motion.
useLayoutEffect(() => {
const list = listRef.current;
if (focus.current) { document.getElementById(focus.current)?.focus(); focus.current = null; }
if (!list) return;
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
const next = new Map<string, number>();
for (const el of Array.from(list.children) as HTMLElement[]) {
const id = el.dataset.id!;
const before = tops.current.get(id);
next.set(id, el.offsetTop);
if (!still && !drag.current && before !== undefined && before !== el.offsetTop) {
el.animate([{ transform: `translateY(${before - el.offsetTop}px)` }, { transform: "none" }], { duration: 200, easing: "cubic-bezier(.2, .8, .2, 1)" });
}
}
tops.current = next;
});
const label = (l: Link) => l.title.trim() || (l.header ? "header" : "link");
const move = (from: number, to: number) => {
if (to < 0 || to >= links.length || to === from) return;
const next = [...links];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
onChange(next);
setSaid(`${label(item)} moved to position ${to + 1} of ${links.length}.`);
};
const add = (header: boolean) => {
const id = randomLinkId();
focus.current = header ? `link-title-${id}` : `link-url-${id}`;
setFresh((f) => [...f, id]);
onChange([header ? { id, title: "", url: "", header: true } : { id, title: "", url: "" }, ...links]);
};
const editLink = (id: string, patch: Partial<Link>) => onChange(links.map((l) => (l.id === id ? { ...l, ...patch } : l)));
const layout = (d: Drag) => {
const last = d.els.length - 1;
const dy = d.y - d.startY + (scrollY - d.startScroll);
d.dy = Math.max(d.tops[0] - d.tops[d.from], Math.min(d.tops[last] + d.heights[last] - d.tops[d.from] - d.heights[d.from], dy));
// An item above stays above until the held item's top passes its middle; one below moves up once the held item's bottom does.
const top = d.tops[d.from] + d.dy, bottom = top + d.heights[d.from];
d.to = d.els.filter((_, i) => (i < d.from ? top : i > d.from ? bottom : -Infinity) > d.tops[i] + d.heights[i] / 2).length;
const shift = d.heights[d.from] + d.gap;
d.els.forEach((el, i) => {
const t = i === d.from ? d.dy : d.from < d.to && i > d.from && i <= d.to ? -shift : d.from > d.to && i >= d.to && i < d.from ? shift : 0;
el.style.transform = t ? `translateY(${t}px)` : "";
});
};
const startDrag = (e: PointerEvent<HTMLButtonElement>, index: number) => {
const list = listRef.current;
if (e.button !== 0 || !list || links.length < 2 || drag.current) return;
e.currentTarget.setPointerCapture(e.pointerId);
const els = Array.from(list.children) as HTMLElement[];
const d: Drag = {
from: index, to: index, startY: e.clientY, y: e.clientY, startScroll: scrollY, dy: 0, els, raf: 0,
tops: els.map((el) => el.offsetTop), heights: els.map((el) => el.offsetHeight), gap: parseFloat(getComputedStyle(list).rowGap) || 0,
};
drag.current = d;
list.classList.add("is-sorting");
els[index].classList.add("is-dragging");
// Scroll the page while the item is held near the top or bottom edge.
const tick = () => {
const top = 150, bottom = innerHeight - 110;
if (d.y < top && d.y < d.startY - 8) scrollBy(0, -Math.ceil((top - d.y) / 8));
else if (d.y > bottom && d.y > d.startY + 8) scrollBy(0, Math.ceil((d.y - bottom) / 8));
layout(d);
d.raf = requestAnimationFrame(tick);
};
d.raf = requestAnimationFrame(tick);
};
const endDrag = () => {
const d = drag.current;
if (!d) return;
cancelAnimationFrame(d.raf);
// Start the slide into place from where each item is drawn now.
d.els.forEach((el, i) => tops.current.set(el.dataset.id!, d.tops[i] + new DOMMatrixReadOnly(getComputedStyle(el).transform).m42));
listRef.current?.classList.remove("is-sorting");
d.els.forEach((el) => { el.style.transform = ""; el.classList.remove("is-dragging"); });
drag.current = null;
if (d.to !== d.from) move(d.from, d.to);
else if (d.dy && !matchMedia("(prefers-reduced-motion: reduce)").matches) {
d.els[d.from].animate([{ transform: `translateY(${d.dy}px)` }, { transform: "none" }], { duration: 150, easing: "cubic-bezier(.2, .8, .2, 1)" });
}
};
const onGripKey = (e: KeyboardEvent<HTMLButtonElement>, index: number, id: string) => {
const by = e.key === "ArrowUp" ? -1 : e.key === "ArrowDown" ? 1 : 0;
if (!by) return;
e.preventDefault();
focus.current = `grip-${id}`;
move(index, index + by);
};
const count = links.filter((l) => !l.header).length;
return (
<section className="card ed-card" aria-labelledby="ed-links">
<div className="spread ed-card-head">
<h2 className="h3" id="ed-links">Links <span className="count" aria-hidden="true">{count}</span></h2>
<div className="ed-add">
<button className="btn btn-sm" type="button" onClick={() => add(false)} disabled={links.length >= MAX_LINKS}>Add link</button>
<button className="btn btn-sm btn-line" type="button" onClick={() => add(true)} disabled={links.length >= MAX_LINKS}>Add header</button>
</div>
</div>
{links.length >= MAX_LINKS && <p className="hint">A page holds up to {MAX_LINKS} links and headers.</p>}
{links.length === 0 && (
<div className="empty-state ed-empty">
<h3>No links yet</h3>
<p>Add your shop, your latest video, a booking page or anything else people should find.</p>
</div>
)}
<p className="sr-only" id="grip-hint">Drag to move it, or press the up and down arrow keys.</p>
<p className="sr-only" aria-live="polite">{said}</p>
<ol className="ed-links" ref={listRef}>
{links.map((l, i) => {
const name = label(l);
const n = clicks?.get(l.id) ?? 0;
return (
<li key={l.id} data-id={l.id}
className={["ed-link", l.header && "is-header", l.hidden && "is-hidden", problems[l.id] && "has-problem",
fresh.includes(l.id) && "enter", removing.includes(l.id) && "leaving"].filter(Boolean).join(" ")}
onAnimationEnd={(e) => {
if (e.target !== e.currentTarget) return;
if (e.animationName === "oa-enter") setFresh((f) => f.filter((x) => x !== l.id));
if (e.animationName === "oa-leave") { setRemoving((r) => r.filter((x) => x !== l.id)); onChange(links.filter((x) => x.id !== l.id)); }
}}>
<button className="ed-grip" id={`grip-${l.id}`} type="button" aria-label={`Move ${name}`} aria-describedby="grip-hint"
onPointerDown={(e) => startDrag(e, i)} onPointerMove={(e) => { if (drag.current) drag.current.y = e.clientY; }}
onPointerUp={endDrag} onPointerCancel={endDrag} onKeyDown={(e) => onGripKey(e, i, l.id)}>
<GripIcon />
</button>
<div className="ed-link-fields">
<input className="input ed-link-title" id={`link-title-${l.id}`} value={l.title} maxLength={MAX_TITLE}
onChange={(e) => editLink(l.id, { title: e.target.value })}
placeholder={l.header ? "Header, like Shop" : "Title, like My shop"} aria-label={l.header ? `Header ${i + 1} text` : `Link ${i + 1} title`}
aria-invalid={l.header && problems[l.id] ? true : undefined} />
{!l.header && (
<input className="input ed-link-url" id={`link-url-${l.id}`} value={l.url} onChange={(e) => editLink(l.id, { url: e.target.value })}
placeholder="https://" type="url" inputMode="url" autoComplete="off" autoCapitalize="off" spellCheck={false}
aria-label={`Link ${i + 1} address`} aria-invalid={problems[l.id] ? true : undefined} />
)}
{problems[l.id] && <small className="field-error">{problems[l.id]}</small>}
</div>
<div className="ed-link-foot">
<span className="ed-link-note">{l.header ? "Header" : clicks ? `${n.toLocaleString()} ${n === 1 ? "click" : "clicks"}` : ""}</span>
<label className="switch">
<input type="checkbox" role="switch" checked={!l.hidden} onChange={(e) => editLink(l.id, { hidden: e.target.checked ? undefined : true })}
aria-label={`Show ${name} on the page`} />
<span aria-hidden="true">Show</span>
</label>
<button className="icon-btn" type="button" aria-label={`Remove ${name}`} onClick={() => setRemoving((r) => [...r, l.id])}><TrashIcon /></button>
</div>
</li>
);
})}
</ol>
</section>
);
}
/** Social icons: one row per network on the page, and a menu to add another. */
function SocialsCard({ socials, problems, onChange }: {
socials: Partial<Record<SocialKind, string>>; problems: Partial<Record<SocialKind, string>>; onChange: (s: Partial<Record<SocialKind, string>>) => void;
}) {
const menu = useRef<HTMLDetailsElement>(null);
const focus = useRef<string | null>(null);
const shown = SOCIALS.filter(({ kind }) => socials[kind] !== undefined);
const unused = SOCIALS.filter(({ kind }) => socials[kind] === undefined);
useLayoutEffect(() => {
if (focus.current) { document.getElementById(focus.current)?.focus(); focus.current = null; }
});
useEffect(() => {
const close = (e: Event) => {
const el = menu.current;
if (el?.open && (e.type === "keydown" ? (e as globalThis.KeyboardEvent).key === "Escape" : !el.contains(e.target as Node))) el.open = false;
};
document.addEventListener("pointerdown", close);
document.addEventListener("keydown", close);
return () => { document.removeEventListener("pointerdown", close); document.removeEventListener("keydown", close); };
}, []);
const add = (kind: SocialKind) => {
if (menu.current) menu.current.open = false;
focus.current = `social-${kind}`;
onChange({ ...socials, [kind]: "" });
};
const remove = (kind: SocialKind) => {
const next = { ...socials };
delete next[kind];
onChange(next);
};
return (
<section className="card ed-card" aria-labelledby="ed-socials">
<div className="spread ed-card-head">
<h2 className="h3" id="ed-socials">Social icons</h2>
{unused.length > 0 && (
<details className="dropdown" ref={menu}>
<summary className="btn btn-sm btn-line">Add icon</summary>
<div className="dropdown-menu dropdown-menu-end ed-social-menu">
{unused.map(({ kind, label }) => (
<button key={kind} type="button" onClick={() => add(kind)}>
<span className="ed-menu-icon" aria-hidden="true" dangerouslySetInnerHTML={{ __html: ICONS[kind] }} />{label}
</button>
))}
</div>
</details>
)}
</div>
<p className="hint ed-card-hint">Small icons under your bio. Paste an address or just your name.</p>
{shown.length > 0 && (
<div className="ed-socials">
{shown.map(({ kind, label, placeholder }) => (
<div key={kind} className="ed-social enter">
<span className="ed-social-icon" aria-hidden="true" dangerouslySetInnerHTML={{ __html: ICONS[kind] }} />
<label className="field">
<span>{label}</span>
<input className="input" id={`social-${kind}`} value={socials[kind]} placeholder={placeholder}
onChange={(e) => onChange({ ...socials, [kind]: e.target.value })} aria-invalid={problems[kind] ? true : undefined}
type={kind === "email" ? "email" : kind === "whatsapp" ? "tel" : "text"} inputMode={kind === "email" ? "email" : kind === "whatsapp" ? "tel" : "url"}
autoComplete="off" autoCapitalize="off" spellCheck={false} />
{problems[kind] && <small className="field-error">{problems[kind]}</small>}
</label>
<button className="icon-btn" type="button" aria-label={`Remove ${label}`} onClick={() => remove(kind)}><TrashIcon /></button>
</div>
))}
</div>
)}
</section>
);
}
function download(blob: Blob, name: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/** About 1,200 pixels square: sharp on a flyer or a table card. */
function qrPng(m: QrMatrix): Promise<Blob> {
const n = m.length + 8;
const px = Math.ceil(1200 / n);
const canvas = document.createElement("canvas");
canvas.width = canvas.height = n * px;
const g = canvas.getContext("2d")!;
g.fillStyle = "#ffffff";
g.fillRect(0, 0, canvas.width, canvas.height);
g.fillStyle = "#111111";
m.forEach((row, r) => row.forEach((dark, c) => { if (dark) g.fillRect((c + 4) * px, (r + 4) * px, px, px); }));
return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("Could not make the image."))), "image/png"));
}
function ShareDialog({ handle, name, onClose }: { handle: string; name: string; onClose: () => void }) {
const url = pageUrl(handle);
const matrix = useMemo(() => qrMatrix(url), [url]);
const canShare = typeof navigator.share === "function";
return (
<Dialog title="Share your page" onClose={onClose}>
<div className="share-url">
<span>{HOST}/<strong>{handle}</strong></span>
<button className="btn btn-sm" type="button" onClick={() => copy(url, "Page link copied")}>Copy link</button>
</div>
<p className="hint">Paste it into the website field of your Instagram, TikTok or YouTube profile.</p>
<div className="share-qr" role="img" aria-label={`QR code for ${HOST}/${handle}`} dangerouslySetInnerHTML={{ __html: qrSvg(matrix) }} />
<div className="share-actions">
<button className="btn btn-line" type="button" onClick={async () => download(await qrPng(matrix), `bio-${handle}-qr.png`)}>Download QR code</button>
{canShare && <button className="btn btn-line" type="button" onClick={() => navigator.share({ title: name, url }).catch(() => {})}>Share with an app</button>}
</div>
</Dialog>
);
}
Change history
Quality pass: reorder, hide links, headers, QR share, 15 social icons, API docs
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync