This commit is contained in:
2026-03-28 01:06:30 -05:00
parent 796c374d38
commit ecb708790d
35 changed files with 2347 additions and 37 deletions

View File

@@ -0,0 +1,182 @@
import { useState, useCallback } from 'react';
import { Search, Upload as UploadIcon, X, Share2 } from 'lucide-react';
import { useMemes, useTags } from '../hooks/useMemes';
import { GalleryGrid } from '../components/GalleryGrid';
import { MemeDetail } from '../components/MemeDetail';
import { UploadModal } from '../components/UploadModal';
import { SharePanel } from '../components/SharePanel';
import type { Meme } from '../api/client';
export function Gallery() {
const [activeTag, setActiveTag] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [selectedMemeId, setSelectedMemeId] = useState<string | null>(null);
const [quickShareMeme, setQuickShareMeme] = useState<Meme | null>(null);
const [showUpload, setShowUpload] = useState(false);
// Debounce search
const [searchTimer, setSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
function handleSearchChange(val: string) {
setSearch(val);
if (searchTimer) clearTimeout(searchTimer);
const t = setTimeout(() => setDebouncedSearch(val), 300);
setSearchTimer(t);
}
const { data, isLoading, isError } = useMemes({
tag: activeTag ?? undefined,
q: debouncedSearch || undefined,
parent_only: true,
});
const { data: tags } = useTags();
const handleOpen = useCallback((meme: Meme) => {
setSelectedMemeId(meme.id);
}, []);
const handleShare = useCallback((meme: Meme) => {
setQuickShareMeme(meme);
}, []);
return (
<div className="min-h-screen bg-zinc-950">
{/* Topbar */}
<header className="sticky top-0 z-30 bg-zinc-950/80 backdrop-blur-md border-b border-zinc-800/60">
<div className="max-w-screen-2xl mx-auto px-4 py-3 flex items-center gap-3">
{/* Logo */}
<div className="flex items-center gap-2 mr-2 flex-shrink-0">
<span className="text-2xl">🎭</span>
<span className="font-bold text-lg tracking-tight hidden sm:block">Memer</span>
</div>
{/* Search */}
<div className="flex-1 relative max-w-md">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 pointer-events-none" />
<input
type="text"
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search memes…"
className="w-full bg-zinc-900 border border-zinc-700 rounded-lg pl-8 pr-3 py-1.5 text-sm focus:outline-none focus:border-accent placeholder-zinc-600"
/>
{search && (
<button
onClick={() => { setSearch(''); setDebouncedSearch(''); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
>
<X size={14} />
</button>
)}
</div>
<div className="ml-auto flex-shrink-0">
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors"
>
<UploadIcon size={15} />
<span className="hidden sm:inline">Upload</span>
</button>
</div>
</div>
{/* Tag filter strip */}
{tags && tags.length > 0 && (
<div className="max-w-screen-2xl mx-auto px-4 pb-2.5 flex gap-2 overflow-x-auto scrollbar-none">
<button
onClick={() => setActiveTag(null)}
className={`text-xs px-2.5 py-1 rounded-full border whitespace-nowrap transition-colors flex-shrink-0 ${
activeTag === null
? 'bg-accent text-white border-accent'
: 'border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300'
}`}
>
All
</button>
{tags.map((tag) => (
<button
key={tag.id}
onClick={() => setActiveTag(activeTag === tag.name ? null : tag.name)}
className={`text-xs px-2.5 py-1 rounded-full border whitespace-nowrap transition-colors flex-shrink-0 ${
activeTag === tag.name
? 'bg-accent text-white border-accent'
: 'border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300'
}`}
>
{tag.name} <span className="opacity-50">({tag.meme_count})</span>
</button>
))}
</div>
)}
</header>
{/* Gallery */}
<main className="max-w-screen-2xl mx-auto px-4 py-6">
{/* Status bar */}
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-zinc-500">
{isLoading
? 'Loading…'
: isError
? 'Failed to load'
: `${data?.total ?? 0} meme${data?.total !== 1 ? 's' : ''}${activeTag ? ` tagged "${activeTag}"` : ''}${debouncedSearch ? ` matching "${debouncedSearch}"` : ''}`}
</p>
</div>
{isLoading ? (
<div className="columns-1 sm:columns-2 lg:columns-3 xl:columns-4 gap-3">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
className="break-inside-avoid mb-3 rounded-xl bg-zinc-900 animate-pulse"
style={{ height: `${120 + Math.random() * 200}px` }}
/>
))}
</div>
) : isError ? (
<div className="text-center py-32 text-red-400">Failed to load memes.</div>
) : (
<GalleryGrid
memes={data?.memes ?? []}
onOpen={handleOpen}
onShare={handleShare}
/>
)}
</main>
{/* Meme detail modal */}
{selectedMemeId && (
<MemeDetail
memeId={selectedMemeId}
onClose={() => setSelectedMemeId(null)}
/>
)}
{/* Quick share popover */}
{quickShareMeme && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/60 animate-fade-in">
<div className="bg-zinc-900 rounded-2xl border border-zinc-800 p-5 w-full max-w-sm shadow-2xl animate-scale-in">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Share2 size={16} className="text-accent" />
<span className="font-semibold text-sm truncate">{quickShareMeme.title}</span>
</div>
<button
onClick={() => setQuickShareMeme(null)}
className="text-zinc-500 hover:text-zinc-300 transition-colors"
>
<X size={18} />
</button>
</div>
<SharePanel meme={quickShareMeme} />
</div>
</div>
)}
{/* Upload modal */}
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
</div>
);
}