This commit is contained in:
암냥 2026-04-15 00:56:08 +09:00
commit 6786cb6148
No known key found for this signature in database
31 changed files with 2352 additions and 0 deletions

View file

@ -0,0 +1,352 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MasonryPhotoAlbum } from "react-photo-album";
import Header from "../components/header";
type Upload = {
_id: string;
tweetId: string;
mediaIndex: number;
mediaUrl: string;
s3Key: string;
tweet: {
url: string;
};
};
type TagItem = {
_id: string;
name: string;
usageCount: number;
};
type GalleryPhoto = {
src: string;
width: number;
height: number;
key: string;
href: string;
alt: string;
};
const PAGE_SIZE = 20;
export default function App() {
const [uploads, setUploads] = useState<Upload[]>([]);
const [tags, setTags] = useState<TagItem[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [total, setTotal] = useState<number>(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [photos, setPhotos] = useState<GalleryPhoto[]>([]);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const isFetchingMoreRef = useRef(false);
function buildQuery(page: number, queryTags: string[]) {
const params = new URLSearchParams();
params.set("page", String(page));
for (const tag of queryTags) {
params.append("tags", tag);
}
return params.toString();
}
function toggleTag(tagName: string) {
setSelectedTags((current) => {
if (current.includes(tagName)) {
return current.filter((tag) => tag !== tagName);
}
return [...current, tagName];
});
}
useEffect(() => {
let active = true;
async function loadTags() {
try {
const tagsResponse = await fetch("/api/tags", { cache: "no-store" });
if (!tagsResponse.ok) {
throw new Error(`Failed to load tags: ${tagsResponse.status}`);
}
const tagsData = (await tagsResponse.json()) as TagItem[];
if (active) {
setTags(tagsData);
}
} catch (err) {
if (active) {
setError(err instanceof Error ? err.message : "Failed to load tags");
}
}
}
loadTags();
return () => {
active = false;
};
}, []);
const loadMore = useCallback(async () => {
if (isFetchingMoreRef.current || loading || loadingMore || !hasMore) {
return;
}
isFetchingMoreRef.current = true;
setLoadingMore(true);
try {
const nextPage = page + 1;
const query = buildQuery(nextPage, selectedTags);
const response = await fetch(`/api/list?${query}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to load more gallery: ${response.status}`);
}
const data = (await response.json()) as Upload[];
if (data.length === 0) {
setHasMore(false);
return;
}
setUploads((current) => {
const existingIds = new Set(current.map((item) => item._id));
const appended = data.filter((item) => !existingIds.has(item._id));
const merged = [...current, ...appended];
setHasMore(merged.length < total && data.length >= PAGE_SIZE);
return merged;
});
setPage(nextPage);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load more gallery");
setHasMore(false);
} finally {
isFetchingMoreRef.current = false;
setLoadingMore(false);
}
}, [hasMore, loading, loadingMore, page, selectedTags, total]);
useEffect(() => {
if (!hasMore || loading) {
return;
}
const target = sentinelRef.current;
if (!target) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
void loadMore();
}
},
{ rootMargin: "800px 0px" },
);
observer.observe(target);
return () => observer.disconnect();
}, [hasMore, loadMore, loading]);
useEffect(() => {
let active = true;
async function loadUploads() {
try {
setLoading(true);
setLoadingMore(false);
setPage(1);
setHasMore(true);
isFetchingMoreRef.current = false;
setError(null);
const query = buildQuery(1, selectedTags);
const [listResponse, totalResponse] = await Promise.all([
fetch(`/api/list?${query}`, { cache: "no-store" }),
fetch(`/api/total?${query}`, { cache: "no-store" }),
]);
if (!listResponse.ok) {
throw new Error(`Failed to load gallery: ${listResponse.status}`);
}
if (!totalResponse.ok) {
throw new Error(`Failed to load total: ${totalResponse.status}`);
}
const [data, totalData] = await Promise.all([
listResponse.json() as Promise<Upload[]>,
totalResponse.json() as Promise<number | { total?: number; count?: number }>,
]);
const resolvedTotal = typeof totalData === "number"
? totalData
: (totalData.total ?? totalData.count ?? 0);
if (active) {
setUploads(data);
setTotal(resolvedTotal);
setHasMore(data.length > 0 && data.length < resolvedTotal);
}
} catch (err) {
if (active) {
setError(err instanceof Error ? err.message : "Failed to load gallery");
}
} finally {
if (active) {
setLoading(false);
}
}
}
loadUploads();
return () => {
active = false;
};
}, [selectedTags]);
const items = useMemo(() => uploads.filter((upload) => Boolean(upload.mediaUrl)), [uploads]);
useEffect(() => {
let cancelled = false;
async function loadPhotoSizes() {
if (items.length === 0) {
setPhotos([]);
return;
}
const nextPhotos = await Promise.all(
items.map(
(upload) =>
new Promise<GalleryPhoto | null>((resolve) => {
const image = new Image();
image.src = upload.mediaUrl;
image.onload = () => {
resolve({
src: upload.mediaUrl,
width: image.naturalWidth,
height: image.naturalHeight,
key: upload._id,
href: upload.tweet.url,
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
});
};
image.onerror = () => {
resolve({
src: upload.mediaUrl,
width: 1,
height: 1,
key: upload._id,
href: upload.mediaUrl,
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
});
};
}),
),
);
if (!cancelled) {
setPhotos(nextPhotos.filter((photo): photo is GalleryPhoto => photo !== null));
}
}
loadPhotoSizes();
return () => {
cancelled = true;
};
}, [items]);
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
<div className="sticky top-0 z-50">
<Header />
<div className="border-b border-border bg-background/85 backdrop-blur px-6 py-2">
<div className="flex w-full items-center justify-between overflow-x-auto text-sm text-foreground/70">
<div id="filter" className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setSelectedTags([])}
className={selectedTags.length === 0 ? "text-foreground" : "text-foreground/50"}
>
all
</button>
{tags.map((tag) => (
<button
key={tag._id}
type="button"
onClick={() => toggleTag(tag.name)}
className={selectedTags.includes(tag.name) ? "text-foreground" : "text-foreground/50"}
>
{tag.name}
</button>
))}
</div>
<span className="shrink-0 text-xs uppercase tracking-[0.3em] text-foreground/40">
{total} items
</span>
</div>
</div>
</div>
<main className="w-full">
{error ? <p className="text-red-500">{error}</p> : null}
{loading ? (
<div className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 12 }).map((_, index) => (
<div
key={index}
className="aspect-4/5 animate-pulse bg-black/8"
/>
))}
</div>
) : (
<div className="w-full">
<MasonryPhotoAlbum
photos={photos}
spacing={0}
columns={(containerWidth) => {
if (containerWidth < 520) return 2;
if (containerWidth < 900) return 3;
if (containerWidth < 1280) return 4;
return 5;
}}
componentsProps={{
container: { className: "!w-full" },
image: {
loading: "lazy",
decoding: "async",
className: "block w-full",
},
link: {
className: "block overflow-hidden image-scale",
},
}}
/>
</div>
)}
</main>
{!loading && hasMore ? <div ref={sentinelRef} className="h-1 w-full" /> : null}
{loadingMore ? (
<div className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={`loading-more-${index}`}
className="aspect-4/5 animate-pulse bg-black/8"
/>
))}
</div>
) : null}
</div>
);
}