| 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> |