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