feat: added base64 thumbnails
6b63f8a8
13 file(s) · +80 −36
| 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", |
| 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 | } |
| 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); |
| 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} |
|
| 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; |
| 13 | 13 | iso: string; |
|
| 14 | 14 | make: string; |
|
| 15 | 15 | tags: string[]; |
|
| 16 | + | blurData?: string; |
|
| 16 | 17 | }; |
|
| 17 | 18 | ||
| 18 | 19 | export type ImageArray = { |
| 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 }; |
| 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> |
|
| 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 | ||
| 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} |
|
| 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 }); |
| 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 }; |
| 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> |