@@ -197,13 +197,12 @@ const socialFields = [
197197 <div class = " rounded-2xl border border-border bg-card p-6 shadow-sm sm:p-8" >
198198 <div class = " flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between" >
199199 <div class = " flex items-center gap-4" >
200- { guestDoc .avatarUrl && (
201- <img
202- src = { ` ${guestDoc .avatarUrl }?w=160&h=160&fit=crop&auto=format ` }
203- alt = { guestDoc .title }
204- class = " h-16 w-16 rounded-full object-cover ring-2 ring-primary/30"
205- />
206- )}
200+ <img
201+ id = " header-avatar-img"
202+ src = { guestDoc .avatarUrl ? ` ${guestDoc .avatarUrl }?w=160&h=160&fit=crop&auto=format ` : " https://codingcat.dev/images/logo.png" }
203+ alt = { guestDoc .title }
204+ class = " h-16 w-16 rounded-full object-cover ring-2 ring-primary/30"
205+ />
207206 <div >
208207 <span class = " inline-flex items-center rounded-full bg-emerald-500/15 px-3 py-1 text-xs font-medium text-emerald-400" >
209208 Verified Sanity Guest Magic Link
@@ -271,6 +270,40 @@ const socialFields = [
271270 data-token = { token }
272271 class = " rounded-2xl border border-border bg-card p-6 shadow-sm sm:p-8 space-y-6"
273272 >
273+ <div class = " flex flex-col gap-4 rounded-xl border border-border/80 bg-muted/20 p-4 sm:flex-row sm:items-center sm:justify-between" >
274+ <div class = " flex items-center gap-4" >
275+ <img
276+ id = " avatar-preview-img"
277+ src = { guestDoc .avatarUrl ? ` ${guestDoc .avatarUrl }?w=160&h=160&fit=crop&auto=format ` : " https://codingcat.dev/images/logo.png" }
278+ alt = { guestDoc .title }
279+ class = " h-16 w-16 rounded-full object-cover ring-2 ring-primary/40"
280+ />
281+ <div >
282+ <h2 class = " text-sm font-semibold text-foreground" >
283+ Profile Photo
284+ </h2 >
285+ <p id = " avatar-status-text" class = " mt-0.5 text-xs text-muted-foreground" >
286+ Upload a new JPG, PNG, or WebP speaker headshot (automatically optimized before saving to Sanity).
287+ </p >
288+ </div >
289+ </div >
290+ <div class = " flex items-center gap-2" >
291+ <input
292+ id = " field-avatar-file"
293+ type = " file"
294+ accept = " image/jpeg,image/png,image/webp"
295+ class = " hidden"
296+ />
297+ <button
298+ type = " button"
299+ id = " choose-avatar-btn"
300+ class = " inline-flex items-center rounded-xl border border-border bg-background px-4 py-2 text-xs font-semibold text-foreground shadow-sm hover:bg-muted cursor-pointer"
301+ >
302+ Upload New Photo
303+ </button >
304+ </div >
305+ </div >
306+
274307 <div class = " grid gap-5 sm:grid-cols-2" >
275308 <div >
276309 <label class = " block text-sm font-medium text-foreground" for = " field-title" >
@@ -538,6 +571,78 @@ const socialFields = [
538571 const toolbarEl = document.getElementById("pt-toolbar") as HTMLElement | null;
539572 const jsonPreviewEl = document.getElementById("pt-json-preview") as HTMLElement | null;
540573 const badgeEl = document.getElementById("pt-block-badge") as HTMLElement | null;
574+ const avatarFileInput = document.getElementById("field-avatar-file") as HTMLInputElement | null;
575+ const chooseAvatarBtn = document.getElementById("choose-avatar-btn") as HTMLButtonElement | null;
576+ const avatarPreviewImg = document.getElementById("avatar-preview-img") as HTMLImageElement | null;
577+ const headerAvatarImg = document.getElementById("header-avatar-img") as HTMLImageElement | null;
578+ const avatarStatusText = document.getElementById("avatar-status-text") as HTMLElement | null;
579+
580+ let stagedAvatarDataUrl: string | undefined;
581+
582+ async function resizeImageFileToDataUrl(file: File, maxDim = 800): Promise<string> {
583+ return new Promise((resolve, reject) => {
584+ const reader = new FileReader();
585+ reader.onerror = () => reject(new Error("Could not read selected image file."));
586+ reader.onload = () => {
587+ const img = new Image();
588+ img.onerror = () => reject(new Error("Invalid or unsupported image file."));
589+ img.onload = () => {
590+ let width = img.naturalWidth || img.width;
591+ let height = img.naturalHeight || img.height;
592+ if (width > maxDim || height > maxDim) {
593+ if (width >= height) {
594+ height = Math.round((height * maxDim) / width);
595+ width = maxDim;
596+ } else {
597+ width = Math.round((width * maxDim) / height);
598+ height = maxDim;
599+ }
600+ }
601+ const canvas = document.createElement("canvas");
602+ canvas.width = width;
603+ canvas.height = height;
604+ const ctx = canvas.getContext("2d");
605+ if (!ctx) {
606+ reject(new Error("Browser canvas is unavailable."));
607+ return;
608+ }
609+ ctx.drawImage(img, 0, 0, width, height);
610+ const dataUrl = canvas.toDataURL("image/jpeg", 0.9);
611+ resolve(dataUrl);
612+ };
613+ img.src = String(reader.result);
614+ };
615+ reader.readAsDataURL(file);
616+ });
617+ }
618+
619+ chooseAvatarBtn?.addEventListener("click", () => {
620+ avatarFileInput?.click();
621+ });
622+
623+ avatarFileInput?.addEventListener("change", async () => {
624+ const file = avatarFileInput.files?.[0];
625+ if (!file) return;
626+ try {
627+ if (avatarStatusText) {
628+ avatarStatusText.className = "mt-0.5 text-xs text-muted-foreground";
629+ avatarStatusText.textContent = "Optimizing photo...";
630+ }
631+ const dataUrl = await resizeImageFileToDataUrl(file, 800);
632+ stagedAvatarDataUrl = dataUrl;
633+ if (avatarPreviewImg) avatarPreviewImg.src = dataUrl;
634+ if (headerAvatarImg) headerAvatarImg.src = dataUrl;
635+ if (avatarStatusText) {
636+ avatarStatusText.className = "mt-0.5 text-xs font-medium text-emerald-400";
637+ avatarStatusText.textContent = `Ready to save (${file.name}) — click "Save Profile to Sanity" below.`;
638+ }
639+ } catch (err) {
640+ if (avatarStatusText) {
641+ avatarStatusText.className = "mt-0.5 text-xs font-medium text-red-400";
642+ avatarStatusText.textContent = err instanceof Error ? err.message : "Could not load photo.";
643+ }
644+ }
645+ });
541646
542647 interface PtSpan {
543648 _type: "span";
@@ -855,15 +960,26 @@ const socialFields = [
855960 websiteUrl: websiteInput?.value.trim(),
856961 excerpt: excerptInput?.value.trim(),
857962 content,
963+ avatarDataUrl: stagedAvatarDataUrl,
858964 socials,
859965 }),
860966 });
861- const data = (await res.json()) as { error?: string };
967+ const data = (await res.json()) as { error?: string; avatarUrl?: string };
862968 if (!res.ok) {
863969 throw new Error(data.error || "Failed to update profile");
864970 }
971+ if (data.avatarUrl) {
972+ const freshUrl = `${data.avatarUrl}?w=160&h=160&fit=crop&auto=format`;
973+ if (avatarPreviewImg) avatarPreviewImg.src = freshUrl;
974+ if (headerAvatarImg) headerAvatarImg.src = freshUrl;
975+ stagedAvatarDataUrl = undefined;
976+ if (avatarStatusText) {
977+ avatarStatusText.className = "mt-0.5 text-xs font-medium text-emerald-400";
978+ avatarStatusText.textContent = "Profile photo uploaded and saved to Sanity!";
979+ }
980+ }
865981 statusEl.className = "text-sm font-medium text-emerald-400";
866- statusEl.textContent = "Saved! Your CodingCat.dev speaker profile and About section have been updated.";
982+ statusEl.textContent = "Saved! Your CodingCat.dev speaker profile, photo, and About section have been updated.";
867983 } catch (err) {
868984 statusEl.className = "text-sm font-medium text-red-400";
869985 statusEl.textContent = err instanceof Error ? err.message : "Error saving profile";
0 commit comments