9 Komitmen c566deccc2 ... 9c90fe3a98

Pembuat SHA1 Pesan Tanggal
  Lee Morgan 9c90fe3a98 Prepare for production 2 minggu lalu
  Lee Morgan 8284803559 Display currencies 2 minggu lalu
  Lee Morgan 8616b170b5 Create the currency page to display locations for user to click on 2 minggu lalu
  Lee Morgan bf3b071caf Add resume 2 minggu lalu
  Lee Morgan 307c5d4940 Create the gallery page 2 minggu lalu
  Lee Morgan a8deb64f5e Add map to gallery page. 2 minggu lalu
  Lee Morgan 716c4cfca0 Add projects page 2 minggu lalu
  Lee Morgan 209ff6af5c Create the landing page 2 minggu lalu
  Lee Morgan 9cc85dbe4c Create agents file 2 minggu lalu
35 mengubah file dengan 2071 tambahan dan 17 penghapusan
  1. 88 0
      AGENTS.md
  2. 25 0
      deploy/leemorgan-dev.service
  3. 42 0
      deploy/nginx.conf
  4. 171 5
      package-lock.json
  5. 6 1
      package.json
  6. 308 0
      src/app.css
  7. 16 0
      src/env.js
  8. 5 1
      src/lib/assets/favicon.svg
  9. 6 0
      src/lib/currency.js
  10. 11 0
      src/lib/gallery.js
  11. 57 0
      src/lib/projects.js
  12. 23 0
      src/lib/server/mongo.js
  13. 1 0
      src/routes/+layout.js
  14. 70 1
      src/routes/+layout.svelte
  15. 58 2
      src/routes/+page.svelte
  16. 37 0
      src/routes/currency/+page.server.js
  17. 108 0
      src/routes/currency/+page.svelte
  18. 43 0
      src/routes/currency/[location]/+page.server.js
  19. 119 0
      src/routes/currency/[location]/+page.svelte
  20. 31 0
      src/routes/currency/[location]/[id]/+page.server.js
  21. 111 0
      src/routes/currency/[location]/[id]/+page.svelte
  22. 31 0
      src/routes/currency/[location]/[id]/[side]/+page.server.js
  23. 40 0
      src/routes/currency/[location]/[id]/[side]/+page.svelte
  24. 42 0
      src/routes/currencyimages/[file]/+server.js
  25. 45 0
      src/routes/gallery/+page.server.js
  26. 195 0
      src/routes/gallery/+page.svelte
  27. 26 0
      src/routes/gallery/[galleryId]/+page.server.js
  28. 94 0
      src/routes/gallery/[galleryId]/+page.svelte
  29. 27 0
      src/routes/gallery/[galleryId]/[file]/+page.server.js
  30. 38 0
      src/routes/gallery/[galleryId]/[file]/+page.svelte
  31. 42 0
      src/routes/galleryImages/[file]/+server.js
  32. 109 0
      src/routes/projects/+page.svelte
  33. 38 0
      src/routes/resume/+page.svelte
  34. TEMPAT SAMPAH
      static/resume.pdf
  35. 8 7
      vite.config.js

+ 88 - 0
AGENTS.md

@@ -0,0 +1,88 @@
+# Style
+
+Professional, minimalistic, and high-contrast. Emphasize clarity, generous whitespace, and restrained use of color. The design should feel precise and technical. Black is the dominant base; light blue is the single accent for interactive and highlighted elements.
+
+Follow this style guide for all visual decisions. When in doubt, prioritize minimalism, high contrast, and the single light-blue accent.
+
+## Colors
+
+| Role              | Color          | Hex       | Usage                                      |
+|-------------------|----------------|-----------|--------------------------------------------|
+| Background        | Near-black     | `#0A0A0A` | Page and section backgrounds               |
+| Surface / Card    | Dark charcoal  | `#141414` | Cards, panels, elevated areas              |
+| Primary text      | Off-white      | `#F5F5F5` | Headings and body text                     |
+| Secondary text    | Medium gray    | `#A0A0A0` | Captions, metadata, supporting text        |
+| Accent            | Light blue     | `#4FC3F7` | Links, buttons, focus states, highlights   |
+| Accent hover      | Lighter blue   | `#81D4FA` | Hover on accent elements                   |
+| Border / Divider  | Subtle gray    | `#2A2A2A` | Card borders, separators                   |
+| Error / Destructive | Soft red    | `#EF5350` | Only when needed (rare)                    |
+
+k## Color Rules
+- Use the light blue accent sparingly — primarily for interactive elements and key highlights.
+- Prefer solid fills over gradients or shadows. When elevation is needed, use a 1px border in `#2A2A2A` instead of drop shadows.
+- Maintain strong contrast. Body text should always be `#F5F5F5` or close to it on dark backgrounds.
+- Avoid pure black (`#000000`) for large areas; use `#0A0A0A`.
+
+## Typography
+
+- **Font stack**: `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
+- **Headings**: Weight 500–600. Optional slight letter-spacing (0.02–0.04em) on large headings.
+- **Body**: Weight 400, line-height 1.6–1.7.
+- **Scale**: Keep hierarchy simple (e.g. 2.5rem / 1.75rem / 1.25rem / 1rem). Avoid many intermediate sizes.
+- **Monospace** (for code or technical details): `ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`
+
+## Layout
+
+- Maximum content width: 720–900px for text-heavy pages; wider for project grids if needed.
+- Vertical rhythm: 1.5–2rem between related elements, 3–4rem between major sections.
+- Left-aligned text by default. Center only short hero statements.
+- Numbered section labels (e.g. “01 Projects”) are optional but acceptable for a structured feel.
+
+## Components
+
+### Buttons
+- **Primary**: Light blue background (`#4FC3F7`), dark text (`#0A0A0A`), 8–10px border-radius, ~0.75rem horizontal / 0.5rem vertical padding.
+- **Secondary / Ghost**: Transparent background, 1px light blue border, light blue text.
+- **Hover**: Slightly lighten the blue or reduce opacity to ~90%. No heavy scale transforms.
+- **Disabled**: Reduced opacity (0.4–0.5), no pointer cursor.
+- Prefer rectangular or lightly rounded shapes. Avoid pill shapes unless used consistently for tags.
+
+### Links
+- Default: Light blue (`#4FC3F7`), no underline by default.
+- Hover: Underline or shift to `#81D4FA`.
+- Visited: Same as default (or a slightly muted blue if distinction is desired).
+- Inline links in body text must remain light blue for visibility.
+
+### Cards
+- Background `#141414`, 1px border `#2A2A2A`, 8–12px border-radius.
+- Padding 1.25–1.5rem.
+- Optional hover: border color shifts toward a muted blue or slight background lighten.
+- Title in primary text, description in secondary text. Keep internal spacing tight but readable.
+- Use for projects, skills, contact items, etc. Avoid heavy imagery unless it is the focus.
+
+### Forms & Inputs
+- Background slightly lighter than card (`#1A1A1A`), 1px border `#2A2A2A`.
+- Focus: Border changes to light blue.
+- Placeholder text in `#666`.
+- Labels above inputs in secondary text color.
+
+### Navigation
+- Simple horizontal or vertical list.
+- Active state indicated by light blue text or a thin underline.
+- Minimal height. No large logo treatments required.
+
+## Other Guidelines
+
+- **Icons**: Simple, line-style, monochrome (white or light blue). Avoid filled multi-color icons.
+- **Images**: Prefer subtle borders or none. Use dark overlays if text must sit on photos.
+- **Motion**: Short, subtle transitions (150–200ms) on hover/focus. Avoid large animated elements.
+- **Accessibility**: Ensure accent blue meets contrast on dark surfaces. Provide visible focus outlines.
+
+# Code
+
+- Never use TypeScript
+
+# Other
+- Do not ever run the server, I will do that manually
+- Never edit environment variables
+- Whenever I type "mnc" anywhere in the message, that means "make no changes". Do not edit any of the code, just respond to my message

+ 25 - 0
deploy/leemorgan-dev.service

@@ -0,0 +1,25 @@
+[Unit]
+Description=leemorgan.dev
+After=network.target mongod.service
+Wants=mongod.service
+
+[Service]
+Type=simple
+User=leemorgan
+Group=leemorgan
+WorkingDirectory=/home/leemorgan/code/leemorgan_dev
+Environment=NODE_ENV=production
+Environment=APP_ENV=production
+Environment=HOST=127.0.0.1
+Environment=PORT=3000
+Environment=ORIGIN=https://leemorgan.dev
+Environment=PROTOCOL_HEADER=x-forwarded-proto
+Environment=HOST_HEADER=x-forwarded-host
+EnvironmentFile=/home/leemorgan/code/leemorgan_dev/.env
+ExecStart=/usr/bin/node build
+Restart=on-failure
+RestartSec=5
+KillSignal=SIGINT
+
+[Install]
+WantedBy=multi-user.target

+ 42 - 0
deploy/nginx.conf

@@ -0,0 +1,42 @@
+map $http_upgrade $connection_upgrade {
+	default upgrade;
+	'' close;
+}
+
+server {
+	listen 80;
+	listen [::]:80;
+	server_name leemorgan.dev www.leemorgan.dev;
+	return 301 https://leemorgan.dev$request_uri;
+}
+
+server {
+	listen 443 ssl http2;
+	listen [::]:443 ssl http2;
+	server_name www.leemorgan.dev;
+	return 301 https://leemorgan.dev$request_uri;
+}
+
+server {
+	listen 443 ssl http2;
+	listen [::]:443 ssl http2;
+	server_name leemorgan.dev;
+
+	# ssl_certificate     /etc/letsencrypt/live/leemorgan.dev/fullchain.pem;
+	# ssl_certificate_key /etc/letsencrypt/live/leemorgan.dev/privkey.pem;
+	# include /etc/letsencrypt/options-ssl-nginx.conf;
+
+	client_max_body_size 20m;
+
+	location / {
+		proxy_pass http://127.0.0.1:3000;
+		proxy_http_version 1.1;
+		proxy_set_header Host $host;
+		proxy_set_header X-Real-IP $remote_addr;
+		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+		proxy_set_header X-Forwarded-Proto $scheme;
+		proxy_set_header Upgrade $http_upgrade;
+		proxy_set_header Connection $connection_upgrade;
+		proxy_read_timeout 60s;
+	}
+}

+ 171 - 5
package-lock.json

@@ -7,8 +7,12 @@
 		"": {
 			"name": "leemorgan-dev",
 			"version": "0.0.1",
+			"dependencies": {
+				"mapbox-gl": "^3.33.0",
+				"mongodb": "^7.7.0"
+			},
 			"devDependencies": {
-				"@sveltejs/adapter-auto": "^8.0.0",
+				"@sveltejs/adapter-node": "^6.0.0",
 				"@sveltejs/kit": "^3.0.0",
 				"@sveltejs/vite-plugin-svelte": "^7.2.0",
 				"svelte": "^5.57.1",
@@ -65,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",
@@ -379,10 +392,10 @@
 				"acorn": "^8.9.0"
 			}
 		},
-		"node_modules/@sveltejs/adapter-auto": {
-			"version": "8.0.0",
-			"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-8.0.0.tgz",
-			"integrity": "sha512-CKOwUS865xfoZn3CpM868slPfitsQ4vmxAR/yOfTIguAwStgkhjhzV0zEhmkzPalH338LJ9XnQX9vfQeY7220A==",
+		"node_modules/@sveltejs/adapter-node": {
+			"version": "6.0.0",
+			"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-6.0.0.tgz",
+			"integrity": "sha512-s7muM4JmJ75OZ8o0G4VRhrEKdi8twuEm3t2o/qQsdlm12hKaX7S/CuUBuw3n983wB/IFBSpzG2ugX0xdGzbKwg==",
 			"dev": true,
 			"license": "MIT",
 			"peerDependencies": {
@@ -454,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",
@@ -487,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",
@@ -896,6 +933,83 @@
 				"@jridgewell/sourcemap-codec": "^1.6.0"
 			}
 		},
+		"node_modules/mapbox-gl": {
+			"version": "3.33.0",
+			"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.33.0.tgz",
+			"integrity": "sha512-2HpIrVedccHedolZyMVG/7FXdpgmsLTENgEDsX71x3/gKDGfYMf1FCRY0prntknkn/JXcraUKXlBTv7vkr0EXg==",
+			"license": "SEE LICENSE IN LICENSE.txt",
+			"workspaces": [
+				"src/style-spec",
+				"plugins/mapbox-gl-pmtiles-provider",
+				"test/bundlers/*",
+				"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",
@@ -988,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",
@@ -1047,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",
@@ -1111,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",
@@ -1209,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",

+ 6 - 1
package.json

@@ -7,10 +7,11 @@
 		"dev": "vite dev",
 		"build": "vite build",
 		"preview": "vite preview",
+		"start": "node build",
 		"prepare": "svelte-kit sync || echo ''"
 	},
 	"devDependencies": {
-		"@sveltejs/adapter-auto": "^8.0.0",
+		"@sveltejs/adapter-node": "^6.0.0",
 		"@sveltejs/kit": "^3.0.0",
 		"@sveltejs/vite-plugin-svelte": "^7.2.0",
 		"svelte": "^5.57.1",
@@ -19,5 +20,9 @@
 	"imports": {
 		"#lib": "./src/lib/index.js",
 		"#lib/*": "./src/lib/*"
+	},
+	"dependencies": {
+		"mapbox-gl": "^3.33.0",
+		"mongodb": "^7.7.0"
 	}
 }

+ 308 - 0
src/app.css

@@ -0,0 +1,308 @@
+:root {
+	--bg: #0a0a0a;
+	--surface: #141414;
+	--text: #f5f5f5;
+	--muted: #a0a0a0;
+	--accent: #4fc3f7;
+	--accent-hover: #81d4fa;
+	--border: #2a2a2a;
+
+	--font: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+	--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+
+	--rail: 22rem;
+	--ease: 180ms ease;
+	--radius: 8px;
+}
+
+*,
+*::before,
+*::after {
+	box-sizing: border-box;
+}
+
+html {
+	color-scheme: dark;
+	background: var(--bg);
+}
+
+html,
+body {
+	margin: 0;
+	padding: 0;
+	height: 100%;
+	min-height: 100%;
+}
+
+body {
+	font-family: var(--font);
+	font-size: 1rem;
+	font-weight: 400;
+	line-height: 1.65;
+	color: var(--text);
+	background: var(--bg);
+	-webkit-font-smoothing: antialiased;
+	-moz-osx-font-smoothing: grayscale;
+}
+
+::selection {
+	background: color-mix(in srgb, var(--accent) 35%, transparent);
+	color: var(--text);
+}
+
+h1,
+h2,
+p,
+dl,
+dt,
+dd {
+	margin: 0;
+}
+
+a {
+	color: var(--accent);
+	text-decoration: none;
+	transition: color var(--ease);
+}
+
+a:hover {
+	color: var(--accent-hover);
+	text-decoration: underline;
+	text-underline-offset: 0.18em;
+}
+
+:focus-visible {
+	outline: 2px solid var(--accent);
+	outline-offset: 3px;
+}
+
+.skip {
+	position: absolute;
+	left: 0.75rem;
+	top: 0.75rem;
+	z-index: 20;
+	padding: 0.5rem 0.75rem;
+	background: var(--surface);
+	border: 1px solid var(--border);
+	border-radius: var(--radius);
+	color: var(--text);
+	transform: translateY(-160%);
+}
+
+.skip:focus {
+	transform: none;
+	text-decoration: none;
+}
+
+.shell {
+	display: grid;
+	grid-template-columns: var(--rail) minmax(0, 1fr);
+	grid-template-rows: minmax(0, 1fr);
+	height: 100svh;
+	min-height: 100svh;
+}
+
+.rail {
+	position: sticky;
+	top: 0;
+	display: flex;
+	flex-direction: column;
+	justify-content: space-between;
+	height: 100svh;
+	padding: 2rem 2rem 1.75rem;
+	border-right: 1px solid var(--border);
+}
+
+.rail-top {
+	display: flex;
+	flex-direction: column;
+	align-items: flex-start;
+	gap: 1.75rem;
+}
+
+.identity {
+	display: flex;
+	flex-direction: column;
+	gap: 0.45rem;
+}
+
+.identity h1,
+.identity .site-name {
+	font-size: 1.25rem;
+	font-weight: 600;
+	letter-spacing: 0.03em;
+	line-height: 1.2;
+}
+
+.wordmark {
+	color: var(--text);
+	text-decoration: none;
+}
+
+.wordmark:hover {
+	color: var(--accent);
+	text-decoration: none;
+}
+
+.role {
+	font-size: 0.9375rem;
+	line-height: 1.45;
+	color: var(--muted);
+}
+
+.status {
+	display: inline-flex;
+	align-items: center;
+	gap: 0.5rem;
+	font-family: var(--mono);
+	font-size: 0.75rem;
+	letter-spacing: 0.06em;
+	color: var(--muted);
+}
+
+.status-dot {
+	width: 0.4rem;
+	height: 0.4rem;
+	background: var(--accent);
+}
+
+.place {
+	font-size: 0.875rem;
+	color: var(--muted);
+}
+
+.menu {
+	list-style: none;
+	margin: 0;
+	padding: 0;
+	display: flex;
+	flex-direction: column;
+	gap: 0.15rem;
+}
+
+.menu a {
+	color: var(--accent);
+	font-size: 1.125rem;
+	font-weight: 500;
+	line-height: 1.45;
+	text-decoration: none;
+	text-underline-offset: 0.18em;
+}
+
+.menu a:hover {
+	color: var(--accent-hover);
+	text-decoration: underline;
+}
+
+.menu a[aria-current='page'] {
+	text-decoration: underline;
+}
+
+.rail-bottom {
+	display: flex;
+	flex-direction: column;
+	gap: 0.35rem;
+}
+
+.rail-bottom a {
+	color: var(--muted);
+	font-size: 0.875rem;
+}
+
+.rail-bottom a:hover {
+	color: var(--accent);
+}
+
+.btn {
+	display: inline-flex;
+	align-items: center;
+	padding: 0.5rem 0.75rem;
+	border-radius: var(--radius);
+	border: 1px solid transparent;
+	background: var(--accent);
+	color: var(--bg);
+	font: inherit;
+	font-size: 0.9375rem;
+	font-weight: 500;
+	letter-spacing: 0.01em;
+	text-decoration: none;
+	transition:
+		background var(--ease),
+		color var(--ease);
+}
+
+.btn:hover {
+	background: var(--accent-hover);
+	color: var(--bg);
+	text-decoration: none;
+}
+
+.card {
+	background: var(--surface);
+	border: 1px solid var(--border);
+	border-radius: 10px;
+	padding: 1.25rem 1.4rem;
+	transition: border-color var(--ease);
+}
+
+@media (hover: hover) {
+	.card:hover {
+		border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
+	}
+}
+
+.page {
+	min-height: 0;
+	overflow: auto;
+	padding: 2rem clamp(1.5rem, 4vw, 4rem) 4rem;
+}
+
+.page-flush {
+	position: relative;
+	height: 100%;
+	min-height: 0;
+	padding: 0;
+	overflow: hidden;
+}
+
+.page-flush .site-main {
+	position: relative;
+	height: 100%;
+	min-height: 0;
+}
+
+@media (max-width: 860px) {
+	.shell {
+		grid-template-columns: 1fr;
+		grid-template-rows: auto minmax(0, 1fr);
+	}
+
+	.rail {
+		position: static;
+		height: auto;
+		border-right: 0;
+		border-bottom: 1px solid var(--border);
+		padding: 1.25rem 1.25rem 1.35rem;
+		gap: 1.25rem;
+	}
+
+	.rail-top {
+		gap: 1rem;
+	}
+
+	.rail-bottom {
+		display: none;
+	}
+
+	.page:not(.page-flush) {
+		padding: 1.75rem 1.25rem 3rem;
+	}
+}
+
+@media (prefers-reduced-motion: reduce) {
+	*,
+	*::before,
+	*::after {
+		transition-duration: 0.01ms !important;
+	}
+}

+ 16 - 0
src/env.js

@@ -0,0 +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 ?? ''
+	}
+});

+ 5 - 1
src/lib/assets/favicon.svg

@@ -1 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Lee Morgan">
+	<rect width="32" height="32" fill="#0A0A0A"/>
+	<rect x="7" y="6" width="3.5" height="20" fill="#4FC3F7"/>
+	<rect x="7" y="22.5" width="13" height="3.5" fill="#4FC3F7"/>
+</svg>

+ 6 - 0
src/lib/currency.js

@@ -0,0 +1,6 @@
+export function currencyImageSrc(value) {
+	const raw = String(value ?? '');
+	if (!raw) return '';
+	if (raw.startsWith('/currencyimages/')) return raw;
+	return `/currencyimages/${raw.replace(/^\/+/, '')}`;
+}

+ 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() ?? '';
+}

+ 57 - 0
src/lib/projects.js

@@ -0,0 +1,57 @@
+export const projects = [
+	{
+		name: 'The Subline',
+		href: 'https://subline.leemorgan.dev',
+		description:
+			'A web application to assist restaurant owners in managing their inventories. Providing insights into usage, waste and other details through the use of data.'
+	},
+	{
+		name: 'Suma',
+		href: 'https://suma.leemorgan.dev',
+		description:
+			'A simple web application to help people to budget their money in a simple and approachable manner.'
+	},
+	{
+		name: 'Workout Tracker',
+		href: 'https://workout.leemorgan.dev',
+		description:
+			'Track lifting sessions, compare your last workout, and save history to see progress over time.'
+	},
+	{
+		name: 'Recipes',
+		href: 'https://recipes.leemorgan.dev',
+		description: 'A web application for creating and sharing recipes.'
+	},
+	{
+		name: 'Sudoku Solver',
+		href: 'https://leemorgan.dev/sudoku',
+		description:
+			'Stuck on a particularly difficult game of sudoku? Enter any sudoku puzzle and have it solved immediately.'
+	},
+	{
+		name: 'The Birthday Paradox',
+		href: 'https://leemorgan.dev/birthdayparadox',
+		description: 'A demonstration of the counterintuitive birthday paradox.'
+	},
+	{
+		name: 'BLACKLIST.AERO',
+		href: 'https://www.blacklist.aero/',
+		description:
+			'A site to enable airports to blacklist and check on the status of those who use their services.'
+	},
+	{
+		name: 'Black Plag',
+		href: 'https://github.com/MorganLee909/black-plag',
+		description: 'Website for searching for assignment plagiarism using TF-IDF. Built for 2U bootcamps.'
+	},
+	{
+		name: 'Tetris',
+		href: 'https://leemorgan.dev/tetris',
+		description: 'A tetris clone built in a single HTML file.'
+	},
+	{
+		name: 'Woodworking in 3D',
+		href: 'https://woodworking.leemorgan.dev',
+		description: 'Collection of woodworking projects planned in 3D with ThreeJS.'
+	}
+];

+ 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');
+}

+ 1 - 0
src/routes/+layout.js

@@ -0,0 +1 @@
+export const prerender = true;

+ 70 - 1
src/routes/+layout.svelte

@@ -1,11 +1,80 @@
 <script>
+	import '../app.css';
 	import favicon from '#lib/assets/favicon.svg';
+	import { page } from '$app/state';
 
 	let { children } = $props();
+
+	const isHome = $derived(page.url.pathname === '/');
+	const isProjects = $derived(page.url.pathname === '/projects');
+	const isGalleryMap = $derived(page.url.pathname === '/gallery');
+	const isGallery = $derived(isGalleryMap || page.url.pathname.startsWith('/gallery/'));
+	const isResume = $derived(page.url.pathname === '/resume');
+	const isCurrency = $derived(page.url.pathname === '/currency' || page.url.pathname.startsWith('/currency/'));
 </script>
 
 <svelte:head>
 	<link rel="icon" href={favicon} />
+	<meta name="theme-color" content="#0A0A0A" />
+	<meta name="color-scheme" content="dark" />
 </svelte:head>
 
-{@render children()}
+<a class="skip" href="#main">Skip to content</a>
+
+<div class="shell">
+	<aside class="rail">
+		<div class="rail-top">
+			<div class="identity">
+				{#if isHome}
+					<h1><a class="wordmark" href="/">Lee Morgan</a></h1>
+				{:else}
+					<p class="site-name"><a class="wordmark" href="/">Lee Morgan</a></p>
+				{/if}
+				<p class="role">Web developer &amp; systems administrator</p>
+			</div>
+			<p class="status">
+				<span class="status-dot" aria-hidden="true"></span>
+				Available for work
+			</p>
+			<p class="place">Murrells Inlet, SC</p>
+			<nav aria-label="Site">
+				<ul class="menu">
+					<li>
+						<a href="/projects" aria-current={isProjects ? 'page' : undefined}>Projects</a>
+					</li>
+					<li>
+						<a href="/gallery" aria-current={isGallery ? 'page' : undefined}>Gallery</a>
+					</li>
+					<li>
+						<a href="/currency" data-sveltekit-reload aria-current={isCurrency ? 'page' : undefined}
+							>Currency</a
+						>
+					</li>
+					<li>
+						<a href="/resume" data-sveltekit-reload aria-current={isResume ? 'page' : undefined}
+							>Resume/CV</a
+						>
+					</li>
+					<li>
+						<a href="https://git.leemorgan.dev/lee" rel="noopener noreferrer" target="_blank">Git</a>
+					</li>
+					<li>
+						<a href="https://www.linkedin.com/in/leemorganio" rel="noopener noreferrer" target="_blank"
+							>LinkedIn</a
+						>
+					</li>
+				</ul>
+			</nav>
+			<a class="btn" href="mailto:lee@leemorgan.dev">Get in touch</a>
+		</div>
+		<div class="rail-bottom">
+			<a href="mailto:lee@leemorgan.dev">lee@leemorgan.dev</a>
+		</div>
+	</aside>
+
+	<div class="page" class:page-flush={isGalleryMap || isResume}>
+		<main id="main" class="site-main">
+			{@render children()}
+		</main>
+	</div>
+</div>

+ 58 - 2
src/routes/+page.svelte

@@ -1,2 +1,58 @@
-<h1>Welcome to SvelteKit</h1>
-<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
+<svelte:head>
+	<title>Lee Morgan — Web developer &amp; systems administrator</title>
+	<meta
+		name="description"
+		content="Lee Morgan is a web developer and systems administrator based in Murrells Inlet, South Carolina. Currently building and operating BLACKLIST.AERO."
+	/>
+</svelte:head>
+
+<article class="copy">
+	<p class="lede">
+		I currently work as a web developer and systems administrator for
+		<a href="https://www.blacklist.aero/" rel="noopener noreferrer" target="_blank">BLACKLIST.AERO</a>,
+		the Global Aviation Register of Defaulter Companies.
+	</p>
+
+	<p>
+		The platform serves as a public registry and self-regulation tool for the aviation industry,
+		helping companies track and address non-payment issues involving airport services, ground
+		handling, fueling, and related obligations. In this role I handle the technical side of the
+		platform—building and maintaining the web application, managing Linux servers, and working with
+		the underlying databases—so the register remains reliable and accessible for verified claimants
+		and industry users.
+	</p>
+
+	<p>
+		Professionally I focus on web development, Linux server management, database administration, and
+		full-stack application development. On the backend I primarily use Rust with the Actix
+		framework; on the frontend I work with Svelte or straightforward HTML, CSS, and JavaScript; and
+		for data I typically use MongoDB. Outside of the day job I run Debian servers at home and use
+		Debian with i3 on my desktop, which keeps me hands-on with efficient, lightweight Linux
+		environments and practical systems administration.
+	</p>
+
+	<p>
+		I’m based in Murrells Inlet, South Carolina, and I’m open to new work. Whether that means
+		additional project-based development, contract systems work, or a longer-term role, I’m
+		interested in opportunities that involve building reliable web applications, managing Linux
+		infrastructure, or working with databases and backend systems. You can read my
+		<a href="/resume">resume</a>.
+	</p>
+</article>
+
+<style>
+	.copy {
+		max-width: 40rem;
+	}
+
+	.copy p + p {
+		margin-top: 1.5rem;
+	}
+
+	.lede {
+		font-size: 1.25rem;
+		font-weight: 500;
+		line-height: 1.5;
+		letter-spacing: 0.01em;
+	}
+</style>

+ 37 - 0
src/routes/currency/+page.server.js

@@ -0,0 +1,37 @@
+import { currencyImageSrc } from '#lib/currency.js';
+import { getDb } from '#lib/server/mongo.js';
+
+export const prerender = false;
+
+export async function load() {
+	let locations = [];
+
+	try {
+		const db = await getDb();
+		const docs = await db
+			.collection('currencies')
+			.find({})
+			.project({ location: 1, frontImage: 1 })
+			.toArray();
+
+		const groups = new Map();
+		for (const doc of docs) {
+			const name = doc.location?.trim() || 'Unknown';
+			const group = groups.get(name) ?? {
+				name,
+				slug: encodeURIComponent(name),
+				count: 0,
+				image: ''
+			};
+			group.count += 1;
+			if (!group.image) group.image = currencyImageSrc(doc.frontImage);
+			groups.set(name, group);
+		}
+
+		locations = [...groups.values()].sort((a, b) => a.name.localeCompare(b.name));
+	} catch (error) {
+		console.error('Failed to load currencies', error);
+	}
+
+	return { locations };
+}

+ 108 - 0
src/routes/currency/+page.svelte

@@ -0,0 +1,108 @@
+<script>
+	let { data } = $props();
+</script>
+
+<svelte:head>
+	<title>Currency — Lee Morgan</title>
+	<meta name="description" content="Currency collection grouped by location." />
+</svelte:head>
+
+<section class="currency-page">
+	<h1>Currency</h1>
+	{#if data.locations.length}
+		<ul class="places">
+			{#each data.locations as place}
+				<li>
+					<a class="card place" href={`/currency/${place.slug}`}>
+						{#if place.image}
+							<img src={place.image} alt="" />
+						{/if}
+						<div class="copy">
+							<h2>{place.name}</h2>
+							<p>{place.count} {place.count === 1 ? 'note' : 'notes'}</p>
+						</div>
+					</a>
+				</li>
+			{/each}
+		</ul>
+	{:else}
+		<p class="empty">No locations yet.</p>
+	{/if}
+</section>
+
+<style>
+	.currency-page {
+		max-width: 52rem;
+	}
+
+	h1 {
+		font-size: 1.25rem;
+		font-weight: 500;
+		letter-spacing: 0.02em;
+		margin-bottom: 1.5rem;
+	}
+
+	.places {
+		list-style: none;
+		margin: 0;
+		padding: 0;
+		display: grid;
+		grid-template-columns: 1fr;
+		grid-auto-rows: 1fr;
+		gap: 0.85rem;
+	}
+
+	.places li {
+		display: flex;
+		min-height: 0;
+	}
+
+	.place {
+		display: flex;
+		flex-direction: column;
+		width: 100%;
+		overflow: hidden;
+		padding: 0;
+		color: inherit;
+		text-decoration: none;
+	}
+
+	.place:hover {
+		color: inherit;
+		text-decoration: none;
+	}
+
+	.place img {
+		display: block;
+		width: 100%;
+		aspect-ratio: 16 / 9;
+		object-fit: cover;
+		background: var(--bg);
+	}
+
+	.copy {
+		padding: 1.25rem 1.4rem 1.35rem;
+	}
+
+	.place h2 {
+		font-size: 1.125rem;
+		font-weight: 600;
+		letter-spacing: 0.02em;
+	}
+
+	.place p {
+		margin-top: 0.35rem;
+		color: var(--muted);
+		font-size: 0.9375rem;
+	}
+
+	.empty {
+		color: var(--muted);
+	}
+
+	@media (min-width: 800px) {
+		.places {
+			grid-template-columns: repeat(2, 1fr);
+		}
+	}
+</style>

+ 43 - 0
src/routes/currency/[location]/+page.server.js

@@ -0,0 +1,43 @@
+import { error } from '@sveltejs/kit';
+import { currencyImageSrc } from '#lib/currency.js';
+import { getDb } from '#lib/server/mongo.js';
+
+export const prerender = false;
+
+export async function load({ params }) {
+	const location = params.location?.trim();
+	if (!location) error(404, 'Location not found');
+
+	const db = await getDb();
+	const docs = await db
+		.collection('currencies')
+		.find({ location })
+		.project({
+			name: 1,
+			value: 1,
+			type: 1,
+			year: 1,
+			comment: 1,
+			frontImage: 1,
+			backImage: 1,
+			currencyCode: 1
+		})
+		.sort({ year: 1, name: 1 })
+		.toArray();
+
+	if (!docs.length) error(404, 'Location not found');
+
+	const notes = docs.map((doc) => ({
+		id: String(doc._id),
+		name: doc.name ?? '',
+		value: doc.value ?? '',
+		type: doc.type ?? '',
+		year: doc.year ?? '',
+		comment: doc.comment ?? '',
+		currencyCode: doc.currencyCode ?? '',
+		frontImage: currencyImageSrc(doc.frontImage),
+		backImage: currencyImageSrc(doc.backImage)
+	}));
+
+	return { location, notes };
+}

+ 119 - 0
src/routes/currency/[location]/+page.svelte

@@ -0,0 +1,119 @@
+<script>
+	let { data } = $props();
+</script>
+
+<svelte:head>
+	<title>{data.location} — Currency — Lee Morgan</title>
+	<meta name="description" content={`Currency from ${data.location}.`} />
+</svelte:head>
+
+<section class="notes-page">
+	<p class="back"><a href="/currency">Currency</a></p>
+	<h1>{data.location}</h1>
+	<ul class="notes">
+		{#each data.notes as note}
+			<li class="card note">
+				<div class="faces">
+					{#if note.frontImage}
+						<a href={`/currency/${encodeURIComponent(data.location)}/${note.id}/front`}>
+							<img src={note.frontImage} alt="Front of {note.name}" />
+						</a>
+					{/if}
+					{#if note.backImage}
+						<a href={`/currency/${encodeURIComponent(data.location)}/${note.id}/back`}>
+							<img src={note.backImage} alt="Back of {note.name}" />
+						</a>
+					{/if}
+				</div>
+				<div class="copy">
+					<h2>{note.name}{#if note.year} <span>{note.year}</span>{/if}</h2>
+					<p class="meta">
+						{#if note.currencyCode}{note.currencyCode}{/if}
+						{#if note.value} · {note.value}{/if}
+						{#if note.type} · {note.type}{/if}
+					</p>
+					{#if note.comment}
+						<p class="comment">{note.comment}</p>
+					{/if}
+				</div>
+			</li>
+		{/each}
+	</ul>
+</section>
+
+<style>
+	.notes-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;
+	}
+
+	.notes {
+		list-style: none;
+		margin: 0;
+		padding: 0;
+		display: flex;
+		flex-direction: column;
+		gap: 0.85rem;
+	}
+
+	.note {
+		padding: 0;
+		overflow: hidden;
+	}
+
+	.faces {
+		display: grid;
+		grid-template-columns: 1fr 1fr;
+		gap: 1px;
+		background: var(--border);
+	}
+
+	.faces a {
+		display: block;
+		color: inherit;
+	}
+
+	.faces img {
+		display: block;
+		width: 100%;
+		aspect-ratio: 16 / 9;
+		object-fit: cover;
+		background: var(--bg);
+	}
+
+	.copy {
+		padding: 1.15rem 1.4rem 1.3rem;
+	}
+
+	.note h2 {
+		font-size: 1.125rem;
+		font-weight: 600;
+		letter-spacing: 0.02em;
+	}
+
+	.note h2 span {
+		font-weight: 500;
+		color: var(--muted);
+	}
+
+	.meta {
+		margin-top: 0.3rem;
+		font-size: 0.875rem;
+		color: var(--muted);
+	}
+
+	.comment {
+		margin-top: 0.7rem;
+	}
+</style>

+ 31 - 0
src/routes/currency/[location]/[id]/+page.server.js

@@ -0,0 +1,31 @@
+import { error } from '@sveltejs/kit';
+import { ObjectId } from 'mongodb';
+import { currencyImageSrc } from '#lib/currency.js';
+import { getDb } from '#lib/server/mongo.js';
+
+export const prerender = false;
+
+export async function load({ params }) {
+	if (!ObjectId.isValid(params.id)) error(404, 'Currency not found');
+
+	const db = await getDb();
+	const doc = await db.collection('currencies').findOne({
+		_id: new ObjectId(params.id),
+		location: params.location
+	});
+
+	if (!doc) error(404, 'Currency not found');
+
+	return {
+		id: String(doc._id),
+		location: doc.location ?? params.location,
+		name: doc.name ?? '',
+		value: doc.value ?? '',
+		type: doc.type ?? '',
+		year: doc.year ?? '',
+		comment: doc.comment ?? '',
+		currencyCode: doc.currencyCode ?? '',
+		frontImage: currencyImageSrc(doc.frontImage),
+		backImage: currencyImageSrc(doc.backImage)
+	};
+}

+ 111 - 0
src/routes/currency/[location]/[id]/+page.svelte

@@ -0,0 +1,111 @@
+<script>
+	let { data } = $props();
+</script>
+
+<svelte:head>
+	<title>{data.name} — {data.location} — Lee Morgan</title>
+</svelte:head>
+
+<div class="viewer">
+	<a class="btn btn-ghost back" href={`/currency/${encodeURIComponent(data.location)}`}
+		>Back to {data.location}</a
+	>
+	<div class="content">
+		<div class="faces">
+			{#if data.frontImage}
+				<a href={`/currency/${encodeURIComponent(data.location)}/${data.id}/front`}>
+					<img src={data.frontImage} alt="Front of {data.name}" />
+				</a>
+			{/if}
+			{#if data.backImage}
+				<a href={`/currency/${encodeURIComponent(data.location)}/${data.id}/back`}>
+					<img src={data.backImage} alt="Back of {data.name}" />
+				</a>
+			{/if}
+		</div>
+		<h1>{data.name}{#if data.year} <span>{data.year}</span>{/if}</h1>
+		<p class="meta">
+			{#if data.currencyCode}{data.currencyCode}{/if}
+			{#if data.value} · {data.value}{/if}
+			{#if data.type} · {data.type}{/if}
+		</p>
+		{#if data.comment}
+			<p class="comment">{data.comment}</p>
+		{/if}
+	</div>
+</div>
+
+<style>
+	.viewer {
+		position: fixed;
+		inset: 0;
+		z-index: 30;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		padding: 4.5rem 1.5rem 1.5rem;
+		background: var(--bg);
+		overflow: auto;
+	}
+
+	.back {
+		position: absolute;
+		top: 1.25rem;
+		left: 1.25rem;
+		z-index: 1;
+	}
+
+	.content {
+		width: min(72rem, 100%);
+		margin: auto;
+	}
+
+	.faces {
+		display: grid;
+		grid-template-columns: 1fr;
+		gap: 0.85rem;
+	}
+
+	.faces a {
+		display: block;
+		color: inherit;
+	}
+
+	.faces img {
+		display: block;
+		width: 100%;
+		max-height: 70svh;
+		object-fit: contain;
+		background: var(--surface);
+		border: 1px solid var(--border);
+		border-radius: 10px;
+	}
+
+	h1 {
+		margin-top: 1.25rem;
+		font-size: 1.25rem;
+		font-weight: 600;
+		letter-spacing: 0.02em;
+	}
+
+	h1 span {
+		font-weight: 500;
+		color: var(--muted);
+	}
+
+	.meta {
+		margin-top: 0.35rem;
+		color: var(--muted);
+	}
+
+	.comment {
+		margin-top: 0.75rem;
+		max-width: 40rem;
+	}
+
+	@media (min-width: 800px) {
+		.faces {
+			grid-template-columns: 1fr 1fr;
+		}
+	}
+</style>

+ 31 - 0
src/routes/currency/[location]/[id]/[side]/+page.server.js

@@ -0,0 +1,31 @@
+import { error } from '@sveltejs/kit';
+import { ObjectId } from 'mongodb';
+import { currencyImageSrc } from '#lib/currency.js';
+import { getDb } from '#lib/server/mongo.js';
+
+export const prerender = false;
+
+export async function load({ params }) {
+	if (params.side !== 'front' && params.side !== 'back') error(404, 'Photo not found');
+	if (!ObjectId.isValid(params.id)) error(404, 'Currency not found');
+
+	const db = await getDb();
+	const doc = await db.collection('currencies').findOne({
+		_id: new ObjectId(params.id),
+		location: params.location
+	});
+
+	if (!doc) error(404, 'Currency not found');
+
+	const src =
+		params.side === 'front' ? currencyImageSrc(doc.frontImage) : currencyImageSrc(doc.backImage);
+	if (!src) error(404, 'Photo not found');
+
+	return {
+		id: String(doc._id),
+		location: doc.location ?? params.location,
+		name: doc.name ?? '',
+		side: params.side,
+		src
+	};
+}

+ 40 - 0
src/routes/currency/[location]/[id]/[side]/+page.svelte

@@ -0,0 +1,40 @@
+<script>
+	let { data } = $props();
+</script>
+
+<svelte:head>
+	<title>{data.side === 'front' ? 'Front' : 'Back'} — {data.name} — Lee Morgan</title>
+</svelte:head>
+
+<div class="viewer">
+	<a class="btn btn-ghost back" href={`/currency/${encodeURIComponent(data.location)}`}
+		>Close</a
+	>
+	<img src={data.src} alt="{data.side === 'front' ? 'Front' : 'Back'} of {data.name}" />
+</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/currencyimages/[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/currencyimages');
+
+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'
+		}
+	});
+}

+ 45 - 0
src/routes/gallery/+page.server.js

@@ -0,0 +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 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,
+		galleries
+	};
+}

+ 195 - 0
src/routes/gallery/+page.svelte

@@ -0,0 +1,195 @@
+<script>
+	import { goto } from '$app/navigation';
+	import { onMount } from 'svelte';
+	import 'mapbox-gl/dist/mapbox-gl.css';
+
+	let { data } = $props();
+
+	let mapEl = $state();
+	let webglError = $state(false);
+
+	function webgl2Available() {
+		const canvas = document.createElement('canvas');
+		canvas.width = 1;
+		canvas.height = 1;
+		return Boolean(canvas.getContext('webgl2'));
+	}
+
+	function escapeHtml(value) {
+		return String(value)
+			.replaceAll('&', '&amp;')
+			.replaceAll('<', '&lt;')
+			.replaceAll('>', '&gt;')
+			.replaceAll('"', '&quot;');
+	}
+
+	onMount(() => {
+		let map;
+		let observer;
+		let cancelled = false;
+
+		(async () => {
+			if (!data.mapboxKey || !mapEl) return;
+
+			const mod = await import('mapbox-gl');
+			const mapboxgl = mod.default?.Map ? mod.default : globalThis.mapboxgl;
+			const workerMod = await import('mapbox-gl/dist/mapbox-gl-csp-worker.js?worker');
+			if (cancelled || !mapEl || !mapboxgl?.Map) return;
+
+			if (!webgl2Available()) {
+				webglError = true;
+				return;
+			}
+
+			mapboxgl.workerClass = workerMod.default;
+			mapboxgl.accessToken = data.mapboxKey;
+
+			map = new mapboxgl.Map({
+				container: mapEl,
+				style: 'mapbox://styles/mapbox/dark-v11',
+				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();
+			requestAnimationFrame(resize);
+			observer = new ResizeObserver(resize);
+			observer.observe(mapEl);
+		})();
+
+		return () => {
+			cancelled = true;
+			observer?.disconnect();
+			map?.remove();
+		};
+	});
+</script>
+
+<svelte:head>
+	<title>Gallery — Lee Morgan</title>
+	<meta name="description" content="Photo galleries on a map." />
+</svelte:head>
+
+<div class="gallery">
+	<h1 class="sr-only">Gallery</h1>
+	{#if webglError}
+		<p class="missing">This browser could not start WebGL, which the map needs to draw.</p>
+	{:else if data.mapboxKey}
+		<div class="map" bind:this={mapEl}></div>
+	{:else}
+		<p class="missing">Set the MAPBOX_KEY environment variable to load the map.</p>
+	{/if}
+</div>
+
+<style>
+	.gallery {
+		position: absolute;
+		inset: 0;
+	}
+
+	.map {
+		position: absolute;
+		inset: 0;
+	}
+
+	.map :global(.mapboxgl-canvas-container),
+	.map :global(.mapboxgl-canvas) {
+		width: 100%;
+		height: 100%;
+	}
+
+	.map :global(.mapboxgl-ctrl-attrib) {
+		background: transparent;
+		color: var(--muted);
+	}
+
+	.map :global(.mapboxgl-ctrl-attrib a) {
+		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;
+		height: 1px;
+		padding: 0;
+		margin: -1px;
+		overflow: hidden;
+		clip: rect(0, 0, 0, 0);
+		white-space: nowrap;
+		border: 0;
+	}
+
+	.missing {
+		margin: 0;
+		padding: 2rem;
+		color: var(--muted);
+	}
+</style>

+ 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'
+		}
+	});
+}

+ 109 - 0
src/routes/projects/+page.svelte

@@ -0,0 +1,109 @@
+<script>
+	import { projects } from '#lib/projects.js';
+</script>
+
+<svelte:head>
+	<title>Projects — Lee Morgan</title>
+	<meta
+		name="description"
+		content="Selected projects by Lee Morgan, including web applications, tools, and experiments."
+	/>
+</svelte:head>
+
+<section class="projects-page">
+	<h1>Projects</h1>
+	<ul class="projects">
+		{#each projects as project}
+			<li>
+				<a
+					class="card project"
+					href={project.href}
+					rel="noopener noreferrer"
+					target="_blank"
+				>
+					<h2>
+						{project.name}
+						<svg class="icon" viewBox="0 0 12 12" aria-hidden="true">
+							<path d="M3.5 2h6.5v6.5M10 2 2 10" />
+						</svg>
+					</h2>
+					<p>{project.description}</p>
+				</a>
+			</li>
+		{/each}
+	</ul>
+</section>
+
+<style>
+	.projects-page {
+		max-width: 52rem;
+	}
+
+	h1 {
+		font-size: 1.25rem;
+		font-weight: 500;
+		letter-spacing: 0.02em;
+		margin-bottom: 1.5rem;
+	}
+
+	.projects {
+		list-style: none;
+		margin: 0;
+		padding: 0;
+		display: grid;
+		grid-template-columns: 1fr;
+		grid-auto-rows: 1fr;
+		gap: 0.85rem;
+	}
+
+	.projects li {
+		display: flex;
+		min-height: 0;
+	}
+
+	.project {
+		display: flex;
+		flex-direction: column;
+		gap: 0.45rem;
+		width: 100%;
+		height: 100%;
+		min-height: 11.5rem;
+		color: inherit;
+		text-decoration: none;
+	}
+
+	.project:hover {
+		color: inherit;
+		text-decoration: none;
+	}
+
+	.project h2 {
+		display: flex;
+		align-items: center;
+		gap: 0.4rem;
+		font-size: 1.125rem;
+		font-weight: 600;
+		letter-spacing: 0.02em;
+	}
+
+	.project p {
+		color: var(--muted);
+		font-size: 0.9375rem;
+	}
+
+	.icon {
+		width: 0.7rem;
+		height: 0.7rem;
+		stroke: var(--accent);
+		stroke-width: 1.5;
+		fill: none;
+		stroke-linecap: square;
+		flex-shrink: 0;
+	}
+
+	@media (min-width: 800px) {
+		.projects {
+			grid-template-columns: 1fr 1fr;
+		}
+	}
+</style>

+ 38 - 0
src/routes/resume/+page.svelte

@@ -0,0 +1,38 @@
+<svelte:head>
+	<title>Resume — Lee Morgan</title>
+	<meta name="description" content="Resume of Lee Morgan, web developer and systems administrator." />
+</svelte:head>
+
+<div class="resume">
+	<h1 class="sr-only">Resume</h1>
+	<iframe class="frame" src="/resume.pdf" title="Lee Morgan resume"></iframe>
+</div>
+
+<style>
+	.resume {
+		position: absolute;
+		inset: 0;
+		min-height: 100svh;
+	}
+
+	.frame {
+		display: block;
+		width: 100%;
+		height: 100%;
+		min-height: 100svh;
+		border: 0;
+		background: var(--bg);
+	}
+
+	.sr-only {
+		position: absolute;
+		width: 1px;
+		height: 1px;
+		padding: 0;
+		margin: -1px;
+		overflow: hidden;
+		clip: rect(0, 0, 0, 0);
+		white-space: nowrap;
+		border: 0;
+	}
+</style>

TEMPAT SAMPAH
static/resume.pdf


+ 8 - 7
vite.config.js

@@ -1,20 +1,21 @@
-import adapter from '@sveltejs/adapter-auto';
+import adapter from '@sveltejs/adapter-node';
 import { sveltekit } from '@sveltejs/kit/vite';
 import { defineConfig } from 'vite';
 
 export default defineConfig({
+	optimizeDeps: {
+		include: ['mapbox-gl']
+	},
 	plugins: [
 		sveltekit({
 			compilerOptions: {
-				// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
 				runes: ({ filename }) =>
 					filename.split(/[/\\]/).includes('node_modules') ? undefined : true
 			},
-
-			// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
-			// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
-			// See https://svelte.dev/docs/kit/adapters for more information about adapters.
-			adapter: adapter()
+			adapter: adapter({
+				out: 'build',
+				precompress: true
+			})
 		})
 	]
 });