diff --git a/bun.lock b/bun.lock
index ab0d7fd..2ab1014 100644
--- a/bun.lock
+++ b/bun.lock
@@ -16,6 +16,7 @@
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17",
+ "sharp": "^0.34.5",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
diff --git a/package.json b/package.json
index 30b68fc..6469232 100644
--- a/package.json
+++ b/package.json
@@ -1,33 +1,34 @@
{
- "name": "steve.photo",
- "private": true,
- "version": "0.1.0",
- "type": "module",
- "scripts": {
- "dev": "vite dev",
- "build": "vite build",
- "deploy": "bun run build && wrangler deploy --minify",
- "preview": "vite preview",
- "prepare": "svelte-kit sync || echo ''",
- "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
- "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
- },
- "devDependencies": {
- "@sveltejs/adapter-auto": "^7.0.0",
- "@sveltejs/adapter-cloudflare": "^7.2.6",
- "@sveltejs/kit": "^2.49.1",
- "@sveltejs/vite-plugin-svelte": "^6.2.1",
- "@tailwindcss/vite": "^4.1.17",
- "svelte": "^5.45.6",
- "svelte-check": "^4.3.4",
- "tailwindcss": "^4.1.17",
- "typescript": "^5.9.3",
- "vite": "^7.2.6"
- },
- "dependencies": {
- "exifr": "^7.1.3",
- "feed": "^5.2.0",
- "from": "^0.1.7",
- "import": "^0.0.6"
- }
+ "name": "steve.photo",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "deploy": "bun run build && wrangler deploy --minify",
+ "preview": "vite preview",
+ "prepare": "svelte-kit sync || echo ''",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^7.0.0",
+ "@sveltejs/adapter-cloudflare": "^7.2.6",
+ "@sveltejs/kit": "^2.49.1",
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
+ "@tailwindcss/vite": "^4.1.17",
+ "sharp": "^0.34.5",
+ "svelte": "^5.45.6",
+ "svelte-check": "^4.3.4",
+ "tailwindcss": "^4.1.17",
+ "typescript": "^5.9.3",
+ "vite": "^7.2.6"
+ },
+ "dependencies": {
+ "exifr": "^7.1.3",
+ "feed": "^5.2.0",
+ "from": "^0.1.7",
+ "import": "^0.0.6"
+ }
}
diff --git a/schema.sql b/schema.sql
index aafb546..9398948 100644
--- a/schema.sql
+++ b/schema.sql
@@ -13,7 +13,8 @@ CREATE TABLE IF NOT EXISTS photos (
focal_length TEXT,
iso TEXT,
make TEXT,
- tags TEXT
+ tags TEXT,
+ blur_data TEXT
);
CREATE INDEX idx_photos_date ON photos(date DESC);
diff --git a/src/lib/components/ProgressiveImage.svelte b/src/lib/components/ProgressiveImage.svelte
index eb03fbc..3e02f9c 100644
--- a/src/lib/components/ProgressiveImage.svelte
+++ b/src/lib/components/ProgressiveImage.svelte
@@ -3,11 +3,13 @@
src,
thumb,
alt,
+ blurData,
class: className = "",
}: {
src: string;
thumb: string;
alt: string;
+ blurData?: string;
class?: string;
} = $props();
@@ -33,6 +35,8 @@
img.onload = null;
};
});
+
+ let placeholderSrc = $derived(blurData || thumb);

