bio-free.openapps.xyz

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.
Owned app · rev 3e98d5e10bac
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.
← All files
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
3e98d5e10b · 5 hours ago
Bounded source sync
c1194348e3 · 1 day ago
Bounded source sync
5b1f3fab35 · 1 day ago
Bounded source sync
21e45889ea · 1 day ago
Bounded source sync
8e21b0e5b7 · 1 day ago
Bounded source sync
0598bcc9b2 · 1 day ago
Bounded source sync
0ea5668865 · 1 day ago