This commit is contained in:
@@ -31,9 +31,7 @@ jobs:
|
||||
run: |
|
||||
cat > .env << 'EOF'
|
||||
HOST_PORT=${{ vars.HOST_PORT }}
|
||||
IMMICH_API_URL=${{ vars.IMMICH_API_URL }}
|
||||
IMMICH_API_KEY=${{ secrets.IMMICH_API_KEY }}
|
||||
IMMICH_ALBUM_ID=${{ vars.IMMICH_ALBUM_ID }}
|
||||
RATE_MY_SHOTS_URL=${{ vars.RATE_MY_SHOTS_URL }}
|
||||
DATA_DIR=/opt/website/data
|
||||
EOF
|
||||
|
||||
|
||||
@@ -12,9 +12,6 @@ RUN bun run build
|
||||
|
||||
FROM node:24-slim AS runner
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libimage-exiftool-perl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
"@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",
|
||||
@@ -285,8 +283,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -329,8 +325,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
+1
-3
@@ -7,9 +7,7 @@ services:
|
||||
ports:
|
||||
- "${HOST_PORT}:3000"
|
||||
environment:
|
||||
IMMICH_API_URL: ${IMMICH_API_URL}
|
||||
IMMICH_API_KEY: ${IMMICH_API_KEY}
|
||||
IMMICH_ALBUM_ID: ${IMMICH_ALBUM_ID}
|
||||
RATE_MY_SHOTS_URL: ${RATE_MY_SHOTS_URL}
|
||||
DATA_DIR: /app/data
|
||||
volumes:
|
||||
- "${DATA_DIR:-./data}:/app/data"
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
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<PhotoItem[]> {
|
||||
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 });
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import mime from "mime";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const IMMICH_API_URL = process.env.IMMICH_API_URL ?? "";
|
||||
const IMMICH_API_KEY = process.env.IMMICH_API_KEY ?? "";
|
||||
|
||||
async function stripMetadata(input: Buffer, id: string): Promise<Buffer> {
|
||||
const dir = join(tmpdir(), "photo-proxy");
|
||||
await mkdir(dir, { recursive: true });
|
||||
const tempFile = join(dir, `${id}.tmp`);
|
||||
|
||||
try {
|
||||
await writeFile(tempFile, input);
|
||||
await execAsync(
|
||||
`exiftool -all= -tagsfromfile @ -Orientation -ICC_Profile -overwrite_original "${tempFile}"`,
|
||||
);
|
||||
return await readFile(tempFile);
|
||||
} finally {
|
||||
await rm(tempFile, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const { allowed } = rateLimit(request, "photo-asset", 120, 60_000);
|
||||
if (!allowed) {
|
||||
return new NextResponse("Too many requests", { status: 429 });
|
||||
}
|
||||
|
||||
const { path } = await params;
|
||||
const fullPath = path.join("/");
|
||||
|
||||
const match = fullPath.match(
|
||||
/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.(\w+)$/i,
|
||||
);
|
||||
if (!match) {
|
||||
return new NextResponse("Invalid path format", { status: 400 });
|
||||
}
|
||||
|
||||
const [, id, ext] = match;
|
||||
const contentType = mime.getType(ext) ?? "application/octet-stream";
|
||||
|
||||
if (!IMMICH_API_URL || !IMMICH_API_KEY) {
|
||||
console.warn("[photos] Missing Immich configuration for asset proxy");
|
||||
return new NextResponse("Server configuration error", { status: 500 });
|
||||
}
|
||||
|
||||
const immichUrl = `${IMMICH_API_URL}/assets/${id}/original`;
|
||||
|
||||
const response = await fetch(immichUrl, {
|
||||
headers: {
|
||||
"x-api-key": IMMICH_API_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return new NextResponse("Image not found", { status: response.status });
|
||||
}
|
||||
|
||||
const originalBuffer = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
let strippedBuffer: Buffer;
|
||||
try {
|
||||
strippedBuffer = await stripMetadata(originalBuffer, id);
|
||||
} catch (err) {
|
||||
console.warn("[photos] Metadata stripping failed:", err);
|
||||
return new NextResponse("Failed to process image", { status: 500 });
|
||||
}
|
||||
|
||||
const etag = `"${id}"`;
|
||||
|
||||
return new NextResponse(strippedBuffer as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
ETag: etag,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { PhotographyClient } from "./photography-client";
|
||||
|
||||
export const Photography = () => {
|
||||
const apiUrl = process.env.RATE_MY_SHOTS_URL;
|
||||
if (!apiUrl) throw new Error("Missing required env var: RATE_MY_SHOTS_URL");
|
||||
|
||||
return (
|
||||
<section id="photography" className="min-h-screen px-8 py-16 md:px-16 lg:px-24">
|
||||
<div className="mx-auto max-w-6xl space-y-16">
|
||||
@@ -12,7 +15,7 @@ export const Photography = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto mt-16 max-w-480">
|
||||
<PhotographyClient />
|
||||
<PhotographyClient apiUrl={apiUrl} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { m, useReducedMotion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
|
||||
import { springGentle } from "@/lib/motion";
|
||||
import type { PhotoItem } from "./types";
|
||||
|
||||
type PhotoTileProps = {
|
||||
photo: PhotoItem;
|
||||
apiUrl: string;
|
||||
};
|
||||
|
||||
const formatExposureTime = (s: number): string => {
|
||||
if (s >= 1) return `${s}s`;
|
||||
const denom = Math.round(1 / s);
|
||||
return `1/${denom}s`;
|
||||
};
|
||||
|
||||
const formatExifLine = (photo: PhotoItem): string | null => {
|
||||
@@ -16,23 +22,13 @@ const formatExifLine = (photo: PhotoItem): string | null => {
|
||||
|
||||
if (exif.focalLength) parts.push(`${exif.focalLength}mm`);
|
||||
if (exif.fNumber) parts.push(`ƒ/${exif.fNumber}`);
|
||||
if (exif.exposureTime) parts.push(`${exif.exposureTime}s`);
|
||||
if (exif.exposureTime) parts.push(formatExposureTime(exif.exposureTime));
|
||||
if (exif.iso) parts.push(`${exif.iso / 100}\u00A0hISO`);
|
||||
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
};
|
||||
|
||||
const formatGearLine = (photo: PhotoItem): string | null => {
|
||||
const { exif } = photo;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (exif.model) parts.push(exif.model);
|
||||
if (exif.lensModel) parts.push(exif.lensModel);
|
||||
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
};
|
||||
|
||||
export const PhotoTile = ({ photo }: PhotoTileProps) => {
|
||||
export const PhotoTile = ({ photo, apiUrl }: PhotoTileProps) => {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const hover = useMagneticSpringHover<HTMLDivElement>({
|
||||
magnetStrength: 0.15,
|
||||
@@ -41,8 +37,8 @@ export const PhotoTile = ({ photo }: PhotoTileProps) => {
|
||||
});
|
||||
|
||||
const exifLine = formatExifLine(photo);
|
||||
const gearLine = formatGearLine(photo);
|
||||
const hasOverlay = exifLine || gearLine;
|
||||
const lensName = photo.exif.lensDisplayName;
|
||||
const hasOverlay = exifLine || lensName;
|
||||
|
||||
return (
|
||||
<m.div
|
||||
@@ -56,24 +52,24 @@ export const PhotoTile = ({ photo }: PhotoTileProps) => {
|
||||
{...hover.handlers}
|
||||
>
|
||||
<a
|
||||
href={`/photos/${photo.id}.${photo.ext}`}
|
||||
href={`${apiUrl}/assets/${photo.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block cursor-pointer"
|
||||
>
|
||||
<Image
|
||||
src={`/photos/${photo.id}.${photo.ext}`}
|
||||
alt="Photograph without description (sorry)"
|
||||
{/* biome-ignore lint/performance/noImgElement: thumbnails are pre-optimized upstream */}
|
||||
<img
|
||||
src={`${apiUrl}/img/${photo.id}?size=preview`}
|
||||
alt=""
|
||||
width={photo.width}
|
||||
height={photo.height}
|
||||
sizes="(max-width: 639px) 100vw, (max-width: 767px) 50vw, (max-width: 1023px) 33vw, 480px"
|
||||
className="w-full"
|
||||
loading="lazy"
|
||||
className="w-full"
|
||||
/>
|
||||
{hasOverlay && (
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col justify-end bg-linear-to-t from-black/70 via-black/20 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
{exifLine && <p className="font-mono text-white/90 text-xs">{exifLine}</p>}
|
||||
{gearLine && <p className="font-mono text-white/60 text-xs">{gearLine}</p>}
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col justify-end gap-1 bg-linear-to-t from-black/70 via-black/20 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
{lensName && <p className="font-mono text-white/90 text-xs">{lensName}</p>}
|
||||
{exifLine && <p className="font-mono text-white/60 text-xs">{exifLine}</p>}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { m } from "motion/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
|
||||
import { PhotoTile } from "./photo-tile";
|
||||
import type { PhotoItem } from "./types";
|
||||
|
||||
type PhotographyClientProps = {
|
||||
apiUrl: string;
|
||||
};
|
||||
|
||||
const LG_BREAKPOINT = 1024;
|
||||
const GAP_DESKTOP = 16;
|
||||
const GAP_MOBILE = 8;
|
||||
const MAX_COLUMN_WIDTH = 400;
|
||||
const MAX_PAGES = 2;
|
||||
|
||||
const getGap = (width: number) => (width >= LG_BREAKPOINT ? GAP_DESKTOP : GAP_MOBILE);
|
||||
|
||||
const MIN_COLUMNS = 3;
|
||||
|
||||
function computeColumnCount(containerWidth: number, gap: number): number {
|
||||
if (containerWidth <= 0) return MIN_COLUMNS;
|
||||
return Math.max(MIN_COLUMNS, Math.ceil((containerWidth + gap) / (MAX_COLUMN_WIDTH + gap)));
|
||||
}
|
||||
|
||||
type PhotosResponse = {
|
||||
photos: PhotoItem[];
|
||||
hasMore: boolean;
|
||||
@@ -19,15 +40,6 @@ const fetcher = async (url: string): Promise<PhotosResponse> => {
|
||||
return res.json();
|
||||
};
|
||||
|
||||
const GAP = 16;
|
||||
|
||||
const getColumnCount = (width: number) => {
|
||||
if (width >= 1024) return 4;
|
||||
if (width >= 768) return 3;
|
||||
if (width >= 640) return 2;
|
||||
return 1;
|
||||
};
|
||||
|
||||
type Column = {
|
||||
photos: PhotoItem[];
|
||||
height: number;
|
||||
@@ -43,6 +55,7 @@ function assignPhotosToColumns(
|
||||
photos: PhotoItem[],
|
||||
columnCount: number,
|
||||
columnWidth: number,
|
||||
gap: number,
|
||||
): ColumnCache {
|
||||
const needsReset = !cache || cache.columns.length !== columnCount;
|
||||
|
||||
@@ -57,11 +70,11 @@ function assignPhotosToColumns(
|
||||
if (photo.width <= 0 || photo.height <= 0) continue;
|
||||
const shortest = columns.reduce((min, col) => (col.height < min.height ? col : min));
|
||||
shortest.photos.push(photo);
|
||||
shortest.height += (photo.height / photo.width) * columnWidth + GAP;
|
||||
shortest.height += (photo.height / photo.width) * columnWidth + gap;
|
||||
}
|
||||
}
|
||||
|
||||
return { columns, photoCount: photos.length };
|
||||
return { columns, photoCount: columnWidth > 0 ? photos.length : 0 };
|
||||
}
|
||||
|
||||
if (photos.length > cache.photoCount) {
|
||||
@@ -71,7 +84,7 @@ function assignPhotosToColumns(
|
||||
if (photo.width <= 0 || photo.height <= 0) continue;
|
||||
const shortest = columns.reduce((min, col) => (col.height < min.height ? col : min));
|
||||
shortest.photos.push(photo);
|
||||
shortest.height += (photo.height / photo.width) * columnWidth + GAP;
|
||||
shortest.height += (photo.height / photo.width) * columnWidth + gap;
|
||||
}
|
||||
|
||||
return { columns, photoCount: photos.length };
|
||||
@@ -80,42 +93,53 @@ function assignPhotosToColumns(
|
||||
return cache;
|
||||
}
|
||||
|
||||
export const PhotographyClient = () => {
|
||||
export const PhotographyClient = ({ apiUrl }: PhotographyClientProps) => {
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const [columnCount, setColumnCount] = useState(4);
|
||||
const [columnCount, setColumnCount] = useState(MIN_COLUMNS);
|
||||
const [columnWidth, setColumnWidth] = useState(0);
|
||||
const [gap, setGap] = useState(GAP_DESKTOP);
|
||||
const cacheRef = useRef<ColumnCache | null>(null);
|
||||
|
||||
const ctaHover = useMagneticSpringHover<HTMLAnchorElement>({
|
||||
magnetStrength: 0.2,
|
||||
scaleAmount: 1.04,
|
||||
shadowElevation: 16,
|
||||
});
|
||||
|
||||
const seed = useMemo(() => Math.random().toString(36).slice(2), []);
|
||||
|
||||
const { data, size, setSize, isValidating } = useSWRInfinite<PhotosResponse>(
|
||||
(pageIndex, previousPageData) => {
|
||||
if (previousPageData && !previousPageData.hasMore) return null;
|
||||
return `/api/photos?seed=${seed}&page=${pageIndex + 1}`;
|
||||
return `${apiUrl}/api/export?seed=${seed}&page=${pageIndex}`;
|
||||
},
|
||||
fetcher,
|
||||
{ revalidateOnFocus: false, revalidateOnReconnect: false },
|
||||
{ revalidateFirstPage: false, revalidateOnFocus: false, revalidateOnReconnect: false },
|
||||
);
|
||||
|
||||
const photos = data?.flatMap((page) => page.photos) ?? [];
|
||||
const hasMore = data?.[data.length - 1]?.hasMore ?? true;
|
||||
const upstreamHasMore = data?.[data.length - 1]?.hasMore ?? true;
|
||||
const reachedCap = (data?.length ?? 0) >= MAX_PAGES;
|
||||
const autoLoad = upstreamHasMore && !reachedCap;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const result = assignPhotosToColumns(cacheRef.current, photos, columnCount, columnWidth);
|
||||
const result = assignPhotosToColumns(cacheRef.current, photos, columnCount, columnWidth, gap);
|
||||
cacheRef.current = result;
|
||||
return result.columns;
|
||||
}, [photos, columnCount, columnWidth]);
|
||||
}, [photos, columnCount, columnWidth, gap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gridRef.current) return;
|
||||
|
||||
const measure = () => {
|
||||
if (!gridRef.current) return;
|
||||
const count = getColumnCount(window.innerWidth);
|
||||
setColumnCount(count);
|
||||
const gridWidth = gridRef.current.offsetWidth;
|
||||
const width = (gridWidth - (count - 1) * GAP) / count;
|
||||
setColumnWidth(width);
|
||||
const g = getGap(window.innerWidth);
|
||||
const count = computeColumnCount(gridWidth, g);
|
||||
setGap(g);
|
||||
setColumnCount(count);
|
||||
setColumnWidth((gridWidth - (count - 1) * g) / count);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
@@ -124,11 +148,11 @@ export const PhotographyClient = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadMoreRef.current) return;
|
||||
if (!loadMoreRef.current || !autoLoad) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !isValidating) {
|
||||
if (entries[0].isIntersecting && !isValidating) {
|
||||
setSize(size + 1);
|
||||
}
|
||||
},
|
||||
@@ -137,23 +161,39 @@ export const PhotographyClient = () => {
|
||||
|
||||
observer.observe(loadMoreRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, isValidating, size, setSize]);
|
||||
}, [autoLoad, isValidating, size, setSize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
|
||||
style={{ gap: GAP }}
|
||||
>
|
||||
{columns.map((column, colIdx) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: columns are positional by nature
|
||||
<div key={colIdx} className="flex flex-col" style={{ gap: GAP }}>
|
||||
{column.photos.map((photo) => (
|
||||
<PhotoTile key={photo.id} photo={photo} />
|
||||
))}
|
||||
<>
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid"
|
||||
style={{ gap, gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{columns.map((column, colIdx) => (
|
||||
<div key={`${colIdx}/${columnCount}`} className="flex flex-col" style={{ gap }}>
|
||||
{column.photos.map((photo) => (
|
||||
<PhotoTile key={photo.id} photo={photo} apiUrl={apiUrl} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{autoLoad && <div ref={loadMoreRef} className="col-span-full h-1" />}
|
||||
</div>
|
||||
{reachedCap && upstreamHasMore && (
|
||||
<div className="mt-8 flex justify-center">
|
||||
<m.a
|
||||
ref={ctaHover.ref}
|
||||
style={ctaHover.style}
|
||||
{...ctaHover.handlers}
|
||||
href={`${apiUrl}/gallery`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block rounded-lg border border-accent-cyan/40 px-6 py-3 font-body text-accent-cyan text-base transition-colors hover:border-accent-cyan/70 hover:bg-accent-cyan/10"
|
||||
>
|
||||
See the rest of the portfolio
|
||||
</m.a>
|
||||
</div>
|
||||
))}
|
||||
<div ref={loadMoreRef} className="col-span-full h-1" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,14 +2,12 @@ export type PhotoItem = {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
ext: string;
|
||||
rating: number;
|
||||
eloVerified: number;
|
||||
exif: {
|
||||
fNumber: number | null;
|
||||
exposureTime: string | null;
|
||||
iso: number | null;
|
||||
lensDisplayName: string | null;
|
||||
focalLength: number | null;
|
||||
model: string | null;
|
||||
lensModel: string | null;
|
||||
fNumber: number | null;
|
||||
exposureTime: number | null;
|
||||
iso: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import ipaddr from "ipaddr.js";
|
||||
|
||||
type RateLimitEntry = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
|
||||
const CLEANUP_INTERVAL = 60_000;
|
||||
let lastCleanup = Date.now();
|
||||
|
||||
function cleanup() {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL) return;
|
||||
lastCleanup = now;
|
||||
|
||||
for (const [key, entry] of store) {
|
||||
if (entry.resetAt < now) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateIp(ip: string): boolean {
|
||||
try {
|
||||
const addr = ipaddr.process(ip);
|
||||
const range = addr.range();
|
||||
return range === "private" || range === "loopback" || range === "linkLocal";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getClientIp(request: Request): string {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function rateLimit(
|
||||
request: Request,
|
||||
key: string,
|
||||
limit: number,
|
||||
windowMs: number,
|
||||
): { allowed: boolean; remaining: number } {
|
||||
const ip = getClientIp(request);
|
||||
|
||||
if (isPrivateIp(ip)) {
|
||||
return { allowed: true, remaining: limit };
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
const fullKey = `${key}:${ip}`;
|
||||
const now = Date.now();
|
||||
const entry = store.get(fullKey);
|
||||
|
||||
if (!entry || entry.resetAt < now) {
|
||||
store.set(fullKey, { count: 1, resetAt: now + windowMs });
|
||||
return { allowed: true, remaining: limit - 1 };
|
||||
}
|
||||
|
||||
if (entry.count >= limit) {
|
||||
return { allowed: false, remaining: 0 };
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return { allowed: true, remaining: limit - entry.count };
|
||||
}
|
||||
Reference in New Issue
Block a user