From e8dc2b373b690c9f10fc36c36c877d983c33e59b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:01:41 +0800 Subject: [PATCH 1/3] feat: implement deferred asset and image optimization items --- package-lock.json | 5 +- package.json | 4 +- scripts/optimize-public-images.mjs | 59 +++++++++++++++++++ src/app/api/images/[id]/signed-url/route.ts | 12 +++- src/app/layout.tsx | 3 + .../clinical-dashboard/signed-image.tsx | 23 +++++++- worker/main.ts | 14 +++++ 7 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 scripts/optimize-public-images.mjs diff --git a/package-lock.json b/package-lock.json index e0beaad4f7..f7d78f8e99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "react": "19.2.7", "react-dom": "19.2.7", "server-only": "^0.0.1", + "sharp": "0.35.3", "zod": "^4.4.3" }, "devDependencies": { @@ -1416,7 +1417,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -5781,7 +5781,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -10769,7 +10768,6 @@ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", @@ -10819,7 +10817,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index dc602114bb..d5e5dac30f 100644 --- a/package.json +++ b/package.json @@ -199,7 +199,8 @@ "drift:manifest": "node scripts/run-tsx.mjs scripts/generate-drift-manifest.ts", "sync:pr-branches": "node scripts/sync-open-pr-branches.mjs", "sync:pr-branches:apply": "node scripts/sync-open-pr-branches.mjs --apply", - "check:assets": "node scripts/check-assets.mjs" + "check:assets": "node scripts/check-assets.mjs", + "optimize:images": "node scripts/optimize-public-images.mjs" }, "dependencies": { "@next/env": "16.2.11", @@ -218,6 +219,7 @@ "react": "19.2.7", "react-dom": "19.2.7", "server-only": "^0.0.1", + "sharp": "0.35.3", "zod": "^4.4.3" }, "overrides": { diff --git a/scripts/optimize-public-images.mjs b/scripts/optimize-public-images.mjs new file mode 100644 index 0000000000..d6e1ea1456 --- /dev/null +++ b/scripts/optimize-public-images.mjs @@ -0,0 +1,59 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import sharp from "sharp"; + +const DIRECTORIES = ["public/mockups", "public/demo-documents"]; + +async function walkDir(dir) { + let results = []; + try { + const list = await fs.readdir(dir); + for (const file of list) { + const filePath = path.join(dir, file); + const stat = await fs.stat(filePath); + if (stat && stat.isDirectory()) { + results = results.concat(await walkDir(filePath)); + } else { + if (filePath.toLowerCase().endsWith(".png")) { + results.push(filePath); + } + } + } + } catch (err) { + if (err.code !== "ENOENT") { + console.error(`Error reading directory ${dir}:`, err); + } + } + return results; +} + +async function optimizeImages() { + console.log("Optimizing public images to WebP and AVIF..."); + + let totalFiles = 0; + for (const dir of DIRECTORIES) { + const pngFiles = await walkDir(dir); + + for (const file of pngFiles) { + const ext = path.extname(file); + const base = file.slice(0, -ext.length); + const webpPath = `${base}.webp`; + const avifPath = `${base}.avif`; + + try { + await sharp(file).webp({ quality: 80 }).toFile(webpPath); + await sharp(file).avif({ quality: 80 }).toFile(avifPath); + totalFiles++; + console.log(`Optimized: ${file}`); + } catch (err) { + console.error(`Failed to optimize ${file}:`, err); + } + } + } + console.log(`Successfully generated variants for ${totalFiles} images.`); +} + +optimizeImages().catch((err) => { + console.error("Image optimization failed:", err); + process.exit(1); +}); diff --git a/src/app/api/images/[id]/signed-url/route.ts b/src/app/api/images/[id]/signed-url/route.ts index a71cd91238..c77b4cb83f 100644 --- a/src/app/api/images/[id]/signed-url/route.ts +++ b/src/app/api/images/[id]/signed-url/route.ts @@ -15,7 +15,7 @@ export const runtime = "nodejs"; const signedUrlTtlSeconds = env.DOCUMENT_SIGNED_URL_TTL_SECONDS; const routeIdSchema = z.string().uuid(); -export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; if (isDemoMode()) { @@ -33,7 +33,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid image id."); const supabase = createAdminClient(); - const { access, rateLimit } = await enforceDocumentReadRateLimit(_request, supabase); + const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); if (rateLimit.limited) { return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); } @@ -62,9 +62,15 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ error: "Image not found." }, { status: 404 }); } + const { searchParams } = new URL(request.url); + const widthParam = searchParams.get("w"); + const transform = widthParam + ? { width: parseInt(widthParam, 10), resize: "contain" as const } + : undefined; + const signed = await supabase.storage .from(env.SUPABASE_IMAGE_BUCKET) - .createSignedUrl(image.storage_path, signedUrlTtlSeconds); + .createSignedUrl(image.storage_path, signedUrlTtlSeconds, { transform }); if (signed.error) throw new Error(signed.error.message); return NextResponse.json({ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5912586bbb..39f2b61627 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from "next"; import localFont from "next/font/local"; import { cookies, headers } from "next/headers"; +import ReactDOM from "react-dom"; import { AuthProvider } from "@/lib/supabase/client"; import { AccountDataProvider } from "@/components/account-data-provider"; import { PwaLifecycle } from "@/components/pwa-lifecycle"; @@ -85,6 +86,8 @@ export default async function RootLayout({ const isDark = clinicalTheme === "dark"; const themeClass = isDark ? "dark" : ""; + ReactDOM.preload("/icon.svg", { as: "image", type: "image/svg+xml", fetchPriority: "high" }); + return ( Boolean(getCachedSignedUrl(endpoint))); const [loaded, setLoaded] = useState(false); const [lightboxOpen, setLightboxOpen] = useState(false); const frameRef = useRef(null); const triggerRef = useRef(null); - const { url, failed, retry, markFailed } = useSignedImageUrl(endpoint, shouldLoad); + const resolvedEndpoint = thumbnail + ? endpoint.includes("?") ? `${endpoint}&w=400` : `${endpoint}?w=400` + : endpoint; + const { url, failed, retry, markFailed } = useSignedImageUrl(resolvedEndpoint, shouldLoad); // Defer the request until the frame is near the viewport. A cached URL seeds // `shouldLoad` synchronously, so already-fetched images skip the observer. @@ -187,9 +196,17 @@ export const SignedImage = memo(function SignedImage({ ) : null} {!url || !loaded ? ( -
+
{shouldLoad ? ( - + placeholderBase64 ? ( +