Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions apps/site/src/lib/sanity/write-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,103 @@ export function getSanityServerClient(): {
canWrite: Boolean(writeToken),
};
}

export async function uploadSanityImageDataUrl(
dataUrl: string,
filenameBase = "guest-avatar",
): Promise<{ _id: string; url: string } | null> {
const workerEnv = (env ?? {}) as unknown as Record<
string,
string | undefined
>;
const projectId = workerEnv.PUBLIC_SANITY_PROJECT_ID || "hfh83o0w";
const dataset = workerEnv.PUBLIC_SANITY_DATASET || "production";
const apiVersion = workerEnv.PUBLIC_SANITY_API_VERSION || "2025-09-30";
const writeToken =
workerEnv.SANITY_API_WRITE_TOKEN ||
workerEnv.SANITY_AUTH_TOKEN ||
workerEnv.SANITY_WRITE_TOKEN;

if (!writeToken) {
return null;
}

const match = dataUrl.match(
/^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/,
);
if (!match) {
throw new Error(
"Invalid image format. Please upload a JPEG, PNG, or WebP image.",
);
}

const mimeType = match[1];
const base64Data = match[2];
const binaryStr = atob(base64Data);
const len = binaryStr.length;

if (len < 64 || len > 2 * 1024 * 1024) {
throw new Error("Profile photo must be under 2 MB.");
}

const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}

// Verify magic bytes for JPEG (FF D8 FF), PNG (89 50 4E 47), or WebP (RIFF....WEBP)
const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
const isPng =
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47;
const isWebp =
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50;

if (!isJpeg && !isPng && !isWebp) {
throw new Error("Invalid image file header.");
}

const ext =
mimeType === "image/png"
? "png"
: mimeType === "image/webp"
? "webp"
: "jpg";
const cleanFilename = `${filenameBase.replace(/[^a-zA-Z0-9_-]/g, "-")}.${ext}`;

const uploadUrl = `https://${projectId}.api.sanity.io/v${apiVersion}/assets/images/${dataset}?filename=${encodeURIComponent(cleanFilename)}`;
const res = await fetch(uploadUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${writeToken}`,
"Content-Type": mimeType,
},
body: bytes,
});

if (!res.ok) {
const text = await res.text();
throw new Error(`Sanity image upload failed (${res.status}): ${text}`);
}

const json = (await res.json()) as {
document?: { _id?: string; url?: string };
};
if (!json.document?._id || !json.document?.url) {
throw new Error("Sanity did not return an uploaded image asset document.");
}

return {
_id: json.document._id,
url: json.document.url,
};
}
28 changes: 27 additions & 1 deletion apps/site/src/pages/api/guest-portal/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {
sanitizePortableTextBlocks,
verifyGuestPortalToken,
} from "@/lib/guest-portal";
import { getSanityServerClient } from "@/lib/sanity/write-client";
import {
getSanityServerClient,
uploadSanityImageDataUrl,
} from "@/lib/sanity/write-client";

export const POST: APIRoute = async ({ request }) => {
try {
Expand All @@ -15,6 +18,7 @@ export const POST: APIRoute = async ({ request }) => {
websiteUrl?: string;
excerpt?: string;
content?: unknown[];
avatarDataUrl?: string;
socials?: Record<string, string>;
};

Expand Down Expand Up @@ -129,6 +133,27 @@ export const POST: APIRoute = async ({ request }) => {
}
}

let uploadedAvatarUrl: string | undefined;
if (
typeof body.avatarDataUrl === "string" &&
body.avatarDataUrl.startsWith("data:image/")
) {
const uploadedAsset = await uploadSanityImageDataUrl(
body.avatarDataUrl,
verification.payload?.slug || existingGuest._id,
);
if (uploadedAsset) {
patchSet.coverImage = {
_type: "image",
asset: {
_type: "reference",
_ref: uploadedAsset._id,
},
};
uploadedAvatarUrl = uploadedAsset.url;
}
}

if (canWrite) {
await client.patch(existingGuest._id).set(patchSet).commit();
}
Expand All @@ -138,6 +163,7 @@ export const POST: APIRoute = async ({ request }) => {
ok: true,
guestId: existingGuest._id,
socials: mergedSocials,
avatarUrl: uploadedAvatarUrl,
persistedToSanity: canWrite,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
Expand Down
134 changes: 125 additions & 9 deletions apps/site/src/pages/guest-portal/[token].astro
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,12 @@ const socialFields = [
<div class="rounded-2xl border border-border bg-card p-6 shadow-sm sm:p-8">
<div class="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-4">
{guestDoc.avatarUrl && (
<img
src={`${guestDoc.avatarUrl}?w=160&h=160&fit=crop&auto=format`}
alt={guestDoc.title}
class="h-16 w-16 rounded-full object-cover ring-2 ring-primary/30"
/>
)}
<img
id="header-avatar-img"
src={guestDoc.avatarUrl ? `${guestDoc.avatarUrl}?w=160&h=160&fit=crop&auto=format` : "https://codingcat.dev/images/logo.png"}
alt={guestDoc.title}
class="h-16 w-16 rounded-full object-cover ring-2 ring-primary/30"
/>
<div>
<span class="inline-flex items-center rounded-full bg-emerald-500/15 px-3 py-1 text-xs font-medium text-emerald-400">
Verified Sanity Guest Magic Link
Expand Down Expand Up @@ -271,6 +270,40 @@ const socialFields = [
data-token={token}
class="rounded-2xl border border-border bg-card p-6 shadow-sm sm:p-8 space-y-6"
>
<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">
<div class="flex items-center gap-4">
<img
id="avatar-preview-img"
src={guestDoc.avatarUrl ? `${guestDoc.avatarUrl}?w=160&h=160&fit=crop&auto=format` : "https://codingcat.dev/images/logo.png"}
alt={guestDoc.title}
class="h-16 w-16 rounded-full object-cover ring-2 ring-primary/40"
/>
<div>
<h2 class="text-sm font-semibold text-foreground">
Profile Photo
</h2>
<p id="avatar-status-text" class="mt-0.5 text-xs text-muted-foreground">
Upload a new JPG, PNG, or WebP speaker headshot (automatically optimized before saving to Sanity).
</p>
</div>
</div>
<div class="flex items-center gap-2">
<input
id="field-avatar-file"
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
/>
<button
type="button"
id="choose-avatar-btn"
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"
>
Upload New Photo
</button>
</div>
</div>

<div class="grid gap-5 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-foreground" for="field-title">
Expand Down Expand Up @@ -538,6 +571,78 @@ const socialFields = [
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;
const avatarFileInput = document.getElementById("field-avatar-file") as HTMLInputElement | null;
const chooseAvatarBtn = document.getElementById("choose-avatar-btn") as HTMLButtonElement | null;
const avatarPreviewImg = document.getElementById("avatar-preview-img") as HTMLImageElement | null;
const headerAvatarImg = document.getElementById("header-avatar-img") as HTMLImageElement | null;
const avatarStatusText = document.getElementById("avatar-status-text") as HTMLElement | null;

let stagedAvatarDataUrl: string | undefined;

async function resizeImageFileToDataUrl(file: File, maxDim = 800): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("Could not read selected image file."));
reader.onload = () => {
const img = new Image();
img.onerror = () => reject(new Error("Invalid or unsupported image file."));
img.onload = () => {
let width = img.naturalWidth || img.width;
let height = img.naturalHeight || img.height;
if (width > maxDim || height > maxDim) {
if (width >= height) {
height = Math.round((height * maxDim) / width);
width = maxDim;
} else {
width = Math.round((width * maxDim) / height);
height = maxDim;
}
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Browser canvas is unavailable."));
return;
}
ctx.drawImage(img, 0, 0, width, height);
const dataUrl = canvas.toDataURL("image/jpeg", 0.9);
resolve(dataUrl);
};
img.src = String(reader.result);
};
reader.readAsDataURL(file);
});
}

chooseAvatarBtn?.addEventListener("click", () => {
avatarFileInput?.click();
});

avatarFileInput?.addEventListener("change", async () => {
const file = avatarFileInput.files?.[0];
if (!file) return;
try {
if (avatarStatusText) {
avatarStatusText.className = "mt-0.5 text-xs text-muted-foreground";
avatarStatusText.textContent = "Optimizing photo...";
}
const dataUrl = await resizeImageFileToDataUrl(file, 800);
stagedAvatarDataUrl = dataUrl;
if (avatarPreviewImg) avatarPreviewImg.src = dataUrl;
if (headerAvatarImg) headerAvatarImg.src = dataUrl;
if (avatarStatusText) {
avatarStatusText.className = "mt-0.5 text-xs font-medium text-emerald-400";
avatarStatusText.textContent = `Ready to save (${file.name}) — click "Save Profile to Sanity" below.`;
}
} catch (err) {
if (avatarStatusText) {
avatarStatusText.className = "mt-0.5 text-xs font-medium text-red-400";
avatarStatusText.textContent = err instanceof Error ? err.message : "Could not load photo.";
}
}
});

interface PtSpan {
_type: "span";
Expand Down Expand Up @@ -855,15 +960,26 @@ const socialFields = [
websiteUrl: websiteInput?.value.trim(),
excerpt: excerptInput?.value.trim(),
content,
avatarDataUrl: stagedAvatarDataUrl,
socials,
}),
});
const data = (await res.json()) as { error?: string };
const data = (await res.json()) as { error?: string; avatarUrl?: string };
if (!res.ok) {
throw new Error(data.error || "Failed to update profile");
}
if (data.avatarUrl) {
const freshUrl = `${data.avatarUrl}?w=160&h=160&fit=crop&auto=format`;
if (avatarPreviewImg) avatarPreviewImg.src = freshUrl;
if (headerAvatarImg) headerAvatarImg.src = freshUrl;
stagedAvatarDataUrl = undefined;
if (avatarStatusText) {
avatarStatusText.className = "mt-0.5 text-xs font-medium text-emerald-400";
avatarStatusText.textContent = "Profile photo uploaded and saved to Sanity!";
}
}
statusEl.className = "text-sm font-medium text-emerald-400";
statusEl.textContent = "Saved! Your CodingCat.dev speaker profile and About section have been updated.";
statusEl.textContent = "Saved! Your CodingCat.dev speaker profile, photo, 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";
Expand Down
Loading