akiyama.mizuki.guru/apps/frontend/src/app/page.tsx

718 lines
23 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;
mediaType?: "image" | "video";
thumbnailUrl?: string;
author?: string;
tweet: {
url: string;
};
};
type TagItem = {
_id: string;
name: string;
usageCount: number;
};
type GalleryPhoto = {
src: string;
width: number;
height: number;
key: string;
alt: string;
author: string;
source: string;
mediaType?: "image" | "video";
thumbnailUrl?: 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 {
const response = await fetch(photo.src);
if (!response.ok) throw new Error("Failed to fetch image");
let blob = await response.blob();
// 대부분의 브라우저는 클립보드 복사 시 image/png만 지원하므로,
// 형식이 다를 경우 canvas를 이용해 PNG로 변환합니다.
if (blob.type !== "image/png") {
const img = new Image();
const url = URL.createObjectURL(blob);
img.src = url;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas context를 가져오지 못했습니다.");
ctx.drawImage(img, 0, 0);
const pngBlob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
URL.revokeObjectURL(url);
if (!pngBlob) throw new Error("PNG 변환에 실패했습니다.");
blob = pngBlob;
}
const clipboardItem = new ClipboardItem({
[blob.type]: blob
});
await navigator.clipboard.write([clipboardItem]);
// alert("이미지가 클립보드에 복사되었습니다!");
} catch (error) {
console.error("Failed to copy image:", error);
alert("이미지 복사에 실패했습니다. 브라우저 호환성 문제일 수 있습니다.");
}
}
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();
const srcToLoad = (upload.mediaType === "video" && upload.thumbnailUrl) ? upload.thumbnailUrl : upload.mediaUrl;
image.src = srcToLoad;
image.onload = () => {
resolve({
src: upload.mediaUrl,
width: image.naturalWidth,
height: image.naturalHeight,
key: upload._id,
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
author: upload.author?.trim() || "unknown",
source: upload.tweet?.url || "",
mediaType: upload.mediaType,
thumbnailUrl: upload.thumbnailUrl,
});
};
image.onerror = () => {
resolve({
src: upload.mediaUrl,
width: 1,
height: 1,
key: upload._id,
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
author: upload.author?.trim() || "unknown",
source: upload.tweet?.url || "",
mediaType: upload.mediaType,
thumbnailUrl: upload.thumbnailUrl,
});
};
}),
),
);
if (!cancelled) {
setPhotos(nextPhotos.filter((photo): photo is GalleryPhoto => photo !== null));
}
}
loadPhotoSizes();
return () => {
cancelled = true;
};
}, [items]);
return (
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
<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 mr-4">
<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") + " w-fit whitespace-nowrap"}
style={{ writingMode: "horizontal-tb" }}
>
{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-muted/50"
/>
))}
</div>
) : (
<div className="w-full">
<MasonryPhotoAlbum<GalleryPhoto>
photos={photos}
spacing={0}
columns={(containerWidth) => getColumnsForWidth(containerWidth)}
render={{
photo: ({ onClick }, { photo }) => (
<a
key={photo.key}
href={`/detail/${photo.key}`}
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 });
}}
className="group relative block overflow-hidden image-scale"
title={`작가 ${photo.author}`}
>
{photo.mediaType === "video" ? (
<video
src={photo.src}
poster={photo.thumbnailUrl}
autoPlay
muted
loop
playsInline
className="block w-full"
/>
) : (
<img
src={photo.src}
alt={photo.alt}
loading="lazy"
decoding="async"
className="block w-full"
/>
)}
<div className="absolute inset-x-3 bottom-3 translate-y-3 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
window.open(photo.source, "_blank", "noopener,noreferrer");
}}
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 pointer-events-auto"
title="원본 이미지 열기"
>
<span className="truncate">© {photo.author}</span>
</button>
</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-accent"
onClick={() => {
navigator.clipboard.writeText(contextMenu.photo.source).then(() => {
setContextMenu(null);
}).catch((err) => {
console.error("Failed to copy link:", err);
alert("링크 복사에 실패했습니다.");
});
}}
>
</button>
<div className="my-1 h-px bg-border" />
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-accent"
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-accent"
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-muted/50"
/>
))}
</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-accent 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 >
);
}