chore: added formatting f850fa0b
Steve · 2026-03-01 19:49 16 file(s) · +474 −431
biome.json +5 −0
12 12
		"enabled": true,
13 13
		"indentStyle": "tab"
14 14
	},
15 +
	"css": {
16 +
		"parser": {
17 +
			"tailwindDirectives": true
18 +
		}
19 +
	},
15 20
	"linter": {
16 21
		"enabled": false,
17 22
		"rules": {
bun.lock +19 −0
11 11
        "import": "^0.0.6",
12 12
      },
13 13
      "devDependencies": {
14 +
        "@biomejs/biome": "^2.4.4",
14 15
        "@sveltejs/adapter-auto": "^7.0.0",
15 16
        "@sveltejs/adapter-cloudflare": "^7.2.6",
16 17
        "@sveltejs/kit": "^2.49.1",
26 27
    },
27 28
  },
28 29
  "packages": {
30 +
    "@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="],
31 +
32 +
    "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="],
33 +
34 +
    "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="],
35 +
36 +
    "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="],
37 +
38 +
    "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="],
39 +
40 +
    "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="],
41 +
42 +
    "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="],
43 +
44 +
    "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="],
45 +
46 +
    "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="],
47 +
29 48
    "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
30 49
31 50
    "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.11.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260115.0" }, "optionalPeers": ["workerd"] }, "sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg=="],
package.json +3 −1
10 10
		"preview": "vite preview",
11 11
		"prepare": "svelte-kit sync || echo ''",
12 12
		"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
13 -
		"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
13 +
		"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
14 +
		"lint": "biome format --write src/"
14 15
	},
15 16
	"devDependencies": {
17 +
		"@biomejs/biome": "^2.4.4",
16 18
		"@sveltejs/adapter-auto": "^7.0.0",
17 19
		"@sveltejs/adapter-cloudflare": "^7.2.6",
18 20
		"@sveltejs/kit": "^2.49.1",
src/hooks.server.ts +9 −9
2 2
import { verifySession } from "$lib";
3 3
4 4
export const handle: Handle = async ({ event, resolve }) => {
5 -
  const sessionCookie = event.cookies.get("session");
6 -
  const secret = event.platform?.env?.SESSION_SECRET;
5 +
	const sessionCookie = event.cookies.get("session");
6 +
	const secret = event.platform?.env?.SESSION_SECRET;
7 7
8 -
  if (sessionCookie && secret) {
9 -
    const isValid = await verifySession(sessionCookie, secret);
10 -
    event.locals.user = isValid ? { authenticated: true } : null;
11 -
  } else {
12 -
    event.locals.user = null;
13 -
  }
8 +
	if (sessionCookie && secret) {
9 +
		const isValid = await verifySession(sessionCookie, secret);
10 +
		event.locals.user = isValid ? { authenticated: true } : null;
11 +
	} else {
12 +
		event.locals.user = null;
13 +
	}
14 14
15 -
  return resolve(event);
15 +
	return resolve(event);
16 16
};
src/lib/components/ProgressiveImage.svelte +33 −33
1 1
<script lang="ts">
2 -
  let {
3 -
    src,
4 -
    thumb,
5 -
    alt,
6 -
    blurData,
7 -
    class: className = "",
8 -
  }: {
9 -
    src: string;
10 -
    thumb: string;
11 -
    alt: string;
12 -
    blurData?: string;
13 -
    class?: string;
14 -
  } = $props();
2 +
let {
3 +
	src,
4 +
	thumb,
5 +
	alt,
6 +
	blurData,
7 +
	class: className = "",
8 +
}: {
9 +
	src: string;
10 +
	thumb: string;
11 +
	alt: string;
12 +
	blurData?: string;
13 +
	class?: string;
14 +
} = $props();
15 15
16 -
  let loaded = $state(false);
17 -
  let thumbAspect = $state(0);
18 -
  let thumbImg: HTMLImageElement;
16 +
let loaded = $state(false);
17 +
let thumbAspect = $state(0);
18 +
let thumbImg: HTMLImageElement;
19 19
20 -
  function onThumbLoad() {
21 -
    if (thumbImg.naturalWidth && thumbImg.naturalHeight) {
22 -
      thumbAspect = thumbImg.naturalWidth / thumbImg.naturalHeight;
23 -
    }
24 -
  }
20 +
function onThumbLoad() {
21 +
	if (thumbImg.naturalWidth && thumbImg.naturalHeight) {
22 +
		thumbAspect = thumbImg.naturalWidth / thumbImg.naturalHeight;
23 +
	}
24 +
}
25 25
26 -
  $effect(() => {
27 -
    loaded = false;
28 -
    const img = new Image();
29 -
    img.onload = () => {
30 -
      loaded = true;
31 -
    };
32 -
    img.src = src;
26 +
$effect(() => {
27 +
	loaded = false;
28 +
	const img = new Image();
29 +
	img.onload = () => {
30 +
		loaded = true;
31 +
	};
32 +
	img.src = src;
33 33
34 -
    return () => {
35 -
      img.onload = null;
36 -
    };
37 -
  });
34 +
	return () => {
35 +
		img.onload = null;
36 +
	};
37 +
});
38 38
39 -
  let placeholderSrc = $derived(blurData || thumb);
39 +
let placeholderSrc = $derived(blurData || thumb);
40 40
</script>
41 41
42 42
<div
src/lib/feed.ts +30 −24
2 2
3 3
const R2_BASE_URL = "https://r2.steve.photo";
4 4
5 -
export async function getPhotos(platform: App.Platform | undefined): Promise<ImageItem[]> {
6 -
  const db = platform?.env?.DB;
5 +
export async function getPhotos(
6 +
	platform: App.Platform | undefined,
7 +
): Promise<ImageItem[]> {
8 +
	const db = platform?.env?.DB;
7 9
8 -
  if (!db) {
9 -
    return [];
10 -
  }
10 +
	if (!db) {
11 +
		return [];
12 +
	}
11 13
12 -
  const result = await db.prepare("SELECT * FROM photos ORDER BY date DESC").all();
14 +
	const result = await db
15 +
		.prepare("SELECT * FROM photos ORDER BY date DESC")
16 +
		.all();
13 17
14 -
  const photos: ImageItem[] = result.results.map((row: Record<string, unknown>) => ({
15 -
    slug: row.slug as string,
16 -
    title: row.title as string,
17 -
    date: row.date as string,
18 -
    image: `${R2_BASE_URL}/${row.image_key}`,
19 -
    thumb: `${R2_BASE_URL}/${row.thumb_key}`,
20 -
    type: row.type as string,
21 -
    camera: row.camera as string,
22 -
    lens: row.lens as string,
23 -
    aperture: row.aperture as string,
24 -
    exposure: row.exposure as string,
25 -
    focalLength: row.focal_length as string,
26 -
    iso: row.iso as string,
27 -
    make: row.make as string,
28 -
    tags: [],
29 -
    blurData: row.blur_data as string,
30 -
  }));
18 +
	const photos: ImageItem[] = result.results.map(
19 +
		(row: Record<string, unknown>) => ({
20 +
			slug: row.slug as string,
21 +
			title: row.title as string,
22 +
			date: row.date as string,
23 +
			image: `${R2_BASE_URL}/${row.image_key}`,
24 +
			thumb: `${R2_BASE_URL}/${row.thumb_key}`,
25 +
			type: row.type as string,
26 +
			camera: row.camera as string,
27 +
			lens: row.lens as string,
28 +
			aperture: row.aperture as string,
29 +
			exposure: row.exposure as string,
30 +
			focalLength: row.focal_length as string,
31 +
			iso: row.iso as string,
32 +
			make: row.make as string,
33 +
			tags: [],
34 +
			blurData: row.blur_data as string,
35 +
		}),
36 +
	);
31 37
32 -
  return photos;
38 +
	return photos;
33 39
}
src/routes/+layout.svelte +12 −12
1 1
<script lang="ts">
2 -
  import { onNavigate } from "$app/navigation";
3 -
  import "./layout.css";
2 +
import { onNavigate } from "$app/navigation";
3 +
import "./layout.css";
4 4
5 -
  let { children } = $props();
5 +
let { children } = $props();
6 6
7 -
  onNavigate((navigation) => {
8 -
    if (!document.startViewTransition) return;
7 +
onNavigate((navigation) => {
8 +
	if (!document.startViewTransition) return;
9 9
10 -
    return new Promise((resolve) => {
11 -
      document.startViewTransition(async () => {
12 -
        resolve();
13 -
        await navigation.complete;
14 -
      });
15 -
    });
16 -
  });
10 +
	return new Promise((resolve) => {
11 +
		document.startViewTransition(async () => {
12 +
			resolve();
13 +
			await navigation.complete;
14 +
		});
15 +
	});
16 +
});
17 17
</script>
18 18
19 19
<svelte:head>
src/routes/+page.server.ts +28 −24
7 7
const PAGE_SIZE = 15;
8 8
9 9
export const load: PageServerLoad = async ({ platform }) => {
10 -
  const db = platform?.env?.DB;
10 +
	const db = platform?.env?.DB;
11 11
12 -
  const result = await db
13 -
    .prepare("SELECT * FROM photos ORDER BY date DESC LIMIT ?")
14 -
    .bind(PAGE_SIZE)
15 -
    .all();
12 +
	const result = await db
13 +
		.prepare("SELECT * FROM photos ORDER BY date DESC LIMIT ?")
14 +
		.bind(PAGE_SIZE)
15 +
		.all();
16 16
17 -
  const countResult = await db.prepare("SELECT COUNT(*) as total FROM photos").first();
18 -
  const total = (countResult?.total as number) || 0;
17 +
	const countResult = await db
18 +
		.prepare("SELECT COUNT(*) as total FROM photos")
19 +
		.first();
20 +
	const total = (countResult?.total as number) || 0;
19 21
20 -
  const photos: ImageItem[] = result.results.map((row: Record<string, unknown>) => ({
21 -
    slug: row.slug,
22 -
    title: row.title,
23 -
    date: row.date,
24 -
    image: `${R2_BASE_URL}/${row.image_key}`,
25 -
    thumb: `${R2_BASE_URL}/${row.thumb_key}`,
26 -
    type: row.type,
27 -
    camera: row.camera,
28 -
    lens: row.lens,
29 -
    aperture: row.aperture,
30 -
    exposure: row.exposure,
31 -
    focalLength: row.focal_length,
32 -
    iso: row.iso,
33 -
    make: row.make,
34 -
    blurData: row.blur_data as string,
35 -
  }));
22 +
	const photos: ImageItem[] = result.results.map(
23 +
		(row: Record<string, unknown>) => ({
24 +
			slug: row.slug,
25 +
			title: row.title,
26 +
			date: row.date,
27 +
			image: `${R2_BASE_URL}/${row.image_key}`,
28 +
			thumb: `${R2_BASE_URL}/${row.thumb_key}`,
29 +
			type: row.type,
30 +
			camera: row.camera,
31 +
			lens: row.lens,
32 +
			aperture: row.aperture,
33 +
			exposure: row.exposure,
34 +
			focalLength: row.focal_length,
35 +
			iso: row.iso,
36 +
			make: row.make,
37 +
			blurData: row.blur_data as string,
38 +
		}),
39 +
	);
36 40
37 -
  return { photos, total, pageSize: PAGE_SIZE };
41 +
	return { photos, total, pageSize: PAGE_SIZE };
38 42
};
src/routes/+page.svelte +53 −53
1 1
<script lang="ts">
2 -
  import { browser } from "$app/environment";
3 -
  import type { PageData } from "./$types";
4 -
  import ProgressiveImage from "$lib/components/ProgressiveImage.svelte";
5 -
  type ImageItem = PageData["photos"][number];
6 -
  let { data }: { data: PageData } = $props();
2 +
import { browser } from "$app/environment";
3 +
import type { PageData } from "./$types";
4 +
import ProgressiveImage from "$lib/components/ProgressiveImage.svelte";
5 +
type ImageItem = PageData["photos"][number];
6 +
let { data }: { data: PageData } = $props();
7 7
8 -
  let viewMode = $state<'feed' | 'grid'>('feed');
9 -
  let photos = $state<ImageItem[]>([]);
10 -
  let loading = $state(false);
11 -
  let hasMore = $derived(photos.length < data.total);
8 +
let viewMode = $state<"feed" | "grid">("feed");
9 +
let photos = $state<ImageItem[]>([]);
10 +
let loading = $state(false);
11 +
let hasMore = $derived(photos.length < data.total);
12 12
13 -
  if (browser) {
14 -
    const saved = localStorage.getItem('viewMode');
15 -
    if (saved === 'feed' || saved === 'grid') {
16 -
      viewMode = saved;
17 -
    }
18 -
  }
13 +
if (browser) {
14 +
	const saved = localStorage.getItem("viewMode");
15 +
	if (saved === "feed" || saved === "grid") {
16 +
		viewMode = saved;
17 +
	}
18 +
}
19 19
20 -
  function toggleViewMode() {
21 -
    viewMode = viewMode === 'feed' ? 'grid' : 'feed';
22 -
    if (browser) {
23 -
      localStorage.setItem('viewMode', viewMode);
24 -
    }
25 -
  }
20 +
function toggleViewMode() {
21 +
	viewMode = viewMode === "feed" ? "grid" : "feed";
22 +
	if (browser) {
23 +
		localStorage.setItem("viewMode", viewMode);
24 +
	}
25 +
}
26 26
27 -
  $effect(() => {
28 -
    photos = data.photos;
29 -
  });
30 -
  let sentinel: HTMLDivElement;
27 +
$effect(() => {
28 +
	photos = data.photos;
29 +
});
30 +
let sentinel: HTMLDivElement;
31 31
32 -
  async function loadMore() {
33 -
    if (loading || !hasMore) return;
34 -
    loading = true;
32 +
async function loadMore() {
33 +
	if (loading || !hasMore) return;
34 +
	loading = true;
35 35
36 -
    try {
37 -
      const response = await fetch(`/api/photos?offset=${photos.length}`);
38 -
      const result = await response.json();
39 -
      if (result.photos.length > 0) {
40 -
        photos = [...photos, ...result.photos];
41 -
      }
42 -
    } catch (error) {
43 -
      console.error("Failed to load more photos:", error);
44 -
    } finally {
45 -
      loading = false;
46 -
    }
47 -
  }
36 +
	try {
37 +
		const response = await fetch(`/api/photos?offset=${photos.length}`);
38 +
		const result = await response.json();
39 +
		if (result.photos.length > 0) {
40 +
			photos = [...photos, ...result.photos];
41 +
		}
42 +
	} catch (error) {
43 +
		console.error("Failed to load more photos:", error);
44 +
	} finally {
45 +
		loading = false;
46 +
	}
47 +
}
48 48
49 -
  $effect(() => {
50 -
    if (!sentinel) return;
49 +
$effect(() => {
50 +
	if (!sentinel) return;
51 51
52 -
    const observer = new IntersectionObserver(
53 -
      (entries) => {
54 -
        if (entries[0].isIntersecting && hasMore && !loading) {
55 -
          loadMore();
56 -
        }
57 -
      },
58 -
      { rootMargin: "200px" },
59 -
    );
52 +
	const observer = new IntersectionObserver(
53 +
		(entries) => {
54 +
			if (entries[0].isIntersecting && hasMore && !loading) {
55 +
				loadMore();
56 +
			}
57 +
		},
58 +
		{ rootMargin: "200px" },
59 +
	);
60 60
61 -
    observer.observe(sentinel);
61 +
	observer.observe(sentinel);
62 62
63 -
    return () => observer.disconnect();
64 -
  });
63 +
	return () => observer.disconnect();
64 +
});
65 65
</script>
66 66
67 67
<div class="bg-[#121113] min-h-screen text-white">
src/routes/admin/+page.svelte +156 −154
1 1
<script lang="ts">
2 -
	import { enhance } from "$app/forms";
3 -
	import exifr from "exifr";
2 +
import { enhance } from "$app/forms";
3 +
import exifr from "exifr";
4 4
5 -
	let { data, form } = $props();
5 +
let { data, form } = $props();
6 6
7 -
	let fileInput = $state<HTMLInputElement | null>(null);
8 -
	let selectedFile = $state<File | null>(null);
9 -
	let previewUrl = $state<string | null>(null);
10 -
	let thumbnailBlob = $state<Blob | null>(null);
11 -
	let blurData = $state<string>("");
7 +
let fileInput = $state<HTMLInputElement | null>(null);
8 +
let selectedFile = $state<File | null>(null);
9 +
let previewUrl = $state<string | null>(null);
10 +
let thumbnailBlob = $state<Blob | null>(null);
11 +
let blurData = $state<string>("");
12 12
13 -
	let title = $state("");
14 -
	let date = $state("");
15 -
	let camera = $state("");
16 -
	let lens = $state("");
17 -
	let aperture = $state("");
18 -
	let exposure = $state("");
19 -
	let focalLength = $state("");
20 -
	let iso = $state("");
21 -
	let make = $state("");
13 +
let title = $state("");
14 +
let date = $state("");
15 +
let camera = $state("");
16 +
let lens = $state("");
17 +
let aperture = $state("");
18 +
let exposure = $state("");
19 +
let focalLength = $state("");
20 +
let iso = $state("");
21 +
let make = $state("");
22 22
23 -
	let isLoading = $state(false);
24 -
	let editingId = $state<number | null>(null);
25 -
	let editingTitle = $state("");
26 -
	let deleteConfirmId = $state<number | null>(null);
23 +
let isLoading = $state(false);
24 +
let editingId = $state<number | null>(null);
25 +
let editingTitle = $state("");
26 +
let deleteConfirmId = $state<number | null>(null);
27 27
28 -
	async function handleFileSelect(event: Event) {
29 -
		const input = event.target as HTMLInputElement;
30 -
		const file = input.files?.[0];
31 -
		if (!file) return;
28 +
async function handleFileSelect(event: Event) {
29 +
	const input = event.target as HTMLInputElement;
30 +
	const file = input.files?.[0];
31 +
	if (!file) return;
32 32
33 -
		selectedFile = file;
34 -
		previewUrl = URL.createObjectURL(file);
33 +
	selectedFile = file;
34 +
	previewUrl = URL.createObjectURL(file);
35 35
36 -
		// Auto-populate title from filename
37 -
		const nameWithoutExt = file.name.replace(/\.[^/.]+$/, "");
38 -
		title = nameWithoutExt.replace(/[-_]/g, " ");
36 +
	// Auto-populate title from filename
37 +
	const nameWithoutExt = file.name.replace(/\.[^/.]+$/, "");
38 +
	title = nameWithoutExt.replace(/[-_]/g, " ");
39 39
40 -
		// Extract EXIF data
41 -
		try {
42 -
			const exif = await exifr.parse(file, {
43 -
				pick: [
44 -
					"DateTimeOriginal",
45 -
					"Model",
46 -
					"LensModel",
47 -
					"FNumber",
48 -
					"ExposureTime",
49 -
					"FocalLength",
50 -
					"ISO",
51 -
					"Make",
52 -
				],
53 -
			});
40 +
	// Extract EXIF data
41 +
	try {
42 +
		const exif = await exifr.parse(file, {
43 +
			pick: [
44 +
				"DateTimeOriginal",
45 +
				"Model",
46 +
				"LensModel",
47 +
				"FNumber",
48 +
				"ExposureTime",
49 +
				"FocalLength",
50 +
				"ISO",
51 +
				"Make",
52 +
			],
53 +
		});
54 54
55 -
			if (exif) {
56 -
				if (exif.DateTimeOriginal) {
57 -
					const d = new Date(exif.DateTimeOriginal);
58 -
					date = d.toISOString().split("T")[0];
59 -
				}
60 -
				if (exif.Model) camera = exif.Model;
61 -
				if (exif.LensModel) lens = exif.LensModel;
62 -
				if (exif.FNumber) aperture = `f/${exif.FNumber}`;
63 -
				if (exif.ExposureTime) {
64 -
					exposure =
65 -
						exif.ExposureTime < 1 ? `1/${Math.round(1 / exif.ExposureTime)}s` : `${exif.ExposureTime}s`;
66 -
				}
67 -
				if (exif.FocalLength) focalLength = `${exif.FocalLength}mm`;
68 -
				if (exif.ISO) iso = String(exif.ISO);
69 -
				if (exif.Make) make = exif.Make;
55 +
		if (exif) {
56 +
			if (exif.DateTimeOriginal) {
57 +
				const d = new Date(exif.DateTimeOriginal);
58 +
				date = d.toISOString().split("T")[0];
59 +
			}
60 +
			if (exif.Model) camera = exif.Model;
61 +
			if (exif.LensModel) lens = exif.LensModel;
62 +
			if (exif.FNumber) aperture = `f/${exif.FNumber}`;
63 +
			if (exif.ExposureTime) {
64 +
				exposure =
65 +
					exif.ExposureTime < 1
66 +
						? `1/${Math.round(1 / exif.ExposureTime)}s`
67 +
						: `${exif.ExposureTime}s`;
70 68
			}
71 -
		} catch (err) {
72 -
			console.error("Failed to read EXIF data:", err);
69 +
			if (exif.FocalLength) focalLength = `${exif.FocalLength}mm`;
70 +
			if (exif.ISO) iso = String(exif.ISO);
71 +
			if (exif.Make) make = exif.Make;
73 72
		}
74 -
75 -
		// Generate thumbnail and blur placeholder
76 -
		await generateThumbnail(file);
77 -
		await generateBlurData(file);
73 +
	} catch (err) {
74 +
		console.error("Failed to read EXIF data:", err);
78 75
	}
79 76
80 -
	async function generateThumbnail(file: File): Promise<void> {
81 -
		return new Promise((resolve) => {
82 -
			const img = new Image();
83 -
			img.onload = () => {
84 -
				const maxSize = 400;
85 -
				let width = img.width;
86 -
				let height = img.height;
77 +
	// Generate thumbnail and blur placeholder
78 +
	await generateThumbnail(file);
79 +
	await generateBlurData(file);
80 +
}
87 81
88 -
				if (width > height) {
89 -
					if (width > maxSize) {
90 -
						height = (height * maxSize) / width;
91 -
						width = maxSize;
92 -
					}
93 -
				} else {
94 -
					if (height > maxSize) {
95 -
						width = (width * maxSize) / height;
96 -
						height = maxSize;
97 -
					}
82 +
async function generateThumbnail(file: File): Promise<void> {
83 +
	return new Promise((resolve) => {
84 +
		const img = new Image();
85 +
		img.onload = () => {
86 +
			const maxSize = 400;
87 +
			let width = img.width;
88 +
			let height = img.height;
89 +
90 +
			if (width > height) {
91 +
				if (width > maxSize) {
92 +
					height = (height * maxSize) / width;
93 +
					width = maxSize;
94 +
				}
95 +
			} else {
96 +
				if (height > maxSize) {
97 +
					width = (width * maxSize) / height;
98 +
					height = maxSize;
98 99
				}
100 +
			}
99 101
100 -
				const canvas = document.createElement("canvas");
101 -
				canvas.width = width;
102 -
				canvas.height = height;
102 +
			const canvas = document.createElement("canvas");
103 +
			canvas.width = width;
104 +
			canvas.height = height;
103 105
104 -
				const ctx = canvas.getContext("2d");
105 -
				if (ctx) {
106 -
					ctx.drawImage(img, 0, 0, width, height);
107 -
					canvas.toBlob(
108 -
						(blob) => {
109 -
							thumbnailBlob = blob;
110 -
							resolve();
111 -
						},
112 -
						"image/jpeg",
113 -
						0.8
114 -
					);
115 -
				} else {
116 -
					resolve();
117 -
				}
118 -
			};
119 -
			img.src = URL.createObjectURL(file);
120 -
		});
121 -
	}
106 +
			const ctx = canvas.getContext("2d");
107 +
			if (ctx) {
108 +
				ctx.drawImage(img, 0, 0, width, height);
109 +
				canvas.toBlob(
110 +
					(blob) => {
111 +
						thumbnailBlob = blob;
112 +
						resolve();
113 +
					},
114 +
					"image/jpeg",
115 +
					0.8,
116 +
				);
117 +
			} else {
118 +
				resolve();
119 +
			}
120 +
		};
121 +
		img.src = URL.createObjectURL(file);
122 +
	});
123 +
}
122 124
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);
125 +
async function generateBlurData(file: File): Promise<void> {
126 +
	return new Promise((resolve) => {
127 +
		const img = new Image();
128 +
		img.onload = () => {
129 +
			const targetWidth = 20;
130 +
			const aspectRatio = img.height / img.width;
131 +
			const targetHeight = Math.round(targetWidth * aspectRatio);
132 +
133 +
			const canvas = document.createElement("canvas");
134 +
			canvas.width = targetWidth;
135 +
			canvas.height = targetHeight;
130 136
131 -
				const canvas = document.createElement("canvas");
132 -
				canvas.width = targetWidth;
133 -
				canvas.height = targetHeight;
137 +
			const ctx = canvas.getContext("2d");
138 +
			if (ctx) {
139 +
				ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
140 +
				blurData = canvas.toDataURL("image/jpeg", 0.3);
141 +
			}
142 +
			resolve();
143 +
		};
144 +
		img.src = URL.createObjectURL(file);
145 +
	});
146 +
}
134 147
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 -
	}
148 +
function handleSubmit() {
149 +
	isLoading = true;
150 +
}
145 151
146 -
	function handleSubmit() {
147 -
		isLoading = true;
148 -
	}
152 +
function startEdit(photo: { id: number; title: string }) {
153 +
	editingId = photo.id;
154 +
	editingTitle = photo.title;
155 +
}
149 156
150 -
	function startEdit(photo: { id: number; title: string }) {
151 -
		editingId = photo.id;
152 -
		editingTitle = photo.title;
153 -
	}
157 +
function cancelEdit() {
158 +
	editingId = null;
159 +
	editingTitle = "";
160 +
}
154 161
155 -
	function cancelEdit() {
156 -
		editingId = null;
157 -
		editingTitle = "";
162 +
function resetUploadForm() {
163 +
	selectedFile = null;
164 +
	if (previewUrl) {
165 +
		URL.revokeObjectURL(previewUrl);
166 +
		previewUrl = null;
158 167
	}
159 -
160 -
	function resetUploadForm() {
161 -
		selectedFile = null;
162 -
		if (previewUrl) {
163 -
			URL.revokeObjectURL(previewUrl);
164 -
			previewUrl = null;
165 -
		}
166 -
		thumbnailBlob = null;
167 -
		blurData = "";
168 -
		title = "";
169 -
		date = "";
170 -
		camera = "";
171 -
		lens = "";
172 -
		aperture = "";
173 -
		exposure = "";
174 -
		focalLength = "";
175 -
		iso = "";
176 -
		make = "";
177 -
		if (fileInput) {
178 -
			fileInput.value = "";
179 -
		}
168 +
	thumbnailBlob = null;
169 +
	blurData = "";
170 +
	title = "";
171 +
	date = "";
172 +
	camera = "";
173 +
	lens = "";
174 +
	aperture = "";
175 +
	exposure = "";
176 +
	focalLength = "";
177 +
	iso = "";
178 +
	make = "";
179 +
	if (fileInput) {
180 +
		fileInput.value = "";
180 181
	}
182 +
}
181 183
</script>
182 184
183 185
<svelte:head>
src/routes/api/photos/+server.ts +25 −23
6 6
const PAGE_SIZE = 15;
7 7
8 8
export const GET: RequestHandler = async ({ url, platform }) => {
9 -
  const db = platform?.env?.DB;
10 -
  const offset = parseInt(url.searchParams.get("offset") || "0", 10);
9 +
	const db = platform?.env?.DB;
10 +
	const offset = parseInt(url.searchParams.get("offset") || "0", 10);
11 11
12 -
  const result = await db
13 -
    .prepare("SELECT * FROM photos ORDER BY date DESC LIMIT ? OFFSET ?")
14 -
    .bind(PAGE_SIZE, offset)
15 -
    .all();
12 +
	const result = await db
13 +
		.prepare("SELECT * FROM photos ORDER BY date DESC LIMIT ? OFFSET ?")
14 +
		.bind(PAGE_SIZE, offset)
15 +
		.all();
16 16
17 -
  const photos: ImageItem[] = result.results.map((row: Record<string, unknown>) => ({
18 -
    slug: row.slug as string,
19 -
    title: row.title as string,
20 -
    date: row.date as string,
21 -
    image: `${R2_BASE_URL}/${row.image_key}`,
22 -
    thumb: `${R2_BASE_URL}/${row.thumb_key}`,
23 -
    type: row.type as string,
24 -
    camera: row.camera as string,
25 -
    lens: row.lens as string,
26 -
    aperture: row.aperture as string,
27 -
    exposure: row.exposure as string,
28 -
    focalLength: row.focal_length as string,
29 -
    iso: row.iso as string,
30 -
    make: row.make as string,
31 -
    blurData: row.blur_data as string,
32 -
  }));
17 +
	const photos: ImageItem[] = result.results.map(
18 +
		(row: Record<string, unknown>) => ({
19 +
			slug: row.slug as string,
20 +
			title: row.title as string,
21 +
			date: row.date as string,
22 +
			image: `${R2_BASE_URL}/${row.image_key}`,
23 +
			thumb: `${R2_BASE_URL}/${row.thumb_key}`,
24 +
			type: row.type as string,
25 +
			camera: row.camera as string,
26 +
			lens: row.lens as string,
27 +
			aperture: row.aperture as string,
28 +
			exposure: row.exposure as string,
29 +
			focalLength: row.focal_length as string,
30 +
			iso: row.iso as string,
31 +
			make: row.make as string,
32 +
			blurData: row.blur_data as string,
33 +
		}),
34 +
	);
33 35
34 -
  return json({ photos });
36 +
	return json({ photos });
35 37
};
src/routes/layout.css +3 −3
1 -
@import 'tailwindcss';
1 +
@import "tailwindcss";
2 2
3 -
html 	{
4 -
  @apply bg-[#121113] min-h-screen w-full font-mono text-white;
3 +
html {
4 +
	@apply bg-[#121113] min-h-screen w-full font-mono text-white;
5 5
}
src/routes/login/+page.server.ts +29 −29
3 3
import { verifyPassword, createSession } from "$lib";
4 4
5 5
export const load: PageServerLoad = async ({ locals }) => {
6 -
  if (locals.user?.authenticated) {
7 -
    throw redirect(302, "/admin");
8 -
  }
9 -
  return {};
6 +
	if (locals.user?.authenticated) {
7 +
		throw redirect(302, "/admin");
8 +
	}
9 +
	return {};
10 10
};
11 11
12 12
export const actions: Actions = {
13 -
  default: async ({ request, platform, cookies }) => {
14 -
    const data = await request.formData();
15 -
    const password = data.get("password");
13 +
	default: async ({ request, platform, cookies }) => {
14 +
		const data = await request.formData();
15 +
		const password = data.get("password");
16 16
17 -
    if (!password || typeof password !== "string") {
18 -
      return fail(400, { error: "Password is required" });
19 -
    }
17 +
		if (!password || typeof password !== "string") {
18 +
			return fail(400, { error: "Password is required" });
19 +
		}
20 20
21 -
    const secret = platform?.env?.SESSION_SECRET;
22 -
    const passwordHash = platform?.env?.ADMIN_PASSWORD_HASH;
21 +
		const secret = platform?.env?.SESSION_SECRET;
22 +
		const passwordHash = platform?.env?.ADMIN_PASSWORD_HASH;
23 23
24 -
    if (!secret || !passwordHash) {
25 -
      return fail(500, { error: "Server configuration error" });
26 -
    }
24 +
		if (!secret || !passwordHash) {
25 +
			return fail(500, { error: "Server configuration error" });
26 +
		}
27 27
28 -
    const isValid = await verifyPassword(password, passwordHash, secret);
28 +
		const isValid = await verifyPassword(password, passwordHash, secret);
29 29
30 -
    if (!isValid) {
31 -
      return fail(401, { error: "Invalid password" });
32 -
    }
30 +
		if (!isValid) {
31 +
			return fail(401, { error: "Invalid password" });
32 +
		}
33 33
34 -
    const session = await createSession(secret);
34 +
		const session = await createSession(secret);
35 35
36 -
    cookies.set("session", session, {
37 -
      path: "/",
38 -
      httpOnly: true,
39 -
      secure: true,
40 -
      sameSite: "strict",
41 -
      maxAge: 60 * 60 * 24, // 24 hours
42 -
    });
36 +
		cookies.set("session", session, {
37 +
			path: "/",
38 +
			httpOnly: true,
39 +
			secure: true,
40 +
			sameSite: "strict",
41 +
			maxAge: 60 * 60 * 24, // 24 hours
42 +
		});
43 43
44 -
    throw redirect(302, "/admin");
45 -
  },
44 +
		throw redirect(302, "/admin");
45 +
	},
46 46
};
src/routes/login/+page.svelte +2 −2
1 1
<script lang="ts">
2 -
	import { enhance } from "$app/forms";
2 +
import { enhance } from "$app/forms";
3 3
4 -
	let { form } = $props();
4 +
let { form } = $props();
5 5
</script>
6 6
7 7
<svelte:head>
src/routes/photo/[slug]/+page.server.ts +26 −23
5 5
const R2_BASE_URL = "https://r2.steve.photo";
6 6
7 7
export const load: PageServerLoad = async ({ platform, params }) => {
8 -
  const db = platform?.env?.DB;
8 +
	const db = platform?.env?.DB;
9 9
10 -
  const result = await db.prepare("SELECT * FROM photos WHERE slug = ?").bind(params.slug).first();
10 +
	const result = await db
11 +
		.prepare("SELECT * FROM photos WHERE slug = ?")
12 +
		.bind(params.slug)
13 +
		.first();
11 14
12 -
  if (!result) {
13 -
    throw error(404, "Photo not found");
14 -
  }
15 +
	if (!result) {
16 +
		throw error(404, "Photo not found");
17 +
	}
15 18
16 -
  const photo: ImageItem = {
17 -
    slug: result.slug as string,
18 -
    title: result.title as string,
19 -
    date: result.date as string,
20 -
    image: `${R2_BASE_URL}/${result.image_key}`,
21 -
    thumb: `${R2_BASE_URL}/${result.thumb_key}`,
22 -
    type: result.type as string,
23 -
    camera: result.camera as string,
24 -
    lens: result.lens as string,
25 -
    aperture: result.aperture as string,
26 -
    exposure: result.exposure as string,
27 -
    focalLength: result.focal_length as string,
28 -
    iso: result.iso as string,
29 -
    make: result.make as string,
30 -
    tags: [],
31 -
    blurData: result.blur_data as string,
32 -
  };
19 +
	const photo: ImageItem = {
20 +
		slug: result.slug as string,
21 +
		title: result.title as string,
22 +
		date: result.date as string,
23 +
		image: `${R2_BASE_URL}/${result.image_key}`,
24 +
		thumb: `${R2_BASE_URL}/${result.thumb_key}`,
25 +
		type: result.type as string,
26 +
		camera: result.camera as string,
27 +
		lens: result.lens as string,
28 +
		aperture: result.aperture as string,
29 +
		exposure: result.exposure as string,
30 +
		focalLength: result.focal_length as string,
31 +
		iso: result.iso as string,
32 +
		make: result.make as string,
33 +
		tags: [],
34 +
		blurData: result.blur_data as string,
35 +
	};
33 36
34 -
  return { photo };
37 +
	return { photo };
35 38
};
src/routes/rss.xml/+server.ts +41 −41
3 3
import type { RequestHandler } from "./$types";
4 4
5 5
export const GET: RequestHandler = async ({ platform }) => {
6 -
  const feed = new Feed({
7 -
    title: "steve.photo",
8 -
    description: "Personal photography portolio of Steve Simkins",
9 -
    id: "https://steve.photo",
10 -
    link: "https://steve.photo/",
11 -
    language: "en",
12 -
    favicon: "https://steve.photo/favicon.ico",
13 -
    copyright: `Copyright ${new Date().getFullYear().toString()}, Steve Simkins`,
14 -
    feedLinks: {
15 -
      rss: "https://steve.photo/rss.xml",
16 -
    },
17 -
    author: {
18 -
      name: "Steve Simkins",
19 -
      email: "contact@stevedylan.dev",
20 -
      link: "https://stevedylan.dev",
21 -
    },
22 -
    ttl: 60,
23 -
  });
6 +
	const feed = new Feed({
7 +
		title: "steve.photo",
8 +
		description: "Personal photography portolio of Steve Simkins",
9 +
		id: "https://steve.photo",
10 +
		link: "https://steve.photo/",
11 +
		language: "en",
12 +
		favicon: "https://steve.photo/favicon.ico",
13 +
		copyright: `Copyright ${new Date().getFullYear().toString()}, Steve Simkins`,
14 +
		feedLinks: {
15 +
			rss: "https://steve.photo/rss.xml",
16 +
		},
17 +
		author: {
18 +
			name: "Steve Simkins",
19 +
			email: "contact@stevedylan.dev",
20 +
			link: "https://stevedylan.dev",
21 +
		},
22 +
		ttl: 60,
23 +
	});
24 24
25 -
  const photos = await getPhotos(platform);
25 +
	const photos = await getPhotos(platform);
26 26
27 -
  for (const photo of photos) {
28 -
    feed.addItem({
29 -
      title: photo.title,
30 -
      id: `https://steve.photo/photo/${photo.slug}`,
31 -
      link: `https://steve.photo/photo/${photo.slug}`,
32 -
      date: new Date(photo.date),
33 -
      image: photo.image,
34 -
      author: [
35 -
        {
36 -
          name: "Steve Simkins",
37 -
          email: "contact@stevedylan.dev",
38 -
          link: "https://stevedylan.dev",
39 -
        },
40 -
      ],
41 -
      content: `<img src="${photo.image}" alt="${photo.title}" /><p>Camera: ${photo.camera} | Lens: ${photo.lens} | ${photo.aperture} | ${photo.exposure} | ISO ${photo.iso}</p>`,
42 -
    });
43 -
  }
27 +
	for (const photo of photos) {
28 +
		feed.addItem({
29 +
			title: photo.title,
30 +
			id: `https://steve.photo/photo/${photo.slug}`,
31 +
			link: `https://steve.photo/photo/${photo.slug}`,
32 +
			date: new Date(photo.date),
33 +
			image: photo.image,
34 +
			author: [
35 +
				{
36 +
					name: "Steve Simkins",
37 +
					email: "contact@stevedylan.dev",
38 +
					link: "https://stevedylan.dev",
39 +
				},
40 +
			],
41 +
			content: `<img src="${photo.image}" alt="${photo.title}" /><p>Camera: ${photo.camera} | Lens: ${photo.lens} | ${photo.aperture} | ${photo.exposure} | ISO ${photo.iso}</p>`,
42 +
		});
43 +
	}
44 44
45 -
  return new Response(feed.rss2(), {
46 -
    headers: {
47 -
      "Content-Type": "application/xml; charset=utf-8",
48 -
    },
49 -
  });
45 +
	return new Response(feed.rss2(), {
46 +
		headers: {
47 +
			"Content-Type": "application/xml; charset=utf-8",
48 +
		},
49 +
	});
50 50
};