feat: added base64 thumbnails 6b63f8a8
Steve · 2026-03-01 19:41 13 file(s) · +80 −36
bun.lock +1 −0
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",
package.json +32 −31
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
}
schema.sql +2 −1
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);
src/lib/components/ProgressiveImage.svelte +5 −1
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}
src/lib/feed.ts +1 −0
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;
src/lib/types.ts +1 −0
13 13
	iso: string;
14 14
	make: string;
15 15
	tags: string[];
16 +
	blurData?: string;
16 17
};
17 18
18 19
export type ImageArray = {
src/routes/+page.server.ts +1 −0
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 };
src/routes/+page.svelte +2 −0
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>
src/routes/admin/+page.server.ts +4 −2
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
src/routes/admin/+page.svelte +28 −1
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}
src/routes/api/photos/+server.ts +1 −0
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 });
src/routes/photo/[slug]/+page.server.ts +1 −0
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 };
src/routes/photo/[slug]/+page.svelte +1 −0
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>