644 lines
20 KiB
TypeScript
644 lines
20 KiB
TypeScript
"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<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 [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest");
|
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<GalleryPhoto | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const [listSize, setListSize] = useState<number>(() => {
|
|
if (typeof window === "undefined") {
|
|
return DEFAULT_LIST_SIZE;
|
|
}
|
|
return calculateListSize(window.innerWidth, window.innerHeight);
|
|
});
|
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
const isFetchingMoreRef = useRef(false);
|
|
const loadMoreRef = useRef<() => Promise<void>>(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<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;
|
|
};
|
|
}, [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<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}`,
|
|
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 (
|
|
<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-nowrap items-center gap-2 h-6 overflow-x-auto overflow-y-hidden">
|
|
<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<GalleryPhoto>
|
|
photos={photos}
|
|
spacing={0}
|
|
columns={(containerWidth) => getColumnsForWidth(containerWidth)}
|
|
render={{
|
|
photo: ({ onClick }, { photo }) => (
|
|
<a
|
|
key={photo.key}
|
|
href={photo.href}
|
|
onClick={onClick}
|
|
onContextMenu={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const safeX = Math.min(event.clientX, window.innerWidth - 200);
|
|
const safeY = Math.min(event.clientY, window.innerHeight - 220);
|
|
setContextMenu({ x: Math.max(12, safeX), y: Math.max(12, safeY), photo });
|
|
}}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="group relative block overflow-hidden image-scale"
|
|
title={`작가 ${photo.author}`}
|
|
>
|
|
<img
|
|
src={photo.src}
|
|
alt={photo.alt}
|
|
loading="lazy"
|
|
decoding="async"
|
|
className="block w-full"
|
|
/>
|
|
<div className="pointer-events-none absolute inset-x-3 bottom-3 translate-y-3 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
|
|
<div className="inline-flex max-w-full items-center rounded-t-sm rounded-b-lg bg-black/72 px-3 py-1.5 text-xs text-white backdrop-blur-sm">
|
|
<span className="truncate">© {photo.author}</span>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
),
|
|
}}
|
|
componentsProps={{
|
|
container: { className: "!w-full" },
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
</main >
|
|
{contextMenu ? (
|
|
<div
|
|
className="fixed z-100 min-w-44 overflow-hidden rounded-lg border border-border bg-background/95 p-1 shadow-xl backdrop-blur"
|
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
{canManagePost ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-black/5"
|
|
onClick={() => {
|
|
window.location.href = `/edit/${contextMenu.photo.key}`;
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
수정
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="block w-full rounded px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50"
|
|
onClick={() => {
|
|
setDeleteTarget(contextMenu.photo);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
삭제
|
|
</button>
|
|
<div className="my-1 h-px bg-border" />
|
|
</>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-black/5"
|
|
onClick={async () => {
|
|
await copyImage(contextMenu.photo);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
이미지 복사
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-black/5"
|
|
onClick={() => {
|
|
saveImage(contextMenu.photo);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
이미지 저장
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{!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
|
|
}
|
|
{deleteTarget ? (
|
|
<div
|
|
className="fixed inset-0 z-120 flex items-center justify-center bg-black/35 px-4"
|
|
onClick={() => {
|
|
if (!isDeleting) {
|
|
setDeleteTarget(null);
|
|
}
|
|
}}
|
|
>
|
|
<div
|
|
className="w-full max-w-sm rounded-xl border border-border bg-background p-5 shadow-2xl"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<p className="text-base font-semibold text-foreground">게시물을 삭제할까요?</p>
|
|
<p className="mt-2 text-sm text-foreground/70">삭제 후에는 되돌릴 수 없습니다.</p>
|
|
<div className="mt-5 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
className="rounded px-4 py-2 text-sm text-foreground/70 hover:bg-black/5 disabled:opacity-50"
|
|
onClick={() => setDeleteTarget(null)}
|
|
disabled={isDeleting}
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
|
onClick={() => {
|
|
void deletePhoto(deleteTarget);
|
|
}}
|
|
disabled={isDeleting}
|
|
>
|
|
{isDeleting ? "삭제 중..." : "삭제"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div >
|
|
);
|
|
}
|