My personal photography website steve.phot
portfolio photography svelte sveltekit

feat: added base64 thumbnails

+80 -36
+1
bun.lock
··· 16 16 "@sveltejs/kit": "^2.49.1", 17 17 "@sveltejs/vite-plugin-svelte": "^6.2.1", 18 18 "@tailwindcss/vite": "^4.1.17", 19 + "sharp": "^0.34.5", 19 20 "svelte": "^5.45.6", 20 21 "svelte-check": "^4.3.4", 21 22 "tailwindcss": "^4.1.17",
+32 -31
package.json
··· 1 1 { 2 - "name": "steve.photo", 3 - "private": true, 4 - "version": "0.1.0", 5 - "type": "module", 6 - "scripts": { 7 - "dev": "vite dev", 8 - "build": "vite build", 9 - "deploy": "bun run build && wrangler deploy --minify", 10 - "preview": "vite preview", 11 - "prepare": "svelte-kit sync || echo ''", 12 - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 13 - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 14 - }, 15 - "devDependencies": { 16 - "@sveltejs/adapter-auto": "^7.0.0", 17 - "@sveltejs/adapter-cloudflare": "^7.2.6", 18 - "@sveltejs/kit": "^2.49.1", 19 - "@sveltejs/vite-plugin-svelte": "^6.2.1", 20 - "@tailwindcss/vite": "^4.1.17", 21 - "svelte": "^5.45.6", 22 - "svelte-check": "^4.3.4", 23 - "tailwindcss": "^4.1.17", 24 - "typescript": "^5.9.3", 25 - "vite": "^7.2.6" 26 - }, 27 - "dependencies": { 28 - "exifr": "^7.1.3", 29 - "feed": "^5.2.0", 30 - "from": "^0.1.7", 31 - "import": "^0.0.6" 32 - } 2 + "name": "steve.photo", 3 + "private": true, 4 + "version": "0.1.0", 5 + "type": "module", 6 + "scripts": { 7 + "dev": "vite dev", 8 + "build": "vite build", 9 + "deploy": "bun run build && wrangler deploy --minify", 10 + "preview": "vite preview", 11 + "prepare": "svelte-kit sync || echo ''", 12 + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 13 + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 14 + }, 15 + "devDependencies": { 16 + "@sveltejs/adapter-auto": "^7.0.0", 17 + "@sveltejs/adapter-cloudflare": "^7.2.6", 18 + "@sveltejs/kit": "^2.49.1", 19 + "@sveltejs/vite-plugin-svelte": "^6.2.1", 20 + "@tailwindcss/vite": "^4.1.17", 21 + "sharp": "^0.34.5", 22 + "svelte": "^5.45.6", 23 + "svelte-check": "^4.3.4", 24 + "tailwindcss": "^4.1.17", 25 + "typescript": "^5.9.3", 26 + "vite": "^7.2.6" 27 + }, 28 + "dependencies": { 29 + "exifr": "^7.1.3", 30 + "feed": "^5.2.0", 31 + "from": "^0.1.7", 32 + "import": "^0.0.6" 33 + } 33 34 }
+2 -1
schema.sql
··· 13 13 focal_length TEXT, 14 14 iso TEXT, 15 15 make TEXT, 16 - tags TEXT 16 + tags TEXT, 17 + blur_data TEXT 17 18 ); 18 19 19 20 CREATE INDEX idx_photos_date ON photos(date DESC);
+5 -1
src/lib/components/ProgressiveImage.svelte
··· 3 3 src, 4 4 thumb, 5 5 alt, 6 + blurData, 6 7 class: className = "", 7 8 }: { 8 9 src: string; 9 10 thumb: string; 10 11 alt: string; 12 + blurData?: string; 11 13 class?: string; 12 14 } = $props(); 13 15 ··· 33 35 img.onload = null; 34 36 }; 35 37 }); 38 + 39 + let placeholderSrc = $derived(blurData || thumb); 36 40 </script> 37 41 38 42 <div ··· 41 45 > 42 46 <img 43 47 bind:this={thumbImg} 44 - src={loaded ? src : thumb} 48 + src={loaded ? src : placeholderSrc} 45 49 {alt} 46 50 class="{className} progressive-image" 47 51 class:progressive-loading={!loaded}
+1
src/lib/feed.ts
··· 26 26 iso: row.iso as string, 27 27 make: row.make as string, 28 28 tags: [], 29 + blurData: row.blur_data as string, 29 30 })); 30 31 31 32 return photos;
+1
src/lib/types.ts
··· 13 13 iso: string; 14 14 make: string; 15 15 tags: string[]; 16 + blurData?: string; 16 17 }; 17 18 18 19 export type ImageArray = {
+1
src/routes/+page.server.ts
··· 31 31 focalLength: row.focal_length, 32 32 iso: row.iso, 33 33 make: row.make, 34 + blurData: row.blur_data as string, 34 35 })); 35 36 36 37 return { photos, total, pageSize: PAGE_SIZE };
+2
src/routes/+page.svelte
··· 92 92 class="max-w-full h-auto block" 93 93 src={image.image} 94 94 thumb={image.thumb} 95 + blurData={image.blurData} 95 96 alt={image.title} 96 97 /> 97 98 </a> ··· 124 125 class="w-full h-full block" 125 126 src={image.image} 126 127 thumb={image.thumb} 128 + blurData={image.blurData} 127 129 alt={image.title} 128 130 /> 129 131 </a>
+4 -2
src/routes/admin/+page.server.ts
··· 60 60 const focalLength = formData.get("focalLength") as string; 61 61 const iso = formData.get("iso") as string; 62 62 const make = formData.get("make") as string; 63 + const blurData = formData.get("blur_data") as string; 63 64 64 65 if (!file || !title || !date) { 65 66 return fail(400, { error: "File, title, and date are required" }); ··· 114 115 // Insert into database 115 116 await db 116 117 .prepare( 117 - `INSERT INTO photos (slug, title, date, image_key, thumb_key, camera, lens, aperture, exposure, focal_length, iso, make) 118 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 118 + `INSERT INTO photos (slug, title, date, image_key, thumb_key, camera, lens, aperture, exposure, focal_length, iso, make, blur_data) 119 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 119 120 ) 120 121 .bind( 121 122 slug, ··· 130 131 focalLength || null, 131 132 iso || null, 132 133 make || null, 134 + blurData || null, 133 135 ) 134 136 .run(); 135 137
+28 -1
src/routes/admin/+page.svelte
··· 8 8 let selectedFile = $state<File | null>(null); 9 9 let previewUrl = $state<string | null>(null); 10 10 let thumbnailBlob = $state<Blob | null>(null); 11 + let blurData = $state<string>(""); 11 12 12 13 let title = $state(""); 13 14 let date = $state(""); ··· 71 72 console.error("Failed to read EXIF data:", err); 72 73 } 73 74 74 - // Generate thumbnail 75 + // Generate thumbnail and blur placeholder 75 76 await generateThumbnail(file); 77 + await generateBlurData(file); 76 78 } 77 79 78 80 async function generateThumbnail(file: File): Promise<void> { ··· 118 120 }); 119 121 } 120 122 123 + async function generateBlurData(file: File): Promise<void> { 124 + return new Promise((resolve) => { 125 + const img = new Image(); 126 + img.onload = () => { 127 + const targetWidth = 20; 128 + const aspectRatio = img.height / img.width; 129 + const targetHeight = Math.round(targetWidth * aspectRatio); 130 + 131 + const canvas = document.createElement("canvas"); 132 + canvas.width = targetWidth; 133 + canvas.height = targetHeight; 134 + 135 + const ctx = canvas.getContext("2d"); 136 + if (ctx) { 137 + ctx.drawImage(img, 0, 0, targetWidth, targetHeight); 138 + blurData = canvas.toDataURL("image/jpeg", 0.3); 139 + } 140 + resolve(); 141 + }; 142 + img.src = URL.createObjectURL(file); 143 + }); 144 + } 145 + 121 146 function handleSubmit() { 122 147 isLoading = true; 123 148 } ··· 139 164 previewUrl = null; 140 165 } 141 166 thumbnailBlob = null; 167 + blurData = ""; 142 168 title = ""; 143 169 date = ""; 144 170 camera = ""; ··· 248 274 <input type="hidden" name="exposure" value={exposure} /> 249 275 <input type="hidden" name="focalLength" value={focalLength} /> 250 276 <input type="hidden" name="iso" value={iso} /> 277 + <input type="hidden" name="blur_data" value={blurData} /> 251 278 </div> 252 279 </div> 253 280 {/if}
+1
src/routes/api/photos/+server.ts
··· 28 28 focalLength: row.focal_length as string, 29 29 iso: row.iso as string, 30 30 make: row.make as string, 31 + blurData: row.blur_data as string, 31 32 })); 32 33 33 34 return json({ photos });
+1
src/routes/photo/[slug]/+page.server.ts
··· 28 28 iso: result.iso as string, 29 29 make: result.make as string, 30 30 tags: [], 31 + blurData: result.blur_data as string, 31 32 }; 32 33 33 34 return { photo };
+1
src/routes/photo/[slug]/+page.svelte
··· 48 48 class="max-w-full h-auto block" 49 49 src={data.photo.image} 50 50 thumb={data.photo.thumb} 51 + blurData={data.photo.blurData} 51 52 alt={data.photo.title} 52 53 /> 53 54 </div>