Skip to content

Commit ce329ec

Browse files
authored
Merge pull request #773 from CodingCatDev/dev
feat(site): add profile photo upload to guest portal
2 parents 5462e58 + 26ebf2e commit ce329ec

3 files changed

Lines changed: 252 additions & 10 deletions

File tree

‎apps/site/src/lib/sanity/write-client.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,103 @@ export function getSanityServerClient(): {
240240
canWrite: Boolean(writeToken),
241241
};
242242
}
243+
244+
export async function uploadSanityImageDataUrl(
245+
dataUrl: string,
246+
filenameBase = "guest-avatar",
247+
): Promise<{ _id: string; url: string } | null> {
248+
const workerEnv = (env ?? {}) as unknown as Record<
249+
string,
250+
string | undefined
251+
>;
252+
const projectId = workerEnv.PUBLIC_SANITY_PROJECT_ID || "hfh83o0w";
253+
const dataset = workerEnv.PUBLIC_SANITY_DATASET || "production";
254+
const apiVersion = workerEnv.PUBLIC_SANITY_API_VERSION || "2025-09-30";
255+
const writeToken =
256+
workerEnv.SANITY_API_WRITE_TOKEN ||
257+
workerEnv.SANITY_AUTH_TOKEN ||
258+
workerEnv.SANITY_WRITE_TOKEN;
259+
260+
if (!writeToken) {
261+
return null;
262+
}
263+
264+
const match = dataUrl.match(
265+
/^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/,
266+
);
267+
if (!match) {
268+
throw new Error(
269+
"Invalid image format. Please upload a JPEG, PNG, or WebP image.",
270+
);
271+
}
272+
273+
const mimeType = match[1];
274+
const base64Data = match[2];
275+
const binaryStr = atob(base64Data);
276+
const len = binaryStr.length;
277+
278+
if (len < 64 || len > 2 * 1024 * 1024) {
279+
throw new Error("Profile photo must be under 2 MB.");
280+
}
281+
282+
const bytes = new Uint8Array(len);
283+
for (let i = 0; i < len; i++) {
284+
bytes[i] = binaryStr.charCodeAt(i);
285+
}
286+
287+
// Verify magic bytes for JPEG (FF D8 FF), PNG (89 50 4E 47), or WebP (RIFF....WEBP)
288+
const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
289+
const isPng =
290+
bytes[0] === 0x89 &&
291+
bytes[1] === 0x50 &&
292+
bytes[2] === 0x4e &&
293+
bytes[3] === 0x47;
294+
const isWebp =
295+
bytes[0] === 0x52 &&
296+
bytes[1] === 0x49 &&
297+
bytes[2] === 0x46 &&
298+
bytes[3] === 0x46 &&
299+
bytes[8] === 0x57 &&
300+
bytes[9] === 0x45 &&
301+
bytes[10] === 0x42 &&
302+
bytes[11] === 0x50;
303+
304+
if (!isJpeg && !isPng && !isWebp) {
305+
throw new Error("Invalid image file header.");
306+
}
307+
308+
const ext =
309+
mimeType === "image/png"
310+
? "png"
311+
: mimeType === "image/webp"
312+
? "webp"
313+
: "jpg";
314+
const cleanFilename = `${filenameBase.replace(/[^a-zA-Z0-9_-]/g, "-")}.${ext}`;
315+
316+
const uploadUrl = `https://${projectId}.api.sanity.io/v${apiVersion}/assets/images/${dataset}?filename=${encodeURIComponent(cleanFilename)}`;
317+
const res = await fetch(uploadUrl, {
318+
method: "POST",
319+
headers: {
320+
Authorization: `Bearer ${writeToken}`,
321+
"Content-Type": mimeType,
322+
},
323+
body: bytes,
324+
});
325+
326+
if (!res.ok) {
327+
const text = await res.text();
328+
throw new Error(`Sanity image upload failed (${res.status}): ${text}`);
329+
}
330+
331+
const json = (await res.json()) as {
332+
document?: { _id?: string; url?: string };
333+
};
334+
if (!json.document?._id || !json.document?.url) {
335+
throw new Error("Sanity did not return an uploaded image asset document.");
336+
}
337+
338+
return {
339+
_id: json.document._id,
340+
url: json.document.url,
341+
};
342+
}

‎apps/site/src/pages/api/guest-portal/update.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import {
55
sanitizePortableTextBlocks,
66
verifyGuestPortalToken,
77
} from "@/lib/guest-portal";
8-
import { getSanityServerClient } from "@/lib/sanity/write-client";
8+
import {
9+
getSanityServerClient,
10+
uploadSanityImageDataUrl,
11+
} from "@/lib/sanity/write-client";
912

1013
export const POST: APIRoute = async ({ request }) => {
1114
try {
@@ -15,6 +18,7 @@ export const POST: APIRoute = async ({ request }) => {
1518
websiteUrl?: string;
1619
excerpt?: string;
1720
content?: unknown[];
21+
avatarDataUrl?: string;
1822
socials?: Record<string, string>;
1923
};
2024

@@ -129,6 +133,27 @@ export const POST: APIRoute = async ({ request }) => {
129133
}
130134
}
131135

136+
let uploadedAvatarUrl: string | undefined;
137+
if (
138+
typeof body.avatarDataUrl === "string" &&
139+
body.avatarDataUrl.startsWith("data:image/")
140+
) {
141+
const uploadedAsset = await uploadSanityImageDataUrl(
142+
body.avatarDataUrl,
143+
verification.payload?.slug || existingGuest._id,
144+
);
145+
if (uploadedAsset) {
146+
patchSet.coverImage = {
147+
_type: "image",
148+
asset: {
149+
_type: "reference",
150+
_ref: uploadedAsset._id,
151+
},
152+
};
153+
uploadedAvatarUrl = uploadedAsset.url;
154+
}
155+
}
156+
132157
if (canWrite) {
133158
await client.patch(existingGuest._id).set(patchSet).commit();
134159
}
@@ -138,6 +163,7 @@ export const POST: APIRoute = async ({ request }) => {
138163
ok: true,
139164
guestId: existingGuest._id,
140165
socials: mergedSocials,
166+
avatarUrl: uploadedAvatarUrl,
141167
persistedToSanity: canWrite,
142168
}),
143169
{ status: 200, headers: { "Content-Type": "application/json" } },

‎apps/site/src/pages/guest-portal/[token].astro‎

Lines changed: 125 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)