;
websites?: Array<{ site?: string; link?: { href?: string } }>;
episodes: EpisodeRef[];
@@ -42,6 +47,8 @@ if (isValid && verification.payload) {
title,
"slug": slug.current,
"avatarUrl": coverImage.asset->url,
+ excerpt,
+ content,
socials,
websites,
"episodes": *[_type == "podcast" && references(^._id) && !(_id in path("drafts.**"))] | order(season desc, episode desc) {
@@ -60,6 +67,11 @@ if (isValid && verification.payload) {
const socials = guestDoc?.socials || {};
const primaryWebsite =
guestDoc?.websites?.[0]?.link?.href || guestDoc?.websites?.[0]?.site || "";
+const initialExcerpt =
+ guestDoc?.excerpt && guestDoc.excerpt.trim().toLowerCase() !== "fix me"
+ ? guestDoc.excerpt.trim()
+ : "";
+const initialAboutHtml = portableTextToHtml(guestDoc?.content);
const socialFields = [
{
@@ -289,6 +301,176 @@ const socialFields = [
+
+
+
+
+ About Section (Sanity Portable Text)
+
+
+ Edit your speaker bio with headings, links, bold/italic text, code, and lists — saved directly as Sanity Portable Text blocks on your public profile.
+
+
+
+ Portable Text Ready
+
+
+
+
+
+ One-Line Summary / SEO Bio (optional)
+
+
+
+
+
+
+
+ Paragraph
+
+
+ H2
+
+
+ H3
+
+
+ Quote
+
+
+
+
+
+ B
+
+
+ I
+
+
+ U
+
+
+ </>
+
+
+
+
+
+ • List
+
+
+ 1. List
+
+
+
+
+
+ Link
+
+
+ Unlink
+
+
+
+
+
+
+
+
+ Inspect Generated Sanity Portable Text Blocks JSON
+
+
+
+
+
+
+
Social Handles for Episode Tagging
@@ -352,6 +534,294 @@ const socialFields = [
const form = document.getElementById("guest-profile-form") as HTMLFormElement | null;
const statusEl = document.getElementById("save-status");
const saveBtn = document.getElementById("save-btn") as HTMLButtonElement | null;
+ const editorEl = document.getElementById("pt-editor") as HTMLElement | null;
+ const toolbarEl = document.getElementById("pt-toolbar") as HTMLElement | null;
+ const jsonPreviewEl = document.getElementById("pt-json-preview") as HTMLElement | null;
+ const badgeEl = document.getElementById("pt-block-badge") as HTMLElement | null;
+
+ interface PtSpan {
+ _type: "span";
+ _key: string;
+ text: string;
+ marks: string[];
+ }
+
+ interface PtMarkDef {
+ _type: "link";
+ _key: string;
+ href: string;
+ blank: boolean;
+ }
+
+ interface PtBlock {
+ _type: "block";
+ _key: string;
+ style: "normal" | "h1" | "h2" | "h3" | "h4" | "blockquote";
+ listItem?: "bullet" | "number";
+ level?: number;
+ markDefs: PtMarkDef[];
+ children: PtSpan[];
+ }
+
+ function genKey(): string {
+ const bytes = new Uint8Array(6);
+ crypto.getRandomValues(bytes);
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+ }
+
+ function extractInlineSpans(
+ rootNode: Node,
+ activeMarks: string[],
+ markDefs: PtMarkDef[],
+ spans: PtSpan[],
+ ): void {
+ if (rootNode.nodeType === Node.TEXT_NODE) {
+ const text = rootNode.textContent ?? "";
+ if (!text) return;
+ const prev = spans[spans.length - 1];
+ const sameMarks =
+ prev &&
+ prev.marks.length === activeMarks.length &&
+ prev.marks.every((m, idx) => m === activeMarks[idx]);
+ if (sameMarks) {
+ prev.text += text;
+ } else {
+ spans.push({
+ _type: "span",
+ _key: genKey(),
+ text,
+ marks: [...activeMarks],
+ });
+ }
+ return;
+ }
+
+ if (rootNode.nodeType !== Node.ELEMENT_NODE) return;
+ const el = rootNode as HTMLElement;
+ const tag = el.tagName.toLowerCase();
+
+ if (tag === "br") {
+ spans.push({
+ _type: "span",
+ _key: genKey(),
+ text: "\n",
+ marks: [...activeMarks],
+ });
+ return;
+ }
+
+ const nextMarks = [...activeMarks];
+ const addMark = (m: string) => {
+ if (!nextMarks.includes(m)) nextMarks.push(m);
+ };
+
+ if (tag === "strong" || tag === "b") addMark("strong");
+ if (tag === "em" || tag === "i") addMark("em");
+ if (tag === "u") addMark("underline");
+ if (tag === "s" || tag === "strike" || tag === "del") addMark("strike-through");
+ if (tag === "code") addMark("code");
+
+ if (tag === "a") {
+ const rawHref = el.getAttribute("href")?.trim() || "";
+ if (rawHref) {
+ const safeHref = /^(https?:\/\/|mailto:|\/)/i.test(rawHref)
+ ? rawHref
+ : `https://${rawHref}`;
+ let existing = markDefs.find((md) => md.href === safeHref);
+ if (!existing) {
+ existing = {
+ _type: "link",
+ _key: genKey(),
+ href: safeHref,
+ blank: true,
+ };
+ markDefs.push(existing);
+ }
+ addMark(existing._key);
+ }
+ }
+
+ for (const child of Array.from(el.childNodes)) {
+ extractInlineSpans(child, nextMarks, markDefs, spans);
+ }
+ }
+
+ function buildBlockFromNode(
+ node: Node,
+ style: PtBlock["style"],
+ listItem?: "bullet" | "number",
+ ): PtBlock {
+ const markDefs: PtMarkDef[] = [];
+ const spans: PtSpan[] = [];
+ for (const child of Array.from(node.childNodes)) {
+ extractInlineSpans(child, [], markDefs, spans);
+ }
+ // Strip trailing solitary newline from contenteditable
+ if (spans.length === 1 && spans[0].text === "\n") {
+ spans[0].text = "";
+ }
+ if (spans.length === 0) {
+ spans.push({
+ _type: "span",
+ _key: genKey(),
+ text: "",
+ marks: [],
+ });
+ }
+ const block: PtBlock = {
+ _type: "block",
+ _key: genKey(),
+ style,
+ markDefs,
+ children: spans,
+ };
+ if (listItem) {
+ block.listItem = listItem;
+ block.level = 1;
+ }
+ return block;
+ }
+
+ function serializeEditorToPortableText(root: HTMLElement): PtBlock[] {
+ const blocks: PtBlock[] = [];
+ const BLOCK_TAGS = new Set([
+ "p",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "blockquote",
+ "ul",
+ "ol",
+ "div",
+ ]);
+
+ const walkChildren = (parent: HTMLElement) => {
+ for (const node of Array.from(parent.childNodes)) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const text = node.textContent?.trim() ?? "";
+ if (text) {
+ blocks.push({
+ _type: "block",
+ _key: genKey(),
+ style: "normal",
+ markDefs: [],
+ children: [
+ {
+ _type: "span",
+ _key: genKey(),
+ text,
+ marks: [],
+ },
+ ],
+ });
+ }
+ continue;
+ }
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
+ const el = node as HTMLElement;
+ const tag = el.tagName.toLowerCase();
+
+ if (tag === "ul" || tag === "ol") {
+ const listType = tag === "ul" ? "bullet" : "number";
+ for (const li of Array.from(el.children)) {
+ if (li.tagName.toLowerCase() === "li") {
+ blocks.push(buildBlockFromNode(li, "normal", listType));
+ }
+ }
+ continue;
+ }
+
+ if (
+ tag === "h1" ||
+ tag === "h2" ||
+ tag === "h3" ||
+ tag === "h4" ||
+ tag === "blockquote"
+ ) {
+ blocks.push(buildBlockFromNode(el, tag));
+ continue;
+ }
+
+ if (tag === "p" || tag === "div") {
+ // Check if div contains nested block elements
+ const hasNestedBlocks = Array.from(el.children).some((c) =>
+ BLOCK_TAGS.has(c.tagName.toLowerCase()),
+ );
+ if (hasNestedBlocks) {
+ walkChildren(el);
+ } else {
+ blocks.push(buildBlockFromNode(el, "normal"));
+ }
+ continue;
+ }
+
+ blocks.push(buildBlockFromNode(el, "normal"));
+ }
+ };
+
+ walkChildren(root);
+
+ // Filter out empty trailing normal blocks when there is other content
+ const nonEmpty = blocks.filter(
+ (b, idx) =>
+ idx === 0 ||
+ b.style !== "normal" ||
+ b.listItem ||
+ b.children.some((s) => s.text.trim().length > 0),
+ );
+ return nonEmpty;
+ }
+
+ function refreshPreview() {
+ if (!editorEl) return;
+ const ptBlocks = serializeEditorToPortableText(editorEl);
+ if (jsonPreviewEl) {
+ jsonPreviewEl.textContent = JSON.stringify(ptBlocks, null, 2);
+ }
+ if (badgeEl) {
+ badgeEl.textContent = `${ptBlocks.length} Portable Text ${ptBlocks.length === 1 ? "block" : "blocks"}`;
+ }
+ }
+
+ toolbarEl?.addEventListener("click", (e) => {
+ const btn = (e.target as HTMLElement | null)?.closest("button");
+ if (!btn || !editorEl) return;
+ e.preventDefault();
+ editorEl.focus();
+
+ const blockTag = btn.dataset.ptBlock;
+ const cmd = btn.dataset.ptCmd;
+ const inlineTag = btn.dataset.ptInline;
+ const action = btn.dataset.ptAction;
+
+ if (blockTag) {
+ document.execCommand("formatBlock", false, `<${blockTag}>`);
+ } else if (cmd) {
+ document.execCommand(cmd, false);
+ } else if (inlineTag === "code") {
+ const sel = window.getSelection();
+ if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
+ const text = sel.toString();
+ document.execCommand("insertHTML", false, `${text}`);
+ }
+ } else if (action === "link") {
+ const url = window.prompt("Enter link URL:", "https://");
+ if (url && url.trim() && url.trim() !== "https://") {
+ document.execCommand("createLink", false, url.trim());
+ }
+ }
+
+ refreshPreview();
+ });
+
+ editorEl?.addEventListener("input", () => {
+ refreshPreview();
+ });
+
+ refreshPreview();
form?.addEventListener("submit", async (e) => {
e.preventDefault();
@@ -360,6 +830,7 @@ const socialFields = [
const token = form.dataset.token;
const titleInput = document.getElementById("field-title") as HTMLInputElement | null;
const websiteInput = document.getElementById("field-website") as HTMLInputElement | null;
+ const excerptInput = document.getElementById("field-excerpt") as HTMLInputElement | null;
const socialInputs = form.querySelectorAll("input[data-social-key]");
const socials: Record = {};
@@ -368,6 +839,8 @@ const socialFields = [
if (k) socials[k] = input.value.trim();
});
+ const content = editorEl ? serializeEditorToPortableText(editorEl) : undefined;
+
saveBtn.disabled = true;
saveBtn.textContent = "Saving to Sanity...";
statusEl.textContent = "";
@@ -380,6 +853,8 @@ const socialFields = [
token,
title: titleInput?.value.trim(),
websiteUrl: websiteInput?.value.trim(),
+ excerpt: excerptInput?.value.trim(),
+ content,
socials,
}),
});
@@ -388,7 +863,7 @@ const socialFields = [
throw new Error(data.error || "Failed to update profile");
}
statusEl.className = "text-sm font-medium text-emerald-400";
- statusEl.textContent = "Saved! Your CodingCat.dev speaker profile has been updated.";
+ statusEl.textContent = "Saved! Your CodingCat.dev speaker profile and About section have been updated.";
} catch (err) {
statusEl.className = "text-sm font-medium text-red-400";
statusEl.textContent = err instanceof Error ? err.message : "Error saving profile";