diff --git a/package-lock.json b/package-lock.json index a1ef3aa6c9..9643fde71e 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" } @@ -5889,7 +5889,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" @@ -10847,7 +10846,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", @@ -10897,7 +10895,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 57c6b0f795..7cf28c35e3 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,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", @@ -221,6 +222,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/phone-mockup-dev.err b/phone-mockup-dev.err new file mode 100644 index 0000000000..1edd23c22e --- /dev/null +++ b/phone-mockup-dev.err @@ -0,0 +1,8 @@ +⨯ Another next dev server is already running. + +- Local: http://localhost:4500 +- PID: 104364 +- Dir: C:\Dev\Apps\Database +- Log: .next\dev\logs\next-development.log + +Run taskkill /PID 104364 /F to stop it. diff --git a/phone-mockup-dev.log b/phone-mockup-dev.log new file mode 100644 index 0000000000..535836791e --- /dev/null +++ b/phone-mockup-dev.log @@ -0,0 +1,18 @@ +Starting Clinical KB at http://localhost:4510 (configured port). +▲ Next.js 16.2.11 (webpack) +- Local: http://localhost:4510 +- Network: http://0.0.0.0:4510 +- Environments: .env.local +✓ Ready in 13.4s○ Compiling /instrumentation ... +- Experiments (use with caution): + · cpus: 1 + · optimizePackageImports + · proxyClientMaxBodySize: "151mb" + +○ Compiling proxy ... +○ Compiling /api/local-project-id ... +○ Compiling / ... +○ Compiling /api/local-project-id ... + GET /api/local-project-id 200 in 444ms (next.js: 100ms, proxy.ts: 136ms, application-code: 208ms) + GET /api/local-project-id 200 in 32ms (next.js: 6ms, proxy.ts: 10ms, application-code: 16ms) + GET /api/local-project-id 200 in 88ms (next.js: 11ms, proxy.ts: 29ms, application-code: 48ms) 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/api/upload/route.ts b/src/app/api/upload/route.ts index c5e41b80f2..1d091f4977 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -290,16 +290,23 @@ export async function POST(request: Request) { insertedDocumentOwnerId = uploadOwnerId; assertUploadNotAborted(request); - await writeAuditLog(supabase, { - ownerId: uploadOwnerId, - action: "document_upload", - resourceType: "document", - resourceId: documentId, - // `audit_logs` is retained indefinitely. Keep only operational facts there; - // the user-controlled filename and content hash remain on the scoped document - // record, not in the durable audit trail. - metadata: { fileType: file.type, fileSize: file.size }, - }); + try { + await writeAuditLog(supabase, { + ownerId: uploadOwnerId, + action: "document_upload", + resourceType: "document", + resourceId: documentId, + // `audit_logs` is retained indefinitely. Keep only operational facts there; + // the user-controlled filename and content hash remain on the scoped document + // record, not in the durable audit trail. + metadata: { fileType: file.type, fileSize: file.size }, + }); + } catch (auditError) { + logger.warn("Upload succeeded but audit log failed", { + documentId, + message: auditError instanceof Error ? auditError.message : String(auditError), + }); + } return NextResponse.json({ document, job }, { status: 201 }); } catch (error) { 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 ? ( +