build collections

This commit is contained in:
2026-03-28 01:34:27 -05:00
parent 2c128a404e
commit 8b502119f1
12 changed files with 704 additions and 114 deletions

View File

@@ -1,18 +1,20 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { Search, Upload as UploadIcon, X, Share2, Lock, LogOut } from 'lucide-react';
import { useMemes, useTags } from '../hooks/useMemes';
import { useMemes, useTags, useCollections } from '../hooks/useMemes';
import { useAuth, useLogout } from '../hooks/useAuth';
import { GalleryGrid } from '../components/GalleryGrid';
import { MemeDetail } from '../components/MemeDetail';
import { UploadModal } from '../components/UploadModal';
import { LoginModal } from '../components/LoginModal';
import { SharePanel } from '../components/SharePanel';
import { CollectionBar } from '../components/CollectionBar';
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 [activeTag, setActiveTag] = useState<string | null>(null);
const [activeCollectionId, setActiveCollectionId] = useState<number | null>(null);
const [selectedMemeId, setSelectedMemeId] = useState<string | null>(null);
const [quickShareMeme, setQuickShareMeme] = useState<Meme | null>(null);
const [showUpload, setShowUpload] = useState(false);
@@ -22,6 +24,20 @@ export function Gallery() {
const logout = useLogout();
const isAdmin = auth?.authenticated === true;
const { data: collections } = useCollections();
// Once collections load, default to the Unsorted (default) collection
const unsorted = collections?.find((c) => c.is_default);
useEffect(() => {
if (unsorted && activeCollectionId === null) {
setActiveCollectionId(unsorted.id);
}
}, [unsorted, activeCollectionId]);
// When on Unsorted, cap at 50 (recent uploads view); other folders show all (paginated)
const isUnsorted = activeCollectionId === unsorted?.id;
const limit = isUnsorted ? 50 : 100;
// Debounce search
const [searchTimer, setSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
function handleSearchChange(val: string) {
@@ -35,30 +51,33 @@ export function Gallery() {
tag: activeTag ?? undefined,
q: debouncedSearch || undefined,
parent_only: true,
collection_id: activeCollectionId ?? undefined,
limit,
});
const { data: tags } = useTags();
const handleOpen = useCallback((meme: Meme) => {
setSelectedMemeId(meme.id);
}, []);
const handleShare = useCallback((meme: Meme) => {
setQuickShareMeme(meme);
}, []);
const handleOpen = useCallback((meme: Meme) => setSelectedMemeId(meme.id), []);
const handleShare = useCallback((meme: Meme) => setQuickShareMeme(meme), []);
function handleUploadClick() {
if (isAdmin) {
setShowUpload(true);
} else {
setShowLogin(true);
}
if (isAdmin) setShowUpload(true);
else setShowLogin(true);
}
function handleCollectionSelect(id: number) {
setActiveCollectionId(id);
setActiveTag(null);
setSearch('');
setDebouncedSearch('');
}
const activeCollection = collections?.find((c) => c.id === activeCollectionId);
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">
<header className="sticky top-0 z-30 bg-zinc-950/90 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">
@@ -73,7 +92,7 @@ export function Gallery() {
type="text"
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search memes…"
placeholder={`Search${activeCollection ? ` in ${activeCollection.name}` : ''}`}
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 && (
@@ -86,19 +105,17 @@ export function Gallery() {
)}
</div>
{/* Right side actions */}
{/* Right side */}
<div className="ml-auto flex items-center gap-2 flex-shrink-0">
{/* Upload button — always visible, gates on auth */}
<button
onClick={handleUploadClick}
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"
title={isAdmin ? 'Upload meme' : 'Sign in to upload'}
>
{isAdmin ? <UploadIcon size={15} /> : <Lock size={15} />}
<span className="hidden sm:inline">{isAdmin ? 'Upload' : 'Upload'}</span>
<span className="hidden sm:inline">Upload</span>
</button>
{/* Auth state */}
{isAdmin ? (
<button
onClick={() => logout.mutate()}
@@ -122,7 +139,7 @@ export function Gallery() {
{/* 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">
<div className="max-w-screen-2xl mx-auto px-4 pb-2 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 ${
@@ -131,7 +148,7 @@ export function Gallery() {
: 'border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300'
}`}
>
All
All tags
</button>
{tags.map((tag) => (
<button
@@ -150,16 +167,43 @@ export function Gallery() {
)}
</header>
{/* Collection bar */}
{collections && collections.length > 0 && (
<div className="sticky top-[57px] z-20 bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800/40 px-4 py-3">
<div className="max-w-screen-2xl mx-auto">
<CollectionBar
collections={collections}
activeId={activeCollectionId}
onSelect={handleCollectionSelect}
isAdmin={isAdmin}
/>
</div>
</div>
)}
{/* Gallery */}
<main className="max-w-screen-2xl mx-auto px-4 py-6">
{/* Section heading */}
<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>
<p className="text-sm text-zinc-500">
{isLoading
? 'Loading…'
: isError
? 'Failed to load'
: (() => {
const count = data?.total ?? 0;
const showing = data?.memes.length ?? 0;
let label = `${count} meme${count !== 1 ? 's' : ''}`;
if (isUnsorted && count > 50 && !debouncedSearch && !activeTag) {
label = `Showing last 50 of ${count}`;
}
if (activeTag) label += ` tagged "${activeTag}"`;
if (debouncedSearch) label += ` matching "${debouncedSearch}"`;
return label;
})()}
</p>
</div>
</div>
{isLoading ? (
@@ -168,7 +212,7 @@ export function Gallery() {
<div
key={i}
className="break-inside-avoid mb-3 rounded-xl bg-zinc-900 animate-pulse"
style={{ height: `${120 + Math.random() * 200}px` }}
style={{ height: `${120 + (i * 37) % 200}px` }}
/>
))}
</div>
@@ -212,8 +256,13 @@ export function Gallery() {
</div>
)}
{/* Upload modal (admin only) */}
{showUpload && isAdmin && <UploadModal onClose={() => setShowUpload(false)} />}
{/* Upload modal */}
{showUpload && isAdmin && (
<UploadModal
onClose={() => setShowUpload(false)}
defaultCollectionId={activeCollectionId ?? unsorted?.id}
/>
)}
{/* Login modal */}
{showLogin && (