[{"data":1,"prerenderedAt":281},["ShallowReactive",2],{"project-\u002Fprojects\u002Fportfolio-photo-en":3,"related-\u002Fprojects\u002Fportfolio-photo-en":260,"legal-link-en":279},{"id":4,"title":5,"body":6,"description":238,"extension":239,"git":240,"git_soon":243,"meta":244,"navigation":245,"order":246,"path":247,"seo":248,"stack":249,"stem":253,"type":254,"updated":255,"web":256,"__hash__":259},"projects_en\u002Fprojects\u002Fportfolio-photo.md","Photo Portfolio",{"type":7,"value":8,"toc":222},"minimark",[9,17,22,25,29,82,86,148,152,155,158,162,167,174,176,180,186,188,192,198,200,204,210,212,216],[10,11,12,16],"p",{},[13,14,15],"strong",{},"Replacing an Adobe Lightroom subscription with my own tool"," — a photography portfolio and its admin back-office, from object storage to public gallery, built entirely from scratch.",[18,19,21],"h2",{"id":20},"whats-the-context-behind-the-photo-portfolio","What's the context behind the Photo Portfolio?",[10,23,24],{},"I was using Adobe Lightroom to publish my photos online, but the Creative Cloud subscription costs significantly more than what the feature actually provides — essentially a static image gallery. I built my own photography portfolio from scratch, with an integrated admin dashboard to manage albums and photos directly from the browser. The project covers the entire chain: object storage, on-demand resizing, public gallery, and admin interface.",[18,26,28],{"id":27},"whats-the-tech-stack","What's the tech stack?",[30,31,32,44,50,56,66,72],"ul",{},[33,34,35,38,39,43],"li",{},[13,36,37],{},"Astro 4 (hybrid)"," — ",[40,41,42],"code",{},"output: 'hybrid'"," mode: public pages are statically pre-rendered at build time, API routes and the admin run as SSR. Avoids a full-SPA framework for what is mostly static content.",[33,45,46,49],{},[13,47,48],{},"Vue 3"," — used only for interactive components (gallery, lightbox, admin). Astro loads Vue on demand via islands; everything else is plain HTML.",[33,51,52,55],{},[13,53,54],{},"Cloudflare R2"," — S3-compatible object storage with no egress fees on public reads, compatible with the AWS SDK. Album metadata is stored as JSON directly in R2 (no database).",[33,57,58,61,62,65],{},[13,59,60],{},"Cloudflare Image Resizing"," — on-demand resizing via URL (",[40,63,64],{},"\u002Fcdn-cgi\u002Fimage\u002Fwidth=1200,quality=78,format=webp\u002F...","). No thumbnail pre-generation needed; CDN cache is built in.",[33,67,68,71],{},[13,69,70],{},"Netlify"," — hosting with Astro adapter. A build webhook allows re-deploying the static site from the admin (\"Publish\" button).",[33,73,74,77,78,81],{},[13,75,76],{},"justified-layout"," — Flickr's open-source library for computing justified grid positions. Recalculated via ",[40,79,80],{},"ResizeObserver"," on every container width change.",[18,83,85],{"id":84},"notable-technical-points","Notable Technical Points",[30,87,88,98,112,118,128,142],{},[33,89,90,93,94,97],{},[13,91,92],{},"Direct browser → R2 upload via presigned URLs",": the ",[40,95,96],{},"\u002Fapi\u002Fpresign"," endpoint generates a signed S3 URL (TTL 3600s), and the browser PUTs the file directly to R2 without routing through the Astro server. Keeps bandwidth costs low and avoids timeouts on large files.",[33,99,100,103,104,107,108,111],{},[13,101,102],{},"No database",": album and photo metadata is stored as JSON files in R2 (",[40,105,106],{},"albums\u002Findex.json",", ",[40,109,110],{},"albums\u002F{slug}.json","). Sufficient for a personal collection, eliminates a service to maintain.",[33,113,114,117],{},[13,115,116],{},"Custom lightbox"," (no external dependency): progressive loading (blurred thumbnail → full-res), wheel\u002Fpinch\u002Fdouble-tap zoom (max 5×), clamped panning, swipe navigation, full keyboard support, EXIF data display (camera, lens, aperture, shutter speed, ISO, location).",[33,119,120,123,124,127],{},[13,121,122],{},"EXIF metadata preserved end-to-end",": the CLI script extracts EXIF data via ",[40,125,126],{},"exifr"," (camera, lens, GPS, capture date) and stores it as structured data in album JSON. Displayed in the public lightbox.",[33,129,130,133,134,137,138,141],{},[13,131,132],{},"Batch import script"," (",[40,135,136],{},"npx tsx scripts\u002Fimport.ts --album tokyo-2025 ~\u002FPhotos\u002F*.jpg","): concurrent uploads (3 files in parallel), automatic EXIF extraction, ",[40,139,140],{},"immutable"," 1-year cache headers on R2 objects, atomic index update.",[33,143,144,147],{},[13,145,146],{},"On-demand static rebuild",": the \"Publish site\" button in the admin triggers a POST to a Netlify build hook — the public site is re-generated statically with fresh data, without exposing dynamic content endpoints in production.",[18,149,151],{"id":150},"what-i-learned","What I Learned",[10,153,154],{},"The main challenge was the lightbox: handling zoom + pan + swipe coherently on mobile without a library, with smooth transitions that don't disorient users on small screens. The other interesting decision was choosing not to use a database — JSON-in-R2 is a constraint that radically simplifies deployment and is more than sufficient for this use case.",[156,157],"hr",{},[18,159,161],{"id":160},"diagrams","Diagrams",[163,164,166],"h3",{"id":165},"global-architecture","Global Architecture",[10,168,169],{},[170,171],"img",{"alt":172,"src":173},"Global architecture","\u002Fdiagrams\u002Fportfolio-photo\u002Farchitecture.svg",[156,175],{},[163,177,179],{"id":178},"photo-upload-pipeline","Photo Upload Pipeline",[10,181,182],{},[170,183],{"alt":184,"src":185},"Upload pipeline","\u002Fdiagrams\u002Fportfolio-photo\u002Fupload.svg",[156,187],{},[163,189,191],{"id":190},"image-delivery-pipeline","Image Delivery Pipeline",[10,193,194],{},[170,195],{"alt":196,"src":197},"Delivery pipeline","\u002Fdiagrams\u002Fportfolio-photo\u002Fdelivery.svg",[156,199],{},[163,201,203],{"id":202},"page-structure-routing","Page Structure & Routing",[10,205,206],{},[170,207],{"alt":208,"src":209},"Routing","\u002Fdiagrams\u002Fportfolio-photo\u002Frouting.svg",[156,211],{},[163,213,215],{"id":214},"data-flow-album-page-render","Data Flow — Album Page Render",[10,217,218],{},[170,219],{"alt":220,"src":221},"Data flow","\u002Fdiagrams\u002Fportfolio-photo\u002Fdataflow.svg",{"title":223,"searchDepth":224,"depth":224,"links":225},"",2,[226,227,228,229,230],{"id":20,"depth":224,"text":21},{"id":27,"depth":224,"text":28},{"id":84,"depth":224,"text":85},{"id":150,"depth":224,"text":151},{"id":160,"depth":224,"text":161,"children":231},[232,234,235,236,237],{"id":165,"depth":233,"text":166},3,{"id":178,"depth":233,"text":179},{"id":190,"depth":233,"text":191},{"id":202,"depth":233,"text":203},{"id":214,"depth":233,"text":215},"Full-stack photography portfolio with justified gallery, custom lightbox, and admin dashboard — built to replace an overpriced Adobe Lightroom subscription.","md",[241,242],"Git Repository","https:\u002F\u002Fgithub.com\u002FOwenLB\u002Fportfolio-photo",false,{},true,5,"\u002Fprojects\u002Fportfolio-photo",{"title":5,"description":238},[250,48,251,252,54,60,70],"Astro 4","TypeScript","Tailwind CSS","projects\u002Fportfolio-photo","Full-stack site","2026-07-17",[257,258],"Live site","https:\u002F\u002Fphoto.owenlebec.fr","tOMDenV_ifv-3INl1d0Ikk4CHcNlveOYL87GTjo7XvQ",[261,266,270,274],{"title":262,"type":263,"path":264,"order":265},"Finixa","Web & mobile app","\u002Fprojects\u002Ffinixa",1,{"title":267,"type":268,"path":269,"order":224},"Peliculle","iOS app","\u002Fprojects\u002Fpeliculle",{"title":271,"type":272,"path":273,"order":233},"ScanAuto","Web app","\u002Fprojects\u002Fscanauto",{"title":275,"type":276,"path":277,"order":278},"Developer Portfolio","Website","\u002Fprojects\u002Fportfolio-dev",4,{"title":280},"Legal notices",1784470460464]