build 1
This commit is contained in:
182
frontend/src/pages/Gallery.tsx
Normal file
182
frontend/src/pages/Gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user