// Main app — screen routing, top-bar switcher, palette/tweaks.
//
// Auth model (see server/auth.js):
//   - Identity is a `fw_uid` cookie set by the server. The browser sends it
//     automatically. If cleared, you become a brand new user.
//   - First visit prompts for a display name (NameModal); without one you
//     can read but not contribute.
//   - Admin = anyone who has submitted the board's password. The server
//     remembers per (board, user) so the password is asked only once per
//     browser. Switching to "Admin" in the POV switcher pops the prompt.

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "paletteKey": "warm",
  "stickerPack": "hearts",
  "density": "regular"
}/*EDITMODE-END*/;

function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const palette = PALETTE[t.paletteKey] || PALETTE.warm;

  // Land on the home screen at `/`; only `/b/<slug>` (or a recipient share
  // link with #r=...) drops you straight onto a board.
  const initialScreen = React.useMemo(() => {
    if (typeof location === "undefined") return "home";
    return /^\/b\/[A-Za-z0-9_-]+\/?$/.test(location.pathname) ? "board" : "home";
  }, []);
  const [screen, setScreen] = React.useState(initialScreen);
  const [showCompose, setShowCompose] = React.useState(false);
  const [showAdmin, setShowAdmin] = React.useState(false);
  const [adminPrompt, setAdminPrompt] = React.useState(false);
  const [toast, setToast] = React.useState(null);
  const [role, setRole] = React.useState("contributor");
  // Holds the note being edited in the modal (null = not editing).
  const [editingNote, setEditingNote] = React.useState(null);

  // Identity & board state.
  const slug = React.useMemo(() => API.currentSlug(), []);
  // If the URL has #r=<token>, this visitor opened a recipient share link.
  // We bypass the name prompt + switcher and drop them straight into the
  // reveal with full content.
  const recipientShareToken = React.useMemo(() => API.currentRecipientToken(), []);
  const isRecipientShare = !!recipientShareToken;
  const [me, setMe] = React.useState(null);     // {id, name} once /api/me resolves
  const [board, setBoard] = React.useState(null);
  const [notes, setNotes] = React.useState([]);
  const [loadError, setLoadError] = React.useState(null);
  const locked = !!(board && board.locked);
  const isAdmin = !!(board && board.isAdmin);
  // Server-side admin can preview the contributor / recipient experience by
  // switching POV. UI-facing admin powers (visibility toggle, canvas
  // handles, freely editing other people's notes) only apply when both
  // server-side admin AND the active POV is "admin".
  const effectiveAdmin = isAdmin && role === "admin";

  // Mirror the server's redaction rules client-side so a server-side admin
  // sees the same wall a normal contributor / recipient would. Recipient
  // share visitors always see the full content (server already enforced this).
  const displayedNotes = React.useMemo(() => {
    if (isRecipientShare) return notes;
    return applyClientRedaction(notes, role, me?.id);
  }, [notes, role, me?.id, isRecipientShare]);

  const showToast = (msg) => {
    setToast(msg);
    setTimeout(() => setToast(null), 2200);
  };

  // Boot: identity → board.
  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      // Recipient share visitor: skip identity entirely; just load the board
      // with the secret token and drop straight into the reveal.
      if (isRecipientShare) {
        try {
          const data = await API.loadBoard(slug, recipientShareToken);
          if (cancelled) return;
          setMe({ id: null, name: null });   // placeholder so renders proceed
          setBoard(data.board);
          setNotes(data.notes);
          setRole("recipient");
          setScreen("reveal");
        } catch (err) {
          if (!cancelled) setLoadError(err.message || "Couldn't load board");
        }
        return;
      }

      try {
        const meRes = await API.me();
        if (cancelled) return;
        setMe(meRes.user);
      } catch (err) {
        if (!cancelled) setLoadError(err.message || "Couldn't reach the server");
        return;
      }
      try {
        const data = await API.loadBoard(slug);
        if (cancelled) return;
        setBoard(data.board);
        setNotes(data.notes);

        // Default POV: admin if you already are; otherwise contributor.
        // Recipient is opt-in via the switcher.
        setRole(data.board.isAdmin ? "admin" : "contributor");

        if (sessionStorage.getItem("fw:just-created") === slug) {
          sessionStorage.removeItem("fw:just-created");
          showToast("Board created — grab the share link from Manage");
          setTimeout(() => setShowAdmin(true), 500);
        }
      } catch (err) {
        if (!cancelled) setLoadError(err.message || "Couldn't load board");
      }
    })();
    return () => { cancelled = true; };
  }, [slug]);

  // Refetch board (e.g. after becoming admin).
  const refetchBoard = React.useCallback(async () => {
    try {
      const data = await API.loadBoard(slug);
      setBoard(data.board);
      setNotes(data.notes);
      return data.board;
    } catch (err) {
      showToast(err.message || "Couldn't reload board");
      return null;
    }
  }, [slug]);

  const onPOVChange = (r) => {
    if (r === "admin" && !isAdmin) {
      // Need the password first. Open the prompt; only flip role on success.
      setAdminPrompt(true);
      return;
    }
    if (r === "recipient" && !isAdmin) {
      // Recipient view is the admin's preview of the unveiled board, so
      // non-admins can't peek either.
      showToast("Switch to Admin first to preview the recipient view");
      return;
    }
    setRole(r);
    if (r === "recipient") setScreen("reveal");
    else if (screen === "reveal") setScreen("board");
  };

  // ── note mutations (routed through the API) ────────────────────────────────

  const patchNoteLocal = React.useCallback((id, patch) => {
    setNotes((prev) => prev.map((n) => (n.id === id ? { ...n, ...patch } : n)));
  }, []);

  const commitNote = React.useCallback(async (id) => {
    setNotes((prev) => {
      const n = prev.find((x) => x.id === id);
      if (n) {
        // Geometry/content patch only — visibility goes through its own
        // dedicated handler so contributors can move/resize their own
        // notes without the server hard-rejecting the bundled visibility
        // field as "admin only".
        const patch = {
          x: n.x, y: n.y, w: n.w, h: n.h, rot: n.rot,
          color: n.color, body: n.body, caption: n.caption, sticker: n.sticker,
          // Per-note text-size multiplier (1.0 = default).
          fontScale: n.fontScale,
          // Audio chip pose lives on the same JSONB blob — include so the
          // owner/admin can drag and rotate it across reloads. Position is
          // stored as fractions of w/h so it stays inside on resize.
          chipFx: n.chipFx, chipFy: n.chipFy, audioTilt: n.audioTilt, audioChipBg: n.audioChipBg,
        };
        API.patchNote(slug, id, patch).catch((err) => {
          console.warn("[note patch failed]", err);
          showToast(err.message || "Couldn't save change");
        });
      }
      return prev;
    });
  }, [slug]);

  // Admin-only visibility flip from the SelectionToolbar. Sent as its own
  // patch so it never piggybacks on a contributor's geometry update.
  const setNoteVisibility = React.useCallback(async (id, visibility) => {
    if (!effectiveAdmin) { showToast("Admin only"); return; }
    setNotes((prev) => prev.map((n) => (n.id === id ? { ...n, visibility } : n)));
    try {
      await API.patchNote(slug, id, { visibility });
    } catch (err) {
      showToast(err.message || "Couldn't update visibility");
    }
  }, [effectiveAdmin, slug]);

  const addNote = React.useCallback(async (note) => {
    setNotes((prev) => [...prev, note]);
    try {
      const { note: saved } = await API.createNote(slug, note);
      setNotes((prev) => prev.map((n) => (n.id === note.id ? saved : n)));
    } catch (err) {
      setNotes((prev) => prev.filter((n) => n.id !== note.id));
      showToast(err.message || "Couldn't post note");
    }
  }, [slug]);

  const deleteNote = React.useCallback(async (id) => {
    const snapshot = notes;
    setNotes((prev) => prev.filter((n) => n.id !== id));
    try {
      await API.deleteNote(slug, id);
    } catch (err) {
      setNotes(snapshot);
      showToast(err.message || "Couldn't delete");
    }
  }, [slug, notes]);

  const bringForward = React.useCallback(async (id) => {
    setNotes((prev) => {
      const i = prev.findIndex((n) => n.id === id);
      if (i < 0) return prev;
      const cp = [...prev];
      const [n] = cp.splice(i, 1);
      cp.push(n);
      API.reorderNotes(slug, cp.map((x) => x.id)).catch((err) => {
        console.warn("[reorder failed]", err);
      });
      return cp;
    });
  }, [slug]);

  // General z-order ops invoked from the selection toolbar. The array order
  // is the z-order (later = on top), so all four ops are array splice
  // variants on the same persistence call.
  const reorderNote = React.useCallback((id, op) => {
    setNotes((prev) => {
      const i = prev.findIndex((n) => n.id === id);
      if (i < 0) return prev;
      if (op === "front" && i === prev.length - 1) return prev;
      if (op === "back" && i === 0) return prev;
      if (op === "forward" && i === prev.length - 1) return prev;
      if (op === "backward" && i === 0) return prev;
      const cp = [...prev];
      const [n] = cp.splice(i, 1);
      if (op === "front")          cp.push(n);
      else if (op === "back")      cp.unshift(n);
      else if (op === "forward")   cp.splice(i + 1, 0, n);
      else if (op === "backward")  cp.splice(i - 1, 0, n);
      else                         cp.splice(i, 0, n);
      API.reorderNotes(slug, cp.map((x) => x.id)).catch((err) => {
        console.warn("[reorder failed]", err);
        showToast(err.message || "Couldn't reorder");
      });
      return cp;
    });
  }, [slug]);

  const setLocked = React.useCallback(async (next) => {
    if (!board) return;
    if (!effectiveAdmin) { showToast("Switch to Admin POV to lock the board"); return; }
    const prevLocked = board.locked;
    setBoard({ ...board, locked: next });
    try {
      const { board: updated } = await API.patchBoard(slug, { locked: next });
      setBoard(updated);
    } catch (err) {
      setBoard({ ...board, locked: prevLocked });
      showToast(err.message || "Couldn't update board");
    }
  }, [board, effectiveAdmin, slug]);

  // Body background follows palette
  React.useEffect(() => {
    document.body.style.background = palette.bg;
    document.documentElement.style.setProperty("--coral", palette.accent);
  }, [palette]);

  // Hide boot splash once we have something to render.
  React.useEffect(() => {
    if (!board && !loadError) return;
    const boot = document.getElementById("boot");
    if (boot) {
      boot.style.transition = "opacity .25s ease";
      boot.style.opacity = "0";
      setTimeout(() => boot.remove(), 280);
    }
  }, [board, loadError]);

  // ── compose submit: upload then create ────────────────────────────────────
  async function handleComposeSubmit(payload) {
    if (!me?.name) {
      showToast("Tell us your name first");
      return;
    }
    const m = payload.mode;
    const type = m === "photo" ? "photo" : m === "gif" ? "gif"
                : m === "voice" ? "voice"
                : m === "video" ? "video" : "text";
    // Video cards adopt the clip's aspect ratio so portrait/landscape both
    // read naturally on the board. Fall back to a 4:3-ish default when the
    // intrinsic dimensions aren't available yet.
    const videoAspect = (payload.videoW && payload.videoH && payload.videoH > 0)
      ? payload.videoW / payload.videoH
      : null;
    const dims = type === "photo" || type === "gif" ? { w: 280, h: 320 }
               : type === "video" ? (videoAspect && videoAspect > 1
                                       ? { w: 320, h: Math.round(320 / videoAspect) }
                                       : videoAspect
                                          ? { w: Math.round(320 * videoAspect), h: 320 }
                                          : { w: 300, h: 280 })
               : type === "voice"                   ? { w: 300, h: 180 }
               :                                      { w: 260, h: 220 };

    // Source of truth is the photos array; the legacy single-photo fields
    // fall through for back-compat (any value present means just one
    // photo to upload).
    const inputPhotos = Array.isArray(payload.photos) && payload.photos.length > 0
      ? payload.photos
      : (payload.photoSrc ? [{ src: payload.photoSrc, name: payload.photoName }] : []);

    let uploadedPhotos = [];
    let audioUrl = null;
    let videoUrl = null;
    try {
      // Upload every photo in parallel and stitch back into {src, name}.
      uploadedPhotos = await Promise.all(inputPhotos.map(async (p) => {
        if (!p?.src) return null;
        // Already-hosted URLs (from edit mode) pass through untouched.
        if (!p.src.startsWith("blob:")) return { src: p.src, name: p.name || "" };
        const blob = await fetch(p.src).then((r) => r.blob());
        const up = await API.upload(blob, p.name || "photo");
        return { src: up.url, name: p.name || "" };
      })).then((arr) => arr.filter(Boolean));
      if (payload.audioSrc) {
        const blob = await fetch(payload.audioSrc).then((r) => r.blob());
        // Pick a filename extension that matches the recorder's actual
        // output so express.static serves the right Content-Type — iOS
        // Safari can't decode WebM/Opus, and a mislabeled .webm with
        // MP4/AAC bytes inside also won't play back on iOS.
        const t = (blob.type || "").toLowerCase();
        // `.m4a` is the audio-flavored MP4 extension — express.static maps
        // it to `audio/mp4` rather than `video/mp4`, which keeps iOS Safari
        // dispatching the file to its audio decoder cleanly.
        const ext = t.includes("mp4") || t.includes("aac") || t.includes("m4a") ? "m4a"
                  : t.includes("ogg") ? "ogg"
                  : "webm";
        const up = await API.upload(blob, `voice.${ext}`);
        audioUrl = up.url;
      }
      if (payload.videoSrc) {
        if (payload.videoSrc.startsWith("blob:")) {
          const blob = await fetch(payload.videoSrc).then((r) => r.blob());
          const up = await API.upload(blob, payload.videoName || "video.mp4");
          videoUrl = up.url;
        } else {
          videoUrl = payload.videoSrc;
        }
      }
    } catch (err) {
      showToast(err.message || "Upload failed — try a smaller file");
      return;
    }

    const newNote = {
      id: `n${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
      type,
      color: payload.color,
      x: 600 + Math.random() * 400, y: 240 + Math.random() * 200,
      ...dims,
      rot: (Math.random() - 0.5) * 10,
      // Identity + denormalized display info — keeps the Author chip working
      // without an extra users join on every render.
      authorId: me.id,
      authorName: payload.anonymous ? "Anonymous" : me.name,
      authorHue: hueFromName(payload.anonymous ? "Anonymous" : me.name),
      body: payload.body || "Wishing you all the best — couldn't have asked for a better teammate.",
      caption: payload.caption || "",
      // photos is the canonical multi-photo field; photoSrc/photoLabel
      // mirror photos[0] so consumers that still read them keep working.
      photos: uploadedPhotos.length > 0 ? uploadedPhotos : undefined,
      photoSrc: uploadedPhotos[0]?.src || null,
      photoHue: 200,
      photoLabel: uploadedPhotos[0]?.name || "photo",
      audioSrc: audioUrl,
      // Audio can be primary OR an attachment on a photo note; record
      // duration whenever audio is present.
      duration: audioUrl ? (payload.audioDuration || 0) : 0,
      videoSrc: videoUrl,
      videoLabel: payload.videoName || "",
      videoDuration: payload.videoDuration || 0,
      transcript: "",
      sticker: payload.sticker,
    };
    // Photo+voice hybrid renders a tilted pastel chip. Roll its color,
    // tilt, and (perimeter) position once at creation so every card gets
    // its own look that survives reloads. Pass the note's dimensions so
    // the chip lands on the inset edge instead of an arbitrary point.
    if (audioUrl && uploadedPhotos.length > 0) {
      Object.assign(newNote, pickAudioChipStyle(dims.w, dims.h));
    }
    addNote(newNote);
    setShowCompose(false);
    showToast("Stuck it on the wall");
  }

  // ── doodle: built directly on the board, then committed as a note ──────
  // BoardScreen passes us the strokes already normalized to the note's
  // local viewBox plus the bbox geometry so we just bolt on identity here.
  const handleCreateDoodle = React.useCallback(({ paths, doodleVW, doodleVH, x, y, w, h }) => {
    if (!me?.name) { showToast("Tell us your name first"); return; }
    const newNote = {
      id: `n${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
      type: "doodle",
      color: "transparent",
      x, y, w, h,
      rot: 0,
      authorId: me.id,
      authorName: me.name,
      authorHue: hueFromName(me.name),
      body: "",
      paths, doodleVW, doodleVH,
    };
    addNote(newNote);
    showToast("Stuck it on the wall");
  }, [me, addNote]);

  // Open the edit modal for a note. Only the author or an admin can edit;
  // the BoardScreen already gates the toolbar, but double-check here so the
  // server doesn't have to be the only line of defense.
  const openEditNote = React.useCallback((id) => {
    const note = notes.find((n) => n.id === id);
    if (!note) return;
    if (note.redacted) return;
    // Doodles are drawn directly on the board, not in the compose modal —
    // re-do them by deleting and sketching a new one.
    if (note.type === "doodle") {
      showToast("Doodles can't be edited yet — delete and redraw");
      return;
    }
    if (locked && !effectiveAdmin) {
      showToast("Board is locked");
      return;
    }
    if (!effectiveAdmin && !(me?.id && note.authorId === me.id)) {
      showToast("Only the author can edit this note");
      return;
    }
    setEditingNote(note);
  }, [notes, locked, effectiveAdmin, me?.id]);

  // ── edit submit: re-upload any newly-picked media, then patch ─────────────
  // The compose payload mirrors handleComposeSubmit, so most fields map 1:1.
  // We only re-upload when the user actually replaced media (blob: URLs
  // indicate a fresh local pick); existing server URLs pass through untouched.
  async function handleEditSubmit(payload) {
    const target = editingNote;
    if (!target) return;
    const inputPhotos = Array.isArray(payload.photos) && payload.photos.length > 0
      ? payload.photos
      : (payload.photoSrc ? [{ src: payload.photoSrc, name: payload.photoName }] : []);

    let uploadedPhotos = [];
    let audioUrl = payload.audioSrc;
    let videoUrl = payload.videoSrc;
    try {
      // Re-upload only freshly-picked media (blob: URLs); already-hosted
      // URLs pass through. Each photo is uploaded in parallel.
      uploadedPhotos = await Promise.all(inputPhotos.map(async (p) => {
        if (!p?.src) return null;
        if (!p.src.startsWith("blob:")) return { src: p.src, name: p.name || "" };
        const blob = await fetch(p.src).then((r) => r.blob());
        const up = await API.upload(blob, p.name || "photo");
        return { src: up.url, name: p.name || "" };
      })).then((arr) => arr.filter(Boolean));
      if (audioUrl && audioUrl.startsWith("blob:")) {
        const blob = await fetch(audioUrl).then((r) => r.blob());
        const t = (blob.type || "").toLowerCase();
        // `.m4a` is the audio-flavored MP4 extension — express.static maps
        // it to `audio/mp4` rather than `video/mp4`, which keeps iOS Safari
        // dispatching the file to its audio decoder cleanly.
        const ext = t.includes("mp4") || t.includes("aac") || t.includes("m4a") ? "m4a"
                  : t.includes("ogg") ? "ogg"
                  : "webm";
        const up = await API.upload(blob, `voice.${ext}`);
        audioUrl = up.url;
      }
      if (videoUrl && videoUrl.startsWith("blob:")) {
        const blob = await fetch(videoUrl).then((r) => r.blob());
        const up = await API.upload(blob, payload.videoName || "video.mp4");
        videoUrl = up.url;
      }
    } catch (err) {
      showToast(err.message || "Upload failed — try a smaller file");
      return;
    }

    const patch = {
      color: payload.color,
      sticker: payload.sticker,
      body: payload.body,
      caption: payload.caption,
      // photos is the canonical multi-photo field; photoSrc/photoLabel
      // mirror photos[0] so legacy reads keep working.
      photos: uploadedPhotos.length > 0 ? uploadedPhotos : null,
      photoSrc: uploadedPhotos[0]?.src || null,
      photoLabel: uploadedPhotos[0]?.name || target.photoLabel || "",
      audioSrc: audioUrl,
      // Update duration when a fresh recording was attached; otherwise
      // keep whatever was on the note already.
      duration: payload.audioDuration || target.duration || 0,
      videoSrc: videoUrl || null,
      videoLabel: payload.videoName || target.videoLabel || "",
      videoDuration: payload.videoDuration || target.videoDuration || 0,
    };
    // Only the original author can flip the anonymous chip; an admin
    // editing someone else's note must not stamp their own name onto it.
    const isAuthor = !!(me?.id && target.authorId === me.id);
    if (isAuthor) {
      const displayName = payload.anonymous ? "Anonymous" : me.name;
      patch.authorName = displayName;
      patch.authorHue = hueFromName(displayName);
    }

    patchNoteLocal(target.id, patch);
    try {
      await API.patchNote(slug, target.id, patch);
    } catch (err) {
      showToast(err.message || "Couldn't save changes");
      return;
    }
    setEditingNote(null);
    showToast("Saved");
  }

  // ── render ─────────────────────────────────────────────────────────────────

  if (loadError) {
    return <BootError message={loadError} slug={slug} />;
  }
  if (!board || !me) return null;  // boot splash stays visible

  // Block contributing/admin actions until the user has a name.
  // Recipient-share visitors are pure viewers; don't pester them for a name.
  const needsName = !isRecipientShare && !me.name;

  return (
    <>
      {/* Top-left brand + screen switcher — hidden in the recipient reveal so
          the unveil feels uninterrupted. RevealScreen has its own brand mark
          and an Exit button to leave. */}
      {screen !== "reveal" && (
        <Switcher
          screen={screen}
          role={role}
          isAdmin={isAdmin}
          onScreenChange={setScreen}
          onRoleChange={onPOVChange}
          palette={palette}
          meName={me.name}
          onRename={async (name) => {
            try {
              const r = await API.setName(name);
              const myId = me.id;
              setMe(r.user);
              // Mirror the server-side rename cascade locally so notes
              // already on screen pick up the new name + hue without a
              // refetch. Anonymous notes are skipped to match the server.
              const hue = hueFromName(name);
              setNotes((prev) => prev.map((n) => (
                n.authorId === myId && n.authorName !== "Anonymous"
                  ? { ...n, authorName: name, authorHue: hue }
                  : n
              )));
              showToast("Name updated");
            } catch (err) {
              showToast(err.message || "Couldn't update name");
              throw err;
            }
          }}
        />
      )}

      {/* Active screen */}
      {screen === "home" && (
        <HomeScreen
          palette={palette}
          onCreate={() => setScreen("create")}
        />
      )}
      {screen === "create" && (
        <CreateBoardScreen
          palette={palette}
          onCancel={() => setScreen("home")}
        />
      )}
      {(screen === "board" || screen === "admin") && (
        <BoardScreen
          palette={palette}
          role={role}
          locked={locked}
          notes={displayedNotes}
          recipientName={board.recipientName}
          density={t.density}
          myUserId={me.id}
          isAdmin={effectiveAdmin}
          onUpdateNoteLocal={patchNoteLocal}
          onCommitNote={commitNote}
          onDeleteNote={deleteNote}
          onBringForward={bringForward}
          onReorderNote={reorderNote}
          onEditNote={openEditNote}
          onSetNoteVisibility={setNoteVisibility}
          onCompose={() => {
            if (needsName) { showToast("Tell us your name first"); return; }
            setShowCompose(true);
          }}
          onStartDoodle={() => {
            if (needsName) { showToast("Tell us your name first"); return false; }
            return true;
          }}
          onCreateDoodle={handleCreateDoodle}
          onOpenAdmin={() => setShowAdmin(true)}
          onReveal={() => { setRole("recipient"); setScreen("reveal"); }}
        />
      )}
      {screen === "reveal" && (
        <RevealScreen
          palette={palette}
          notes={displayedNotes}
          recipientName={board.recipientName}
          isRecipientShare={isRecipientShare}
          onClose={() => { setRole(isAdmin ? "admin" : "contributor"); setScreen("board"); }}
          onSave={() => showToast("Saved to keepsakes")}
        />
      )}

      {/* Modal overlays */}
      {showCompose && (
        <ComposeScreen
          palette={palette}
          recipientName={board.recipientName}
          onCancel={() => setShowCompose(false)}
          onSubmit={handleComposeSubmit}
        />
      )}
      {editingNote && (
        <ComposeScreen
          key={`edit-${editingNote.id}`}
          palette={palette}
          recipientName={board.recipientName}
          existingNote={editingNote}
          isAuthor={!!(me?.id && editingNote.authorId === me.id)}
          onCancel={() => setEditingNote(null)}
          onSubmit={handleEditSubmit}
        />
      )}
      {showAdmin && (
        <AdminPanel
          palette={palette}
          slug={slug}
          locked={locked}
          setLocked={setLocked}
          recipientName={board.recipientName}
          contributorLink={API.contributorLink(slug)}
          recipientLink={board.recipientToken ? API.recipientLink(slug, board.recipientToken) : null}
          onToast={showToast}
          onClose={() => setShowAdmin(false)}
        />
      )}

      {/* Identity prompts */}
      {needsName && (
        <NameModal
          onSubmit={async (name) => {
            try {
              const r = await API.setName(name);
              setMe(r.user);
            } catch (err) {
              showToast(err.message || "Couldn't save name");
            }
          }}
        />
      )}
      {adminPrompt && (
        <PasswordModal
          recipientName={board.recipientName}
          onCancel={() => setAdminPrompt(false)}
          onSubmit={async (password) => {
            try {
              await API.becomeAdmin(slug, password);
              await refetchBoard();
              setAdminPrompt(false);
              setRole("admin");
              showToast("You're an admin now");
            } catch (err) {
              throw err;  // surfaced by the modal
            }
          }}
        />
      )}

      <APILoadingIndicator />
      {toast && <div className="toast">{toast}</div>}

      <TweaksPanel>
        <TweakSection label="Palette">
          <TweakColor label="Theme"
            value={[PALETTE[t.paletteKey].notes[0], PALETTE[t.paletteKey].notes[1], PALETTE[t.paletteKey].accent]}
            options={[
              [PALETTE.warm.notes[0],  PALETTE.warm.notes[1],  PALETTE.warm.accent],
              [PALETTE.jewel.notes[0], PALETTE.jewel.notes[1], PALETTE.jewel.accent],
              [PALETTE.paper.notes[0], PALETTE.paper.notes[1], PALETTE.paper.accent],
            ]}
            onChange={(v) => {
              const k = Object.keys(PALETTE).find((k) => PALETTE[k].notes[0] === v[0]) || "warm";
              setTweak("paletteKey", k);
            }}
          />
        </TweakSection>
        <TweakSection label="Stickers">
          <TweakRadio label="Pack" value={t.stickerPack}
            options={["hearts","paper","letters"]}
            onChange={(v) => setTweak("stickerPack", v)} />
        </TweakSection>
        <TweakSection label="Board">
          <TweakRadio label="Density" value={t.density}
            options={["sparse","regular","packed"]}
            onChange={(v) => setTweak("density", v)} />
          <TweakToggle label="Locked for reveal" value={locked} onChange={setLocked} />
        </TweakSection>
      </TweaksPanel>
    </>
  );
}

// ── tiny modals (inlined; only used here) ───────────────────────────────────

function NameModal({ onSubmit }) {
  const [name, setName] = React.useState("");
  const [pending, setPending] = React.useState(false);
  const submit = async (e) => {
    e?.preventDefault?.();
    const trimmed = name.trim();
    if (!trimmed || pending) return;
    setPending(true);
    try { await onSubmit(trimmed); } finally { setPending(false); }
  };
  return (
    <div className="modal-backdrop">
      <form className="modal-card" onSubmit={submit}
            style={{ width: 420, maxWidth: "92vw", padding: "30px 28px 24px" }}>
        <div className="chip" style={{ marginBottom: 10 }}>say hi</div>
        <h2 style={{ fontFamily: "var(--serif)", fontSize: 28, margin: "0 0 6px", fontWeight: 400 }}>
          What should we call you?
        </h2>
        <p style={{ color: "var(--ink-3)", fontSize: 13.5, margin: "0 0 18px", lineHeight: 1.5 }}>
          Shown on the notes you leave. You can read the board without one.
        </p>
        <input className="txt" autoFocus value={name}
               onChange={(e) => setName(e.target.value)}
               placeholder="Your name"
               maxLength={80} />
        <div style={{ display: "flex", justifyContent: "flex-end", marginTop: 22 }}>
          <button type="submit" className="btn btn-coral"
                  disabled={!name.trim() || pending}
                  style={{ opacity: !name.trim() || pending ? 0.5 : 1 }}>
            {pending ? "Saving…" : (<>Continue <IconArrowRight size={13} /></>)}
          </button>
        </div>
      </form>
    </div>
  );
}

function PasswordModal({ onSubmit, onCancel, recipientName }) {
  const [pw, setPw] = React.useState("");
  const [err, setErr] = React.useState(null);
  const [pending, setPending] = React.useState(false);
  const submit = async (e) => {
    e?.preventDefault?.();
    if (!pw || pending) return;
    setPending(true); setErr(null);
    try {
      await onSubmit(pw);
    } catch (e2) {
      setErr(e2.message || "Wrong password");
    } finally {
      setPending(false);
    }
  };
  return (
    <div className="modal-backdrop" onClick={onCancel}>
      <form className="modal-card" onClick={(e) => e.stopPropagation()} onSubmit={submit}
            style={{ width: 420, maxWidth: "92vw", padding: "30px 28px 24px" }}>
        <div className="chip" style={{ marginBottom: 10 }}>admin access</div>
        <h2 style={{ fontFamily: "var(--serif)", fontSize: 26, margin: "0 0 6px", fontWeight: 400 }}>
          Password for <em>{recipientName || "this board"}</em>
        </h2>
        <p style={{ color: "var(--ink-3)", fontSize: 13.5, margin: "0 0 18px", lineHeight: 1.5 }}>
          Set when the board was created. Anyone with it can manage notes and lock for reveal.
        </p>
        <input className="txt" autoFocus type="password" value={pw}
               onChange={(e) => setPw(e.target.value)}
               placeholder="Password" />
        {err && (
          <div style={{
            marginTop: 12, padding: "8px 12px", borderRadius: 8, fontSize: 13,
            background: "rgba(232,126,90,0.10)", color: "var(--ink)",
            border: "1px solid rgba(232,126,90,0.35)",
          }}>{err}</div>
        )}
        <div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 22 }}>
          <button type="button" className="btn btn-ghost" onClick={onCancel} disabled={pending}>
            Cancel
          </button>
          <button type="submit" className="btn btn-coral"
                  disabled={!pw || pending}
                  style={{ opacity: !pw || pending ? 0.5 : 1 }}>
            {pending ? "Checking…" : "Become admin"}
          </button>
        </div>
      </form>
    </div>
  );
}

