wow
This commit is contained in:
commit
6786cb6148
31 changed files with 2352 additions and 0 deletions
352
apps/frontend/src/app/page.tsx
Normal file
352
apps/frontend/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue