wow
This commit is contained in:
parent
b12ebb725d
commit
5207f5d431
25 changed files with 2932 additions and 332 deletions
|
|
@ -10,6 +10,7 @@ type Upload = {
|
|||
mediaIndex: number;
|
||||
mediaUrl: string;
|
||||
s3Key: string;
|
||||
author?: string;
|
||||
tweet: {
|
||||
url: string;
|
||||
};
|
||||
|
|
@ -28,9 +29,38 @@ type GalleryPhoto = {
|
|||
key: string;
|
||||
href: string;
|
||||
alt: string;
|
||||
author: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
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[]>([]);
|
||||
|
|
@ -43,12 +73,27 @@ export default function App() {
|
|||
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[]) {
|
||||
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);
|
||||
}
|
||||
|
|
@ -69,7 +114,7 @@ export default function App() {
|
|||
|
||||
async function loadTags() {
|
||||
try {
|
||||
const tagsResponse = await fetch("/api/tags", { cache: "no-store" });
|
||||
const tagsResponse = await fetch("/api/post/tags", { cache: "no-store" });
|
||||
if (!tagsResponse.ok) {
|
||||
throw new Error(`Failed to load tags: ${tagsResponse.status}`);
|
||||
}
|
||||
|
|
@ -92,6 +137,119 @@ export default function App() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
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;
|
||||
|
|
@ -102,8 +260,8 @@ export default function App() {
|
|||
|
||||
try {
|
||||
const nextPage = page + 1;
|
||||
const query = buildQuery(nextPage, selectedTags);
|
||||
const response = await fetch(`/api/list?${query}`, { cache: "no-store" });
|
||||
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}`);
|
||||
|
|
@ -120,7 +278,7 @@ export default function App() {
|
|||
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);
|
||||
setHasMore(merged.length < total && data.length >= listSize);
|
||||
return merged;
|
||||
});
|
||||
|
||||
|
|
@ -132,7 +290,11 @@ export default function App() {
|
|||
isFetchingMoreRef.current = false;
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [hasMore, loading, loadingMore, page, selectedTags, total]);
|
||||
}, [hasMore, listSize, loading, loadingMore, page, selectedTags, total]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMoreRef.current = loadMore;
|
||||
}, [loadMore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || loading) {
|
||||
|
|
@ -146,16 +308,30 @@ export default function App() {
|
|||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
void loadMore();
|
||||
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: "800px 0px" },
|
||||
{ rootMargin: "200px 0px" },
|
||||
);
|
||||
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loadMore, loading]);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
wasIntersectingRef.current = false;
|
||||
};
|
||||
}, [hasMore, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
|
@ -168,10 +344,10 @@ export default function App() {
|
|||
setHasMore(true);
|
||||
isFetchingMoreRef.current = false;
|
||||
setError(null);
|
||||
const query = buildQuery(1, selectedTags);
|
||||
const query = buildQuery(1, selectedTags, listSize);
|
||||
const [listResponse, totalResponse] = await Promise.all([
|
||||
fetch(`/api/list?${query}`, { cache: "no-store" }),
|
||||
fetch(`/api/total?${query}`, { cache: "no-store" }),
|
||||
fetch(`/api/post/list?${query}`, { cache: "no-store" }),
|
||||
fetch(`/api/post/total?${buildQuery(1, selectedTags)}`, { cache: "no-store" }),
|
||||
]);
|
||||
|
||||
if (!listResponse.ok) {
|
||||
|
|
@ -212,7 +388,7 @@ export default function App() {
|
|||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedTags]);
|
||||
}, [listSize, selectedTags]);
|
||||
|
||||
const items = useMemo(() => uploads.filter((upload) => Boolean(upload.mediaUrl)), [uploads]);
|
||||
|
||||
|
|
@ -239,6 +415,7 @@ export default function App() {
|
|||
key: upload._id,
|
||||
href: upload.tweet.url,
|
||||
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
|
||||
author: upload.author?.trim() || "unknown",
|
||||
});
|
||||
};
|
||||
image.onerror = () => {
|
||||
|
|
@ -249,6 +426,7 @@ export default function App() {
|
|||
key: upload._id,
|
||||
href: upload.mediaUrl,
|
||||
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
|
||||
author: upload.author?.trim() || "unknown",
|
||||
});
|
||||
};
|
||||
}),
|
||||
|
|
@ -312,41 +490,155 @@ export default function App() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<MasonryPhotoAlbum
|
||||
<MasonryPhotoAlbum<GalleryPhoto>
|
||||
photos={photos}
|
||||
spacing={0}
|
||||
columns={(containerWidth) => {
|
||||
if (containerWidth < 520) return 2;
|
||||
if (containerWidth < 900) return 3;
|
||||
if (containerWidth < 1280) return 4;
|
||||
return 5;
|
||||
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" },
|
||||
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"
|
||||
/>
|
||||
))}
|
||||
)
|
||||
}
|
||||
</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}
|
||||
</div>
|
||||
{!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 >
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue