src/routes/+page.svelte 5.7 K raw
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();
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);
12
13
if (browser) {
14
	const saved = localStorage.getItem("viewMode");
15
	if (saved === "feed" || saved === "grid") {
16
		viewMode = saved;
17
	}
18
}
19
20
function toggleViewMode() {
21
	viewMode = viewMode === "feed" ? "grid" : "feed";
22
	if (browser) {
23
		localStorage.setItem("viewMode", viewMode);
24
	}
25
}
26
27
$effect(() => {
28
	photos = data.photos;
29
});
30
let sentinel: HTMLDivElement;
31
32
async function loadMore() {
33
	if (loading || !hasMore) return;
34
	loading = true;
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
}
48
49
$effect(() => {
50
	if (!sentinel) return;
51
52
	const observer = new IntersectionObserver(
53
		(entries) => {
54
			if (entries[0].isIntersecting && hasMore && !loading) {
55
				loadMore();
56
			}
57
		},
58
		{ rootMargin: "200px" },
59
	);
60
61
	observer.observe(sentinel);
62
63
	return () => observer.disconnect();
64
});
65
</script>
66
67
<div class="bg-[#121113] min-h-screen text-white">
68
  <div class="fixed bg-[#121113] w-full py-4 sm:px-8 px-4 flex items-center justify-between z-10">
69
    <h1 class="text-sm">steve.photo</h1>
70
    <div class="flex items-center gap-4">
71
      <a href="https://stevedylan.dev" target="_blank" rel="noreferrer" class="text-neutral-400 hover:text-white transition-colors" aria-label="stevedylan.dev">
72
        <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 256 256">
73
          <path fill="currentColor" d="M164 80a28 28 0 1 0-28-28a28 28 0 0 0 28 28m0-40a12 12 0 1 1-12 12a12 12 0 0 1 12-12m90.88 155.92l-54.56-92.08A15.87 15.87 0 0 0 186.55 96a15.85 15.85 0 0 0-13.76 7.84L146.63 148l-44.84-76.1a16 16 0 0 0-27.58 0L1.11 195.94A8 8 0 0 0 8 208h240a8 8 0 0 0 6.88-12.08M88 80l23.57 40H64.43ZM22 192l33-56h66l18.74 31.8L154 192Zm150.57 0l-16.66-28.28L186.55 112L234 192Z"/>
74
        </svg>
75
      </a>
76
      <a href="/rss.xml" class="text-neutral-400 hover:text-white transition-colors" aria-label="RSS feed">
77
        <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 256 256">
78
          <path fill="currentColor" d="M106.91 149.09A71.53 71.53 0 0 1 128 200a8 8 0 0 1-16 0a56 56 0 0 0-56-56a8 8 0 0 1 0-16a71.53 71.53 0 0 1 50.91 21.09M56 80a8 8 0 0 0 0 16a104 104 0 0 1 104 104a8 8 0 0 0 16 0A120 120 0 0 0 56 80m118.79 1.21A166.9 166.9 0 0 0 56 32a8 8 0 0 0 0 16a151 151 0 0 1 107.48 44.52A151 151 0 0 1 208 200a8 8 0 0 0 16 0a166.9 166.9 0 0 0-49.21-118.79M60 184a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/>
79
        </svg>
80
      </a>
81
      <button onclick={toggleViewMode} class="text-neutral-400 hover:text-white transition-colors" aria-label="Toggle view mode">
82
        {#if viewMode === 'feed'}
83
          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 256 256">
84
            <path fill="currentColor" d="M200 40H56a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 80h-64V56h64Zm-80-64v64H56V56Zm-64 80h64v64H56Zm144 64h-64v-64h64z"/>
85
          </svg>
86
        {:else}
87
          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 256 256">
88
            <path fill="currentColor" d="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8M40 72h176a8 8 0 0 0 0-16H40a8 8 0 0 0 0 16m176 112H40a8 8 0 0 0 0 16h176a8 8 0 0 0 0-16"/>
89
          </svg>
90
        {/if}
91
      </button>
92
    </div>
93
  </div>
94
95
  {#snippet figure(image: ImageItem)}
96
    <div class="flex sm:flex-row flex-col gap-2 sm:px-8 px-4 pt-2">
97
      <a href="/photo/{image.slug}" class="flex-2 min-w-0">
98
        <ProgressiveImage
99
          class="max-w-full h-auto block"
100
          src={image.image}
101
          thumb={image.thumb}
102
          blurData={image.blurData}
103
          alt={image.title}
104
        />
105
      </a>
106
      <div class="flex flex-col gap-1 flex-1 min-w-0 p-4">
107
        <h2 class="text-lg">{image.title.toUpperCase()}</h2>
108
        <h3 class="text-sm">{image.make} {image.camera}</h3>
109
        <div class="flex flex-col gap-2 text-neutral-400 font-thin text-xs mt-4">
110
          <p>{image.focalLength}</p>
111
          <p>{image.aperture}</p>
112
          <p>{image.exposure}</p>
113
          <p>ISO {image.iso}</p>
114
          <p>-</p>
115
          <p class="text-neutral-700 text-xs">{new Date(image.date).toLocaleDateString()}</p>
116
        </div>
117
      </div>
118
    </div>
119
  {/snippet}
120
121
  {#if viewMode === 'feed'}
122
    <div class="flex flex-col gap-2 pt-12">
123
      {#each photos as image}
124
        {@render figure(image)}
125
      {/each}
126
    </div>
127
  {:else}
128
    <div class="grid grid-cols-2 sm:grid-cols-3 gap-1 pt-12 sm:px-8 px-4">
129
      {#each photos as image}
130
        <a href="/photo/{image.slug}" class="grid-item block overflow-hidden aspect-[3/2]">
131
          <ProgressiveImage
132
            class="w-full h-full block"
133
            src={image.image}
134
            thumb={image.thumb}
135
            blurData={image.blurData}
136
            alt={image.title}
137
          />
138
        </a>
139
      {/each}
140
    </div>
141
  {/if}
142
143
  <div bind:this={sentinel} class="h-4"></div>
144
145
  {#if loading}
146
    <div class="flex justify-center py-8">
147
      <div class="text-neutral-400 text-sm">Loading...</div>
148
    </div>
149
  {/if}
150
</div>
151
152
<style>
153
  .grid-item :global(.progressive-container) {
154
    aspect-ratio: unset !important;
155
    height: 100%;
156
  }
157
158
  .grid-item :global(.progressive-image) {
159
    object-fit: cover;
160
  }
161
</style>