function BootError({ message, slug }) {
  return (
    <div style={{
      position: "fixed", inset: 0, display: "grid", placeItems: "center",
      background: "var(--bg)", padding: 28, textAlign: "center",
    }}>
      <div style={{ maxWidth: 420 }}>
        <div className="wordmark" style={{ fontSize: 38, marginBottom: 18 }}>
          <em>Fare<span className="comma">,</span> well</em>
        </div>
        <h2 style={{ fontFamily: "var(--serif)", fontSize: 22, fontWeight: 400, margin: "0 0 6px" }}>
          Couldn't load <em>{slug}</em>
        </h2>
        <p style={{ color: "var(--ink-2)", lineHeight: 1.5, fontSize: 14 }}>{message}</p>
        <p style={{ color: "var(--ink-3)", fontSize: 12.5, marginTop: 14 }}>
          If you just deployed: confirm <code className="kbd">DATABASE_URL</code> is set on the API service.
        </p>
      </div>
    </div>
  );
}

function Switcher({ screen, role, isAdmin, onScreenChange, onRoleChange, palette, meName, onRename }) {
  // Inline rename: click "you are <name>" to swap into a text input. Enter
  // (or blur) saves; Escape cancels. The chip itself still reads the latest
  // name once the parent updates `me` on success.
  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft] = React.useState(meName || "");
  const [saving, setSaving] = React.useState(false);
  const inputRef = React.useRef(null);
  React.useEffect(() => { setDraft(meName || ""); }, [meName]);
  React.useEffect(() => { if (editing) { inputRef.current?.focus(); inputRef.current?.select(); } }, [editing]);

  const beginEdit = () => {
    if (!onRename) return;
    setDraft(meName || "");
    setEditing(true);
  };
  const cancelEdit = () => { setDraft(meName || ""); setEditing(false); };
  const commitEdit = async () => {
    const next = draft.trim();
    if (!next || next === meName) { cancelEdit(); return; }
    setSaving(true);
    try { await onRename(next); setEditing(false); }
    catch (_) { /* parent toasted; stay in edit so the user can retry */ }
    finally { setSaving(false); }
  };
  // On the board, the switcher stays slim: title (→ home) plus Contributor /
  // Admin POV toggles. Recipient is a preview admins reach via the "Send to"
  // button on the board, not a top-level role here.
  const onBoard = screen === "board" || screen === "admin";

  return (
    <div className="switcher">
      <button
        className="brand"
        onClick={() => onScreenChange("home")}
        title="Back to home"
        style={{
          appearance: "none", border: 0, background: "transparent", cursor: "pointer",
          display: "inline-flex", alignItems: "center", gap: 8,
          padding: "4px 10px 4px 8px",
          borderRight: "1px solid var(--line)",
          marginRight: 4,
        }}
      >
        <span className="wordmark" style={{ fontSize: 19 }}>
          <em>Fare<span className="comma" style={{ color: palette.accent }}>,</span> well</em>
        </span>
      </button>

      {onBoard && ["contributor", "admin"].map((rid) => {
        const label = rid === "contributor" ? "Contributor" : "Admin";
        const active = role === rid;
        return (
          <button key={rid} className={"seg" + (active ? " active" : "")}
                  onClick={() => onRoleChange(rid)}>
            {active && <span className="dot" style={{ background: palette.accent }} />}
            {label}
          </button>
        );
      })}

      {meName && (
        <span style={{
          marginLeft: onBoard ? 10 : 6,
          paddingLeft: onBoard ? 10 : 10,
          paddingRight: 6,
          borderLeft: onBoard ? "1px solid var(--line)" : undefined,
          fontSize: 11.5, color: "var(--ink-3)",
          display: "inline-flex", alignItems: "center", gap: 4,
        }}>
          {editing ? (
            <>
              <span>you are</span>
              <input
                ref={inputRef}
                value={draft}
                disabled={saving}
                onChange={(e) => setDraft(e.target.value)}
                onBlur={commitEdit}
                onKeyDown={(e) => {
                  if (e.key === "Enter") { e.preventDefault(); commitEdit(); }
                  else if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }
                }}
                maxLength={80}
                placeholder="your name"
                style={{
                  appearance: "none",
                  border: "1px solid var(--line-2)",
                  background: "#fff",
                  padding: "2px 6px",
                  borderRadius: 6,
                  fontFamily: "var(--sans)",
                  fontSize: 11.5,
                  color: "var(--ink)",
                  width: 120,
                  outline: 0,
                }}
              />
            </>
          ) : (
            <button
              onClick={beginEdit}
              title="Click to rename"
              style={{
                appearance: "none", border: 0, background: "transparent",
                cursor: onRename ? "pointer" : "default",
                padding: 0, fontFamily: "inherit", fontSize: 11.5,
                color: "var(--ink-3)",
              }}
            >
              you are <span style={{ color: "var(--ink-2)", fontWeight: 500, borderBottom: "1px dashed transparent" }}>
                {meName}
              </span>
            </button>
          )}
        </span>
      )}
    </div>
  );
}

// Tiny bottom-of-screen pill that mirrors the central API loading registry.
// Subscribes to API_LOADING events and shows the most recent label while
// requests are in flight. Debounced 250ms so fast calls don't flash a pill.
function APILoadingIndicator() {
  const [inflight, setInflight] = React.useState([]);
  const [shown, setShown] = React.useState(false);
  React.useEffect(() => window.API_LOADING.subscribe(setInflight), []);
  React.useEffect(() => {
    if (inflight.length === 0) { setShown(false); return; }
    const t = setTimeout(() => setShown(true), 250);
    return () => clearTimeout(t);
  }, [inflight.length === 0]);
  if (!shown || inflight.length === 0) return null;
  const latest = inflight[inflight.length - 1];
  const extra = inflight.length - 1;
  return (
    <div className="api-status" role="status" aria-live="polite">
      <span className="spin" aria-hidden="true" />
      <span>{latest.label}{extra > 0 ? ` +${extra}` : ""}…</span>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
