diff --git a/src/components/Bars.tsx b/src/components/Bars.tsx index 298b6ad..278be55 100644 --- a/src/components/Bars.tsx +++ b/src/components/Bars.tsx @@ -25,6 +25,23 @@ function formatDomainName(url: string): string { return withoutWww.charAt(0).toUpperCase() + withoutWww.slice(1); } +/** + * Converts a banner photo URL to its thumbnail version + * Replaces the filename with -small variant (e.g., image.jpg -> image-small.jpg) + */ +function getThumbnailUrl(bannerPhoto: string | null): string { + if (!bannerPhoto) return "/blueprint.jpg"; + + // Check if URL contains a file extension + const match = bannerPhoto.match(/^(.+)(\.[^.]+)$/); + if (match) { + return `${match[1]}-small${match[2]}`; + } + + // Fallback to original if no extension found + return bannerPhoto; +} + interface GitCommit { sha: string; message: string; @@ -393,9 +410,16 @@ export function LeftBar() {
post-cover { + // Fallback to full banner if thumbnail doesn't exist + const img = e.currentTarget; + if (img.src !== (post.banner_photo || "/blueprint.jpg")) { + img.src = post.banner_photo || "/blueprint.jpg"; + } + }} /> {insertSoftHyphens(post.title.replace(/_/g, " "))} diff --git a/src/lib/resize-utils.ts b/src/lib/resize-utils.ts index 3cc47a2..83a7015 100644 --- a/src/lib/resize-utils.ts +++ b/src/lib/resize-utils.ts @@ -58,3 +58,83 @@ export function createIsMobile( ): Accessor { return () => isMobile(windowWidth()); } + +/** + * Resizes an image file to a maximum width/height while maintaining aspect ratio + * @param file Original image file + * @param maxWidth Maximum width in pixels + * @param maxHeight Maximum height in pixels + * @param quality JPEG quality (0-1), default 0.85 + * @returns Resized image as Blob + */ +export async function resizeImage( + file: File | Blob, + maxWidth: number, + maxHeight: number, + quality: number = 0.85 +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + reject(new Error("Failed to get canvas context")); + return; + } + + img.onload = () => { + let { width, height } = img; + + // Calculate new dimensions maintaining aspect ratio + if (width > maxWidth || height > maxHeight) { + const aspectRatio = width / height; + + if (width > height) { + width = Math.min(width, maxWidth); + height = width / aspectRatio; + } else { + height = Math.min(height, maxHeight); + width = height * aspectRatio; + } + } + + canvas.width = width; + canvas.height = height; + + // Draw image on canvas with new dimensions + ctx.drawImage(img, 0, 0, width, height); + + // Convert canvas to blob + canvas.toBlob( + (blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Failed to create blob from canvas")); + } + }, + "image/jpeg", + quality + ); + }; + + img.onerror = () => { + reject(new Error("Failed to load image")); + }; + + // Load image from file + const reader = new FileReader(); + reader.onload = (e) => { + if (e.target?.result) { + img.src = e.target.result as string; + } else { + reject(new Error("Failed to read file")); + } + }; + reader.onerror = () => { + reject(new Error("FileReader error")); + }; + reader.readAsDataURL(file); + }); +} diff --git a/src/lib/s3upload.ts b/src/lib/s3upload.ts index a39b3a7..550946d 100644 --- a/src/lib/s3upload.ts +++ b/src/lib/s3upload.ts @@ -4,6 +4,7 @@ */ import { api } from "~/lib/api"; +import { resizeImage } from "~/lib/resize-utils"; export default async function AddImageToS3( file: Blob | File, @@ -26,7 +27,7 @@ export default async function AddImageToS3( const ext = /^.+\.([^.]+)$/.exec(filename); const contentType = ext ? `image/${ext[1]}` : "application/octet-stream"; - // Upload file to S3 using pre-signed URL + // Upload original file to S3 using pre-signed URL const uploadResponse = await fetch(uploadURL, { method: "PUT", headers: { @@ -39,6 +40,47 @@ export default async function AddImageToS3( throw new Error("Failed to upload file to S3"); } + // For blog cover images, also create and upload a thumbnail + if (type === "blog") { + try { + // Create thumbnail (max 200x200px for sidebar display) + const thumbnail = await resizeImage(file, 200, 200, 0.8); + + // Generate thumbnail filename: insert "-small" before extension + const thumbnailFilename = filename.replace( + /(\.[^.]+)$/, + "-small$1" + ); + + // Get pre-signed URL for thumbnail + const { uploadURL: thumbnailUploadURL } = + await api.misc.getPreSignedURL.mutate({ + type, + title, + filename: thumbnailFilename + }); + + // Upload thumbnail to S3 + const thumbnailUploadResponse = await fetch(thumbnailUploadURL, { + method: "PUT", + headers: { + "Content-Type": "image/jpeg" // Thumbnails are always JPEG + }, + body: thumbnail + }); + + if (!thumbnailUploadResponse.ok) { + console.error("Failed to upload thumbnail to S3"); + // Don't fail the entire upload if thumbnail fails + } else { + console.log("Thumbnail uploaded successfully"); + } + } catch (thumbnailError) { + console.error("Thumbnail creation/upload failed:", thumbnailError); + // Don't fail the entire upload if thumbnail fails + } + } + return key; } catch (e) { console.error("S3 upload error:", e);