{
focalLength: row.focal_length,
iso: row.iso,
make: row.make,
+ blurData: row.blur_data as string,
}));
return { photos, total, pageSize: PAGE_SIZE };
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 857eb4b..82a0855 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -92,6 +92,7 @@
class="max-w-full h-auto block"
src={image.image}
thumb={image.thumb}
+ blurData={image.blurData}
alt={image.title}
/>
@@ -124,6 +125,7 @@
class="w-full h-full block"
src={image.image}
thumb={image.thumb}
+ blurData={image.blurData}
alt={image.title}
/>
diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts
index d3414f5..ddda95f 100644
--- a/src/routes/admin/+page.server.ts
+++ b/src/routes/admin/+page.server.ts
@@ -60,6 +60,7 @@ export const actions: Actions = {
const focalLength = formData.get("focalLength") as string;
const iso = formData.get("iso") as string;
const make = formData.get("make") as string;
+ const blurData = formData.get("blur_data") as string;
if (!file || !title || !date) {
return fail(400, { error: "File, title, and date are required" });
@@ -114,8 +115,8 @@ export const actions: Actions = {
// Insert into database
await db
.prepare(
- `INSERT INTO photos (slug, title, date, image_key, thumb_key, camera, lens, aperture, exposure, focal_length, iso, make)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ `INSERT INTO photos (slug, title, date, image_key, thumb_key, camera, lens, aperture, exposure, focal_length, iso, make, blur_data)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
slug,
@@ -130,6 +131,7 @@ export const actions: Actions = {
focalLength || null,
iso || null,
make || null,
+ blurData || null,
)
.run();
diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte
index 55c8e15..7b379b6 100644
--- a/src/routes/admin/+page.svelte
+++ b/src/routes/admin/+page.svelte
@@ -8,6 +8,7 @@
let selectedFile = $state
(null);
let previewUrl = $state(null);
let thumbnailBlob = $state(null);
+ let blurData = $state("");
let title = $state("");
let date = $state("");
@@ -71,8 +72,9 @@
console.error("Failed to read EXIF data:", err);
}
- // Generate thumbnail
+ // Generate thumbnail and blur placeholder
await generateThumbnail(file);
+ await generateBlurData(file);
}
async function generateThumbnail(file: File): Promise {
@@ -118,6 +120,29 @@
});
}
+ async function generateBlurData(file: File): Promise {
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () => {
+ const targetWidth = 20;
+ const aspectRatio = img.height / img.width;
+ const targetHeight = Math.round(targetWidth * aspectRatio);
+
+ const canvas = document.createElement("canvas");
+ canvas.width = targetWidth;
+ canvas.height = targetHeight;
+
+ const ctx = canvas.getContext("2d");
+ if (ctx) {
+ ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
+ blurData = canvas.toDataURL("image/jpeg", 0.3);
+ }
+ resolve();
+ };
+ img.src = URL.createObjectURL(file);
+ });
+ }
+
function handleSubmit() {
isLoading = true;
}
@@ -139,6 +164,7 @@
previewUrl = null;
}
thumbnailBlob = null;
+ blurData = "";
title = "";
date = "";
camera = "";
@@ -248,6 +274,7 @@
+
{/if}
diff --git a/src/routes/api/photos/+server.ts b/src/routes/api/photos/+server.ts
index c920b09..3d1b270 100644
--- a/src/routes/api/photos/+server.ts
+++ b/src/routes/api/photos/+server.ts
@@ -28,6 +28,7 @@ export const GET: RequestHandler = async ({ url, platform }) => {
focalLength: row.focal_length as string,
iso: row.iso as string,
make: row.make as string,
+ blurData: row.blur_data as string,
}));
return json({ photos });
diff --git a/src/routes/photo/[slug]/+page.server.ts b/src/routes/photo/[slug]/+page.server.ts
index 4a90543..c345f3f 100644
--- a/src/routes/photo/[slug]/+page.server.ts
+++ b/src/routes/photo/[slug]/+page.server.ts
@@ -28,6 +28,7 @@ export const load: PageServerLoad = async ({ platform, params }) => {
iso: result.iso as string,
make: result.make as string,
tags: [],
+ blurData: result.blur_data as string,
};
return { photo };
diff --git a/src/routes/photo/[slug]/+page.svelte b/src/routes/photo/[slug]/+page.svelte
index 6c00d0d..27f2cc1 100644
--- a/src/routes/photo/[slug]/+page.svelte
+++ b/src/routes/photo/[slug]/+page.svelte
@@ -48,6 +48,7 @@ function handleKeydown(event) {
class="max-w-full h-auto block"
src={data.photo.image}
thumb={data.photo.thumb}
+ blurData={data.photo.blurData}
alt={data.photo.title}
/>