Jelajahi Sumber

Create the gallery page

Lee Morgan 2 minggu lalu
induk
melakukan
307c5d4940

+ 152 - 1
package-lock.json

@@ -8,7 +8,8 @@
 			"name": "leemorgan-dev",
 			"version": "0.0.1",
 			"dependencies": {
-				"mapbox-gl": "^3.33.0"
+				"mapbox-gl": "^3.33.0",
+				"mongodb": "^7.7.0"
 			},
 			"devDependencies": {
 				"@sveltejs/adapter-auto": "^8.0.0",
@@ -68,6 +69,15 @@
 				"@jridgewell/sourcemap-codec": "^1.4.14"
 			}
 		},
+		"node_modules/@mongodb-js/saslprep": {
+			"version": "1.6.1",
+			"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.1.tgz",
+			"integrity": "sha512-D4CQtk9IwjCLAvCf1xvHEod3960q8XMUzfmdRU9qjUNlHnesGuTOvJcOTaPm9koZ1caUKA2aTbszZiAkcmSgmg==",
+			"license": "MIT",
+			"dependencies": {
+				"sparse-bitfield": "^3.0.3"
+			}
+		},
 		"node_modules/@oxc-project/types": {
 			"version": "0.154.0",
 			"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
@@ -457,6 +467,21 @@
 			"dev": true,
 			"license": "MIT"
 		},
+		"node_modules/@types/webidl-conversions": {
+			"version": "7.0.3",
+			"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
+			"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
+			"license": "MIT"
+		},
+		"node_modules/@types/whatwg-url": {
+			"version": "13.0.0",
+			"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz",
+			"integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==",
+			"license": "MIT",
+			"dependencies": {
+				"@types/webidl-conversions": "*"
+			}
+		},
 		"node_modules/acorn": {
 			"version": "8.19.0",
 			"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
@@ -490,6 +515,15 @@
 				"node": ">= 0.4"
 			}
 		},
+		"node_modules/bson": {
+			"version": "7.3.3",
+			"resolved": "https://registry.npmjs.org/bson/-/bson-7.3.3.tgz",
+			"integrity": "sha512-oz3LwE3qGEhTCSyhzQRLY3Bj1NLOsjFMf6tcmY5J8M+3R7EVcmoBfSHWvjUK3yXCLDUsGpIuB3aQL4nDZ1EV/g==",
+			"license": "Apache-2.0",
+			"engines": {
+				"node": ">=20.19.0"
+			}
+		},
 		"node_modules/clsx": {
 			"version": "2.1.1",
 			"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -911,6 +945,71 @@
 				"test/build/typings"
 			]
 		},
+		"node_modules/memory-pager": {
+			"version": "1.5.0",
+			"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
+			"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
+			"license": "MIT"
+		},
+		"node_modules/mongodb": {
+			"version": "7.7.0",
+			"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.7.0.tgz",
+			"integrity": "sha512-XG1xXEqyouAV8GCb3PuRaujofeXKpAJAPvXYWZgHl6Dz4QHSJ1nRLl0+GpqaLTwKvC0RFE9truOur34RNFY1nw==",
+			"license": "Apache-2.0",
+			"dependencies": {
+				"@mongodb-js/saslprep": "^1.5.0",
+				"bson": "^7.3.2",
+				"mongodb-connection-string-url": "^7.0.2"
+			},
+			"engines": {
+				"node": ">=20.19.0"
+			},
+			"peerDependencies": {
+				"@aws-sdk/credential-providers": "^3.806.0",
+				"@mongodb-js/zstd": "^7.0.0",
+				"gcp-metadata": "^7.0.1",
+				"kerberos": "^7.0.0",
+				"mongodb-client-encryption": "^7.2.0",
+				"snappy": "^7.3.2",
+				"socks": "^2.8.6"
+			},
+			"peerDependenciesMeta": {
+				"@aws-sdk/credential-providers": {
+					"optional": true
+				},
+				"@mongodb-js/zstd": {
+					"optional": true
+				},
+				"gcp-metadata": {
+					"optional": true
+				},
+				"kerberos": {
+					"optional": true
+				},
+				"mongodb-client-encryption": {
+					"optional": true
+				},
+				"snappy": {
+					"optional": true
+				},
+				"socks": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/mongodb-connection-string-url": {
+			"version": "7.0.2",
+			"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.2.tgz",
+			"integrity": "sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==",
+			"license": "Apache-2.0",
+			"dependencies": {
+				"@types/whatwg-url": "^13.0.0",
+				"whatwg-url": "^14.1.0"
+			},
+			"engines": {
+				"node": ">=20.19.0"
+			}
+		},
 		"node_modules/mrmime": {
 			"version": "2.0.1",
 			"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -1003,6 +1102,15 @@
 				"node": "^10 || ^12 || >=14"
 			}
 		},
+		"node_modules/punycode": {
+			"version": "2.3.1",
+			"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+			"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=6"
+			}
+		},
 		"node_modules/rolldown": {
 			"version": "1.2.14",
 			"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
@@ -1062,6 +1170,15 @@
 				"node": ">=0.10.0"
 			}
 		},
+		"node_modules/sparse-bitfield": {
+			"version": "3.0.3",
+			"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
+			"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
+			"license": "MIT",
+			"dependencies": {
+				"memory-pager": "^1.0.2"
+			}
+		},
 		"node_modules/svelte": {
 			"version": "5.57.2",
 			"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz",
@@ -1126,6 +1243,18 @@
 				"node": ">=6"
 			}
 		},
+		"node_modules/tr46": {
+			"version": "5.1.1",
+			"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+			"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+			"license": "MIT",
+			"dependencies": {
+				"punycode": "^2.3.1"
+			},
+			"engines": {
+				"node": ">=18"
+			}
+		},
 		"node_modules/vite": {
 			"version": "8.3.4",
 			"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
@@ -1224,6 +1353,28 @@
 				}
 			}
 		},
+		"node_modules/webidl-conversions": {
+			"version": "7.0.0",
+			"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+			"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+			"license": "BSD-2-Clause",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/whatwg-url": {
+			"version": "14.2.0",
+			"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+			"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+			"license": "MIT",
+			"dependencies": {
+				"tr46": "^5.1.0",
+				"webidl-conversions": "^7.0.0"
+			},
+			"engines": {
+				"node": ">=18"
+			}
+		},
 		"node_modules/zimmerframe": {
 			"version": "1.1.5",
 			"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",

+ 2 - 1
package.json

@@ -21,6 +21,7 @@
 		"#lib/*": "./src/lib/*"
 	},
 	"dependencies": {
-		"mapbox-gl": "^3.33.0"
+		"mapbox-gl": "^3.33.0",
+		"mongodb": "^7.7.0"
 	}
 }

+ 8 - 0
src/env.js

@@ -1,8 +1,16 @@
 import { defineEnvVars } from '@sveltejs/kit/env';
 
 export const variables = defineEnvVars({
+	APP_ENV: {
+		description: 'Runtime environment: development or production',
+		schema: (value) => value ?? 'development'
+	},
 	MAPBOX_KEY: {
 		description: 'Mapbox access token used to load the gallery map',
 		schema: (value) => value ?? ''
+	},
+	MONGODB_PASS: {
+		description: 'MongoDB password used in production',
+		schema: (value) => value ?? ''
 	}
 });

+ 11 - 0
src/lib/gallery.js

@@ -0,0 +1,11 @@
+export function galleryImageSrc(value) {
+	const raw = String(value ?? '');
+	if (!raw) return '';
+	if (raw.startsWith('/galleryImages/')) return raw;
+	return `/galleryImages/${raw.replace(/^\/+/, '')}`;
+}
+
+export function galleryImageFile(value) {
+	const src = galleryImageSrc(value);
+	return src.split('/').pop() ?? '';
+}

+ 23 - 0
src/lib/server/mongo.js

@@ -0,0 +1,23 @@
+import { MongoClient } from 'mongodb';
+import { APP_ENV, MONGODB_PASS } from '$app/env/private';
+
+function uri() {
+	if (APP_ENV === 'production') {
+		const pass = encodeURIComponent(MONGODB_PASS);
+		return `mongodb://leemorgan:${pass}@127.0.0.1:27017/leemorgan?authSource=admin`;
+	}
+
+	return 'mongodb://127.0.0.1:27017/leemorgan';
+}
+
+/** @type {MongoClient | undefined} */
+let client;
+
+export async function getDb() {
+	if (!client) {
+		client = new MongoClient(uri());
+		await client.connect();
+	}
+
+	return client.db('leemorgan');
+}

+ 3 - 2
src/routes/+layout.svelte

@@ -7,7 +7,8 @@
 
 	const isHome = $derived(page.url.pathname === '/');
 	const isProjects = $derived(page.url.pathname === '/projects');
-	const isGallery = $derived(page.url.pathname === '/gallery');
+	const isGalleryMap = $derived(page.url.pathname === '/gallery');
+	const isGallery = $derived(isGalleryMap || page.url.pathname.startsWith('/gallery/'));
 </script>
 
 <svelte:head>
@@ -55,7 +56,7 @@
 		</div>
 	</aside>
 
-	<div class="page" class:page-flush={isGallery}>
+	<div class="page" class:page-flush={isGalleryMap}>
 		<main id="main" class="site-main">
 			{@render children()}
 		</main>

+ 38 - 2
src/routes/gallery/+page.server.js

@@ -1,9 +1,45 @@
 import { MAPBOX_KEY } from '$app/env/private';
+import { galleryImageSrc } from '#lib/gallery.js';
+import { getDb } from '#lib/server/mongo.js';
 
 export const prerender = false;
 
-export function load() {
+export async function load() {
+	let galleries = [];
+
+	try {
+		const db = await getDb();
+		const docs = await db
+			.collection('galleries')
+			.find({ 'location.coordinates.0': { $exists: true } })
+			.project({ title: 1, images: 1, location: 1 })
+			.toArray();
+
+		galleries = docs
+			.map((doc) => {
+				const coords = doc.location?.coordinates;
+				if (!Array.isArray(coords) || coords.length < 2) return null;
+				const lat = Number(coords[0]);
+				const lng = Number(coords[1]);
+				if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
+
+				const image = galleryImageSrc(Array.isArray(doc.images) ? doc.images[0] : '');
+
+				return {
+					id: String(doc._id),
+					title: doc.title ?? '',
+					image,
+					lng,
+					lat
+				};
+			})
+			.filter(Boolean);
+	} catch (error) {
+		console.error('Failed to load galleries', error);
+	}
+
 	return {
-		mapboxKey: MAPBOX_KEY
+		mapboxKey: MAPBOX_KEY,
+		galleries
 	};
 }

+ 78 - 3
src/routes/gallery/+page.svelte

@@ -1,4 +1,5 @@
 <script>
+	import { goto } from '$app/navigation';
 	import { onMount } from 'svelte';
 	import 'mapbox-gl/dist/mapbox-gl.css';
 
@@ -14,6 +15,14 @@
 		return Boolean(canvas.getContext('webgl2'));
 	}
 
+	function escapeHtml(value) {
+		return String(value)
+			.replaceAll('&', '&amp;')
+			.replaceAll('<', '&lt;')
+			.replaceAll('>', '&gt;')
+			.replaceAll('"', '&quot;');
+	}
+
 	onMount(() => {
 		let map;
 		let observer;
@@ -38,13 +47,52 @@
 			map = new mapboxgl.Map({
 				container: mapEl,
 				style: 'mapbox://styles/mapbox/dark-v11',
-				center: [-79.048, 33.553],
-				zoom: 10,
+				center: [-6.2764, 48.3623],
+				zoom: 2,
 				failIfMajorPerformanceCaveat: false
 			});
 
+			const addPins = () => {
+				const bounds = new mapboxgl.LngLatBounds();
+				let hasPins = false;
+
+				for (const gallery of data.galleries) {
+					const image = gallery.image
+						? `<img class="pin-image" src="${escapeHtml(gallery.image)}" alt="${escapeHtml(gallery.title)}">`
+						: '';
+					const lngLat = [gallery.lng, gallery.lat];
+					const popup = new mapboxgl.Popup({
+						offset: 18,
+						closeButton: false,
+						closeOnClick: false
+					})
+						.setLngLat(lngLat)
+						.setHTML(`<p class="pin-title">${escapeHtml(gallery.title)}</p>${image}`);
+
+					const marker = new mapboxgl.Marker({ color: '#4FC3F7' })
+						.setLngLat(lngLat)
+						.addTo(map);
+					const el = marker.getElement();
+					el.style.cursor = 'pointer';
+					el.addEventListener('mouseenter', () => popup.addTo(map));
+					el.addEventListener('mouseleave', () => popup.remove());
+					el.addEventListener('click', () => goto(`/gallery/${gallery.id}`));
+
+					bounds.extend(lngLat);
+					hasPins = true;
+				}
+
+				if (hasPins) {
+					map.fitBounds(bounds, { padding: 64, maxZoom: 8, duration: 0 });
+				}
+			};
+
+			map.on('load', () => {
+				addPins();
+				map.resize();
+			});
+
 			const resize = () => map?.resize();
-			map.on('load', resize);
 			requestAnimationFrame(resize);
 			observer = new ResizeObserver(resize);
 			observer.observe(mapEl);
@@ -100,6 +148,33 @@
 		color: var(--muted);
 	}
 
+	.map :global(.mapboxgl-popup-content) {
+		background: var(--surface);
+		color: var(--text);
+		border: 1px solid var(--border);
+		border-radius: 8px;
+		padding: 0.7rem 0.85rem;
+		box-shadow: none;
+	}
+
+	.map :global(.mapboxgl-popup-tip) {
+		border-top-color: var(--surface);
+	}
+
+	.map :global(.pin-title) {
+		margin: 0;
+		font-size: 0.9375rem;
+		font-weight: 600;
+	}
+
+	.map :global(.pin-image) {
+		display: block;
+		width: 12rem;
+		max-height: 9rem;
+		margin-top: 0.5rem;
+		object-fit: cover;
+	}
+
 	.sr-only {
 		position: absolute;
 		width: 1px;

+ 26 - 0
src/routes/gallery/[galleryId]/+page.server.js

@@ -0,0 +1,26 @@
+import { error } from '@sveltejs/kit';
+import { ObjectId } from 'mongodb';
+import { galleryImageSrc } from '#lib/gallery.js';
+import { getDb } from '#lib/server/mongo.js';
+
+export const prerender = false;
+
+export async function load({ params }) {
+	if (!ObjectId.isValid(params.galleryId)) error(404, 'Gallery not found');
+
+	const db = await getDb();
+	const doc = await db.collection('galleries').findOne(
+		{ _id: new ObjectId(params.galleryId) },
+		{ projection: { title: 1, images: 1 } }
+	);
+
+	if (!doc) error(404, 'Gallery not found');
+
+	const images = Array.isArray(doc.images) ? doc.images.map(galleryImageSrc).filter(Boolean) : [];
+
+	return {
+		id: String(doc._id),
+		title: doc.title ?? 'Gallery',
+		images
+	};
+}

+ 94 - 0
src/routes/gallery/[galleryId]/+page.svelte

@@ -0,0 +1,94 @@
+<script>
+	let { data } = $props();
+
+	function imageName(src) {
+		return String(src).split('/').pop() ?? '';
+	}
+</script>
+
+<svelte:head>
+	<title>{data.title} — Lee Morgan</title>
+	<meta name="description" content={`Photos from ${data.title}.`} />
+</svelte:head>
+
+<section class="shots-page">
+	<p class="back"><a href="/gallery">Gallery</a></p>
+	<h1>{data.title}</h1>
+	{#if data.images.length}
+		<ul class="shots">
+			{#each data.images as src}
+				<li>
+					<a class="card shot" href={`/gallery/${data.id}/${imageName(src)}`}>
+						<img {src} alt="" />
+					</a>
+				</li>
+			{/each}
+		</ul>
+	{:else}
+		<p class="empty">No photos in this gallery.</p>
+	{/if}
+</section>
+
+<style>
+	.shots-page {
+		max-width: 52rem;
+	}
+
+	.back {
+		margin: 0 0 1rem;
+		font-size: 0.875rem;
+	}
+
+	h1 {
+		font-size: 1.25rem;
+		font-weight: 500;
+		letter-spacing: 0.02em;
+		margin-bottom: 1.5rem;
+	}
+
+	.shots {
+		list-style: none;
+		margin: 0;
+		padding: 0;
+		display: grid;
+		grid-template-columns: repeat(2, 1fr);
+		gap: 0.85rem;
+	}
+
+	.shot {
+		position: relative;
+		z-index: 0;
+		display: block;
+		padding: 0;
+		overflow: hidden;
+		color: inherit;
+		text-decoration: none;
+		aspect-ratio: 1;
+		transition: transform 180ms ease;
+	}
+
+	@media (hover: hover) {
+		.shot:hover {
+			z-index: 2;
+			text-decoration: none;
+			transform: scale(1.08);
+		}
+	}
+
+	.shot img {
+		display: block;
+		width: 100%;
+		height: 100%;
+		object-fit: cover;
+	}
+
+	.empty {
+		color: var(--muted);
+	}
+
+	@media (min-width: 800px) {
+		.shots {
+			grid-template-columns: repeat(4, 1fr);
+		}
+	}
+</style>

+ 27 - 0
src/routes/gallery/[galleryId]/[file]/+page.server.js

@@ -0,0 +1,27 @@
+import { error } from '@sveltejs/kit';
+import { ObjectId } from 'mongodb';
+import { galleryImageFile, galleryImageSrc } from '#lib/gallery.js';
+import { getDb } from '#lib/server/mongo.js';
+
+export const prerender = false;
+
+export async function load({ params }) {
+	if (!ObjectId.isValid(params.galleryId)) error(404, 'Gallery not found');
+
+	const db = await getDb();
+	const doc = await db.collection('galleries').findOne(
+		{ _id: new ObjectId(params.galleryId) },
+		{ projection: { title: 1, images: 1 } }
+	);
+
+	if (!doc) error(404, 'Gallery not found');
+
+	const files = Array.isArray(doc.images) ? doc.images.map(galleryImageFile) : [];
+	if (!files.includes(params.file)) error(404, 'Photo not found');
+
+	return {
+		id: String(doc._id),
+		title: doc.title ?? 'Gallery',
+		src: galleryImageSrc(params.file)
+	};
+}

+ 38 - 0
src/routes/gallery/[galleryId]/[file]/+page.svelte

@@ -0,0 +1,38 @@
+<script>
+	let { data } = $props();
+</script>
+
+<svelte:head>
+	<title>{data.title} — Lee Morgan</title>
+</svelte:head>
+
+<div class="viewer">
+	<a class="btn btn-ghost back" href={`/gallery/${data.id}`}>Back to gallery</a>
+	<img src={data.src} alt={data.title} />
+</div>
+
+<style>
+	.viewer {
+		position: fixed;
+		inset: 0;
+		z-index: 30;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		background: var(--bg);
+	}
+
+	.back {
+		position: absolute;
+		top: 1.25rem;
+		left: 1.25rem;
+		z-index: 1;
+	}
+
+	img {
+		display: block;
+		max-width: 100%;
+		max-height: 100%;
+		object-fit: contain;
+	}
+</style>

+ 42 - 0
src/routes/galleryImages/[file]/+server.js

@@ -0,0 +1,42 @@
+import { createReadStream } from 'node:fs';
+import { stat } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { Readable } from 'node:stream';
+import { error } from '@sveltejs/kit';
+
+export const prerender = false;
+
+const ROOT = join(homedir(), 'srv/leemorgan_dev/galleryImages');
+
+const TYPES = {
+	jpg: 'image/jpeg',
+	jpeg: 'image/jpeg',
+	png: 'image/png',
+	webp: 'image/webp',
+	gif: 'image/gif'
+};
+
+export async function GET({ params }) {
+	const file = params.file;
+	if (!/^[A-Za-z0-9._-]+$/.test(file)) error(400, 'Invalid file');
+
+	const path = join(ROOT, file);
+
+	try {
+		const info = await stat(path);
+		if (!info.isFile()) error(404, 'Not found');
+	} catch {
+		error(404, 'Not found');
+	}
+
+	const ext = file.split('.').pop()?.toLowerCase() ?? '';
+	const body = Readable.toWeb(createReadStream(path));
+
+	return new Response(body, {
+		headers: {
+			'content-type': TYPES[ext] ?? 'application/octet-stream',
+			'cache-control': 'public, max-age=31536000, immutable'
+		}
+	});
+}