diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b235786 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +IMMICH_API_URL= +IMMICH_API_KEY= +IMMICH_ALBUM_ID= diff --git a/.gitignore b/.gitignore index 5ef6a52..3d07a4d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,9 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) +# env files .env* +!.env.example # vercel .vercel diff --git a/README.md b/README.md index f3c5239..3d2c11a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,20 @@ Personal portfolio as a linear scroll gallery. Motion as medium. - **better-sqlite3** — Visitor counter persistence - **Biome** — Linting and formatting +## Environment + +Copy `.env.example` to `.env` and configure: + +| Variable | Description | +|----------|-------------| +| `IMMICH_API_URL` | Immich API base URL (e.g., `https://my.immich.app/api`) | +| `IMMICH_API_KEY` | API key with scopes: `album.read`, `asset.read`, `asset.download` | +| `IMMICH_ALBUM_ID` | UUID of the album to display in the photography section | + +## Server Requirements + +- [exiftool](https://exiftool.org/) (image metadata stripping) + ## Development ```bash diff --git a/bun.lock b/bun.lock index 46bbc07..a6ab021 100644 --- a/bun.lock +++ b/bun.lock @@ -8,10 +8,13 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "better-sqlite3": "^12.6.2", + "ipaddr.js": "^2.3.0", + "mime": "^4.1.0", "motion": "^12.34.2", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", + "swr": "^2.4.1", "three": "^0.183.0", }, "devDependencies": { @@ -244,6 +247,8 @@ "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-gpu": ["detect-gpu@5.0.70", "", { "dependencies": { "webgl-constants": "^1.1.1" } }, "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -280,6 +285,8 @@ "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], + "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -322,6 +329,8 @@ "meshoptimizer": ["meshoptimizer@1.0.1", "", {}, "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g=="], + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -400,6 +409,8 @@ "suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="], + "swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="], + "tailwindcss": ["tailwindcss@4.2.0", "", {}, "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], diff --git a/next.config.ts b/next.config.ts index 66e1566..6c6a37b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,8 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ reactCompiler: true, + poweredByHeader: false, }; export default nextConfig; diff --git a/package.json b/package.json index 1146edd..2220a49 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,13 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "better-sqlite3": "^12.6.2", + "ipaddr.js": "^2.3.0", + "mime": "^4.1.0", "motion": "^12.34.2", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", + "swr": "^2.4.1", "three": "^0.183.0" }, "devDependencies": { diff --git a/src/app/api/photos/route.ts b/src/app/api/photos/route.ts new file mode 100644 index 0000000..2863c5f --- /dev/null +++ b/src/app/api/photos/route.ts @@ -0,0 +1,115 @@ +import mime from "mime"; +import { type NextRequest, NextResponse } from "next/server"; +import type { PhotoItem } from "@/app/sections/photography/types"; +import { rateLimit } from "@/lib/rate-limit"; + +const IMMICH_API_URL = process.env.IMMICH_API_URL ?? ""; +const IMMICH_API_KEY = process.env.IMMICH_API_KEY ?? ""; +const IMMICH_ALBUM_ID = process.env.IMMICH_ALBUM_ID ?? ""; +const PAGE_SIZE = 48; + +type ImmichAsset = { + id: string; + width: number; + height: number; + originalMimeType: string; + exifInfo: { + rating: number | null; + fNumber: number | null; + exposureTime: string | null; + iso: number | null; + focalLength: number | null; + model: string | null; + lensModel: string | null; + }; +}; + +type ImmichAlbumResponse = { + assets: ImmichAsset[]; +}; + +function seededRandom(seed: string) { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + const char = seed.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + + return () => { + hash = Math.imul(hash ^ (hash >>> 15), hash | 1); + hash ^= hash + Math.imul(hash ^ (hash >>> 7), hash | 61); + return ((hash ^ (hash >>> 14)) >>> 0) / 4294967296; + }; +} + +function sortByRating(items: PhotoItem[], seed: string): PhotoItem[] { + const random = seededRandom(seed); + return [...items].sort((a, b) => { + const diff = b.rating - a.rating; + if (diff !== 0) return diff; + return random() - 0.5; + }); +} + +async function fetchAlbum(): Promise { + if (!IMMICH_API_URL || !IMMICH_API_KEY || !IMMICH_ALBUM_ID) { + console.error("[photos] Missing Immich configuration:", { + hasUrl: !!IMMICH_API_URL, + hasKey: !!IMMICH_API_KEY, + hasAlbumId: !!IMMICH_ALBUM_ID, + }); + return []; + } + + const response = await fetch(`${IMMICH_API_URL}/albums/${IMMICH_ALBUM_ID}`, { + headers: { + "x-api-key": IMMICH_API_KEY, + }, + next: { revalidate: 3600 }, + }); + + if (!response.ok) { + console.error("[photos] Immich API error:", response.status, response.statusText); + return []; + } + + const data = (await response.json()) as ImmichAlbumResponse; + + return data.assets.map((asset) => ({ + id: asset.id, + width: asset.width, + height: asset.height, + ext: mime.getExtension(asset.originalMimeType) ?? "jpg", + rating: asset.exifInfo.rating ?? 0, + exif: { + fNumber: asset.exifInfo.fNumber, + exposureTime: asset.exifInfo.exposureTime, + iso: asset.exifInfo.iso, + focalLength: asset.exifInfo.focalLength, + model: asset.exifInfo.model, + lensModel: asset.exifInfo.lensModel, + }, + })); +} + +export async function GET(request: NextRequest) { + const { allowed } = rateLimit(request, "photos", 60, 60_000); + if (!allowed) { + return new NextResponse("Too many requests", { status: 429 }); + } + + const { searchParams } = request.nextUrl; + const seed = searchParams.get("seed") ?? "default"; + const page = Number.parseInt(searchParams.get("page") ?? "1", 10); + + const allPhotos = await fetchAlbum(); + const sorted = sortByRating(allPhotos, seed); + + const startIndex = (page - 1) * PAGE_SIZE; + const endIndex = startIndex + PAGE_SIZE; + const photos = sorted.slice(startIndex, endIndex); + const hasMore = endIndex < sorted.length; + + return NextResponse.json({ photos, hasMore }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 31f0938..c79ab4a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,6 @@ import { About } from "@/app/sections/about"; import { Landing } from "@/app/sections/landing"; +import { Photography } from "@/app/sections/photography"; import { MagneticHoverShowcase } from "@/components/magnetic-hover-showcase"; import { ScrollProgressIndicator } from "@/components/scroll-progress-indicator"; @@ -54,30 +55,7 @@ export default function Home() { -
-
-
-

Photography

-

- A masonry gallery will be implemented here in Epic 6, featuring integration with - Immich for photo management. -

-
- -
- {Array.from({ length: 9 }, (_, i) => ( -
- Photo {i + 1} -
- ))} -
-
-
+