"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; author?: 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; author: string; }; type Me = { role: "admin" | "writer" | "reader"; }; type ContextMenuState = { x: number; y: number; photo: GalleryPhoto; }; const DEFAULT_LIST_SIZE = 8; const EXTRA_PREFETCH_ROWS = 2; function getColumnsForWidth(containerWidth: number) { if (containerWidth < 520) return 2; if (containerWidth < 900) return 3; if (containerWidth < 1280) return 4; return 5; } function calculateListSize(viewportWidth: number, viewportHeight: number) { const columns = getColumnsForWidth(viewportWidth); const columnWidth = viewportWidth / columns; // grid item uses aspect-4/5, so height is width * (5 / 4) const itemHeight = columnWidth * (5 / 4); const rowsInViewport = Math.ceil(viewportHeight / itemHeight); const size = columns * (rowsInViewport + EXTRA_PREFETCH_ROWS); return Math.max(DEFAULT_LIST_SIZE, size); } export default function App() { const [uploads, setUploads] = useState([]); const [tags, setTags] = useState([]); const [selectedTags, setSelectedTags] = useState([]); const [total, setTotal] = useState(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(null); const [photos, setPhotos] = useState([]); const [viewerRole, setViewerRole] = useState("guest"); const [contextMenu, setContextMenu] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [listSize, setListSize] = useState(() => { if (typeof window === "undefined") { return DEFAULT_LIST_SIZE; } return calculateListSize(window.innerWidth, window.innerHeight); }); const sentinelRef = useRef(null); const isFetchingMoreRef = useRef(false); const loadMoreRef = useRef<() => Promise>(async () => { }); const wasIntersectingRef = useRef(false); function buildQuery(page: number, queryTags: string[], size?: number) { const params = new URLSearchParams(); params.set("page", String(page)); if (typeof size === "number") { params.set("size", String(size)); } 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/post/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; }; }, []); useEffect(() => { let active = true; async function loadMe() { try { const response = await fetch("/api/auth/me", { cache: "no-store" }); if (!response.ok) { if (active) setViewerRole("guest"); return; } const me = (await response.json()) as Me; if (active) { setViewerRole(me.role); } } catch { if (active) { setViewerRole("guest"); } } } void loadMe(); return () => { active = false; }; }, []); useEffect(() => { function closeContextMenu() { setContextMenu(null); } function closeOnEscape(event: KeyboardEvent) { if (event.key === "Escape") { setContextMenu(null); if (!isDeleting) { setDeleteTarget(null); } } } document.addEventListener("click", closeContextMenu); document.addEventListener("scroll", closeContextMenu, true); document.addEventListener("keydown", closeOnEscape); return () => { document.removeEventListener("click", closeContextMenu); document.removeEventListener("scroll", closeContextMenu, true); document.removeEventListener("keydown", closeOnEscape); }; }, [isDeleting]); const canManagePost = viewerRole === "admin" || viewerRole === "writer"; async function deletePhoto(photo: GalleryPhoto) { try { setIsDeleting(true); const response = await fetch(`/api/post/delete/${photo.key}`, { method: "DELETE", }); if (!response.ok) { throw new Error(`Failed to delete post: ${response.status}`); } setUploads((current) => current.filter((upload) => upload._id !== photo.key)); setTotal((current) => Math.max(0, current - 1)); setDeleteTarget(null); } catch (err) { alert(err instanceof Error ? err.message : "Failed to delete post"); } finally { setIsDeleting(false); } } async function copyImage(photo: GalleryPhoto) { try { if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) { const response = await fetch(photo.src, { cache: "no-store" }); const blob = await response.blob(); await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]); } else if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(photo.src); } } catch { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(photo.src); } } } function saveImage(photo: GalleryPhoto) { const link = document.createElement("a"); link.href = photo.src; link.download = `${photo.key}.jpg`; link.target = "_blank"; link.rel = "noopener noreferrer"; document.body.appendChild(link); link.click(); link.remove(); } useEffect(() => { function updateListSize() { const nextSize = calculateListSize(window.innerWidth, window.innerHeight); setListSize((current) => (current === nextSize ? current : nextSize)); } updateListSize(); window.addEventListener("resize", updateListSize); return () => window.removeEventListener("resize", updateListSize); }, []); 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, listSize); const response = await fetch(`/api/post/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 >= listSize); 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, listSize, loading, loadingMore, page, selectedTags, total]); useEffect(() => { loadMoreRef.current = loadMore; }, [loadMore]); useEffect(() => { if (!hasMore || loading) { return; } const target = sentinelRef.current; if (!target) { return; } const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; if (!entry) { return; } if (entry.isIntersecting && !wasIntersectingRef.current) { wasIntersectingRef.current = true; void loadMoreRef.current(); return; } if (!entry.isIntersecting) { wasIntersectingRef.current = false; } }, { rootMargin: "200px 0px" }, ); observer.observe(target); return () => { observer.disconnect(); wasIntersectingRef.current = false; }; }, [hasMore, 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, listSize); const [listResponse, totalResponse] = await Promise.all([ fetch(`/api/post/list?${query}`, { cache: "no-store" }), fetch(`/api/post/total?${buildQuery(1, selectedTags)}`, { 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, totalResponse.json() as Promise, ]); 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; }; }, [listSize, 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((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}`, author: upload.author?.trim() || "unknown", }); }; image.onerror = () => { resolve({ src: upload.mediaUrl, width: 1, height: 1, key: upload._id, href: upload.mediaUrl, alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`, author: upload.author?.trim() || "unknown", }); }; }), ), ); if (!cancelled) { setPhotos(nextPhotos.filter((photo): photo is GalleryPhoto => photo !== null)); } } loadPhotoSizes(); return () => { cancelled = true; }; }, [items]); return (
{tags.map((tag) => ( ))}
{total} items
{error ?

{error}

: null} {loading ? (
{contextMenu ? (
event.stopPropagation()} > {canManagePost ? ( <>
) : null}
) : null} {!loading && hasMore ?
: null} { loadingMore ? (
{Array.from({ length: 5 }).map((_, index) => (
))}
) : null } {deleteTarget ? (
{ if (!isDeleting) { setDeleteTarget(null); } }} >
event.stopPropagation()} >

게시물을 삭제할까요?

삭제 후에는 되돌릴 수 없습니다.

) : null}
); }