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

@@ -9,11 +9,20 @@ export interface Meme {
width: number;
height: number;
parent_id: string | null;
collection_id: number | null;
created_at: string;
tags: string[];
children?: Meme[];
}
export interface Collection {
id: number;
name: string;
is_default: number;
created_at: string;
meme_count: number;
}
export interface Tag {
id: number;
name: string;
@@ -33,6 +42,7 @@ export interface ListParams {
page?: number;
limit?: number;
parent_only?: boolean;
collection_id?: number;
}
async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
@@ -53,6 +63,7 @@ export const api = {
if (params.page) qs.set('page', String(params.page));
if (params.limit) qs.set('limit', String(params.limit));
if (params.parent_only !== undefined) qs.set('parent_only', String(params.parent_only));
if (params.collection_id !== undefined) qs.set('collection_id', String(params.collection_id));
return apiFetch<MemesResponse>(`/api/memes?${qs}`);
},
@@ -83,6 +94,40 @@ export const api = {
body: JSON.stringify(body),
});
},
move(id: string, collection_id: number): Promise<Meme> {
return apiFetch(`/api/memes/${id}/collection`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ collection_id }),
});
},
},
collections: {
list(): Promise<Collection[]> {
return apiFetch('/api/collections');
},
create(name: string): Promise<Collection> {
return apiFetch('/api/collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
},
rename(id: number, name: string): Promise<Collection> {
return apiFetch(`/api/collections/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
},
delete(id: number): Promise<{ ok: boolean }> {
return apiFetch(`/api/collections/${id}`, { method: 'DELETE' });
},
},
tags: {

View File

@@ -0,0 +1,101 @@
import { useState } from 'react';
import { FolderOpen, Inbox, FolderPlus, Pencil, Trash2 } from 'lucide-react';
import { useDeleteCollection } from '../hooks/useMemes';
import { CollectionModal } from './CollectionModal';
import type { Collection } from '../api/client';
interface Props {
collections: Collection[];
activeId: number | null;
onSelect: (id: number) => void;
isAdmin: boolean;
}
export function CollectionBar({ collections, activeId, onSelect, isAdmin }: Props) {
const [showCreate, setShowCreate] = useState(false);
const [renaming, setRenaming] = useState<Collection | null>(null);
const deleteCollection = useDeleteCollection();
async function handleDelete(col: Collection) {
if (!confirm(`Delete folder "${col.name}"? Its memes will be moved to Unsorted.`)) return;
// If the deleted folder is active, switch to Unsorted first
if (activeId === col.id) {
const unsorted = collections.find((c) => c.is_default);
if (unsorted) onSelect(unsorted.id);
}
await deleteCollection.mutateAsync(col.id);
}
return (
<>
<div className="flex gap-2 overflow-x-auto pb-0.5 scrollbar-none items-stretch">
{collections.map((col) => {
const isActive = activeId === col.id;
const isDefault = col.is_default === 1;
return (
<div key={col.id} className="relative group flex-shrink-0">
<button
onClick={() => onSelect(col.id)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-all border ${
isActive
? 'bg-accent/15 border-accent/50 text-purple-200'
: 'bg-zinc-900 border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200 hover:bg-zinc-800/80'
}`}
>
{isDefault ? (
<Inbox size={15} className={isActive ? 'text-accent' : 'text-zinc-500'} />
) : (
<FolderOpen size={15} className={isActive ? 'text-accent' : 'text-zinc-500'} />
)}
<span className="whitespace-nowrap">{col.name}</span>
<span className={`text-xs ${isActive ? 'text-purple-400' : 'text-zinc-600'}`}>
{col.meme_count}
</span>
</button>
{/* Admin controls — appear on hover for non-default collections */}
{isAdmin && !isDefault && (
<div className="absolute -top-2 -right-1 hidden group-hover:flex gap-0.5 z-10">
<button
onClick={(e) => { e.stopPropagation(); setRenaming(col); }}
className="p-1 rounded bg-zinc-800 border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700 transition-colors"
title="Rename folder"
>
<Pencil size={11} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(col); }}
className="p-1 rounded bg-zinc-800 border border-zinc-700 text-zinc-400 hover:text-red-400 hover:bg-zinc-700 transition-colors"
title="Delete folder"
>
<Trash2 size={11} />
</button>
</div>
)}
</div>
);
})}
{/* New folder button (admin only) */}
{isAdmin && (
<button
onClick={() => setShowCreate(true)}
className="flex-shrink-0 flex items-center gap-1.5 px-3 py-2.5 rounded-xl text-sm text-zinc-600 border border-dashed border-zinc-700 hover:border-zinc-500 hover:text-zinc-400 transition-colors"
title="New folder"
>
<FolderPlus size={15} />
<span className="whitespace-nowrap hidden sm:inline">New folder</span>
</button>
)}
</div>
{showCreate && (
<CollectionModal mode="create" onClose={() => setShowCreate(false)} />
)}
{renaming && (
<CollectionModal mode="rename" collection={renaming} onClose={() => setRenaming(null)} />
)}
</>
);
}

View File

@@ -0,0 +1,104 @@
import { useState, useEffect, useRef } from 'react';
import { X, FolderPlus, Pencil } from 'lucide-react';
import { useCreateCollection, useRenameCollection } from '../hooks/useMemes';
import type { Collection } from '../api/client';
interface CreateProps {
mode: 'create';
onClose: () => void;
}
interface RenameProps {
mode: 'rename';
collection: Collection;
onClose: () => void;
}
type Props = CreateProps | RenameProps;
export function CollectionModal(props: Props) {
const isRename = props.mode === 'rename';
const [name, setName] = useState(isRename ? (props as RenameProps).collection.name : '');
const inputRef = useRef<HTMLInputElement>(null);
const create = useCreateCollection();
const rename = useRenameCollection();
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const isPending = create.isPending || rename.isPending;
const error = create.error || rename.error;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
if (isRename) {
await rename.mutateAsync({ id: (props as RenameProps).collection.id, name: trimmed });
} else {
await create.mutateAsync(trimmed);
}
props.onClose();
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 animate-fade-in">
<div className="bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-sm border border-zinc-800 animate-scale-in">
<div className="flex items-center justify-between p-5 border-b border-zinc-800">
<div className="flex items-center gap-2">
{isRename ? (
<Pencil size={16} className="text-accent" />
) : (
<FolderPlus size={16} className="text-accent" />
)}
<h2 className="text-base font-semibold">
{isRename ? 'Rename Folder' : 'New Folder'}
</h2>
</div>
<button onClick={props.onClose} className="text-zinc-500 hover:text-zinc-300 transition-colors">
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div>
<label className="block text-xs text-zinc-500 mb-1">Folder name</label>
<input
ref={inputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Dank Memes"
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
maxLength={64}
/>
</div>
{error && (
<p className="text-red-400 text-sm">{(error as Error).message}</p>
)}
<div className="flex gap-3">
<button
type="button"
onClick={props.onClose}
className="flex-1 py-2 rounded-lg border border-zinc-700 text-sm text-zinc-400 hover:bg-zinc-800 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || isPending}
className="flex-1 py-2 rounded-lg bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending ? 'Saving…' : isRename ? 'Rename' : 'Create'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { X, Minimize2, Trash2, Edit2, Check, Layers } from 'lucide-react';
import { useMeme, useDeleteMeme, useUpdateMeme } from '../hooks/useMemes';
import { X, Minimize2, Trash2, Edit2, Check, Layers, FolderOpen, Inbox } from 'lucide-react';
import { useMeme, useDeleteMeme, useUpdateMeme, useMoveMeme, useCollections } from '../hooks/useMemes';
import { useAuth } from '../hooks/useAuth';
import { SharePanel } from './SharePanel';
import { RescaleModal } from './RescaleModal';
@@ -29,6 +29,8 @@ export function MemeDetail({ memeId, onClose }: Props) {
const updateMeme = useUpdateMeme();
const { data: auth } = useAuth();
const isAdmin = auth?.authenticated === true;
const moveMeme = useMoveMeme();
const { data: collections } = useCollections();
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
@@ -209,6 +211,39 @@ export function MemeDetail({ memeId, onClose }: Props) {
)}
</section>
{/* Folder */}
{collections && collections.length > 0 && (
<section>
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Folder</h3>
{isAdmin ? (
<div className="flex flex-wrap gap-1.5">
{collections.map((col) => {
const isActive = meme.collection_id === col.id;
return (
<button
key={col.id}
onClick={() => moveMeme.mutate({ id: meme.id, collection_id: col.id })}
disabled={isActive || moveMeme.isPending}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border transition-colors ${
isActive
? 'bg-accent/20 border-accent/50 text-purple-300 cursor-default'
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300 disabled:opacity-50'
}`}
>
{col.is_default ? <Inbox size={11} /> : <FolderOpen size={11} />}
{col.name}
</button>
);
})}
</div>
) : (
<p className="text-sm text-zinc-400">
{collections.find((c) => c.id === meme.collection_id)?.name ?? 'Unsorted'}
</p>
)}
</section>
)}
{/* Metadata */}
<section>
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Info</h3>

View File

@@ -1,21 +1,28 @@
import { useState, useRef, useCallback } from 'react';
import { X, Upload, ImagePlus } from 'lucide-react';
import { useUploadMeme } from '../hooks/useMemes';
import { X, Upload, ImagePlus, Inbox, FolderOpen } from 'lucide-react';
import { useUploadMeme, useCollections } from '../hooks/useMemes';
interface Props {
onClose: () => void;
defaultCollectionId?: number;
}
const ALLOWED = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
export function UploadModal({ onClose }: Props) {
export function UploadModal({ onClose, defaultCollectionId }: Props) {
const [files, setFiles] = useState<File[]>([]);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [tags, setTags] = useState('');
const [dragging, setDragging] = useState(false);
const [collectionId, setCollectionId] = useState<number | null>(defaultCollectionId ?? null);
const inputRef = useRef<HTMLInputElement>(null);
const upload = useUploadMeme();
const { data: collections } = useCollections();
// Use the unsorted (default) collection if none selected yet
const unsorted = collections?.find((c) => c.is_default);
const effectiveCollectionId = collectionId ?? unsorted?.id;
const addFiles = useCallback((incoming: FileList | File[]) => {
const valid = Array.from(incoming).filter((f) => ALLOWED.includes(f.type));
@@ -42,6 +49,7 @@ export function UploadModal({ onClose }: Props) {
fd.append('title', title || file.name.replace(/\.[^.]+$/, ''));
if (description) fd.append('description', description);
if (tags) fd.append('tags', tags);
if (effectiveCollectionId != null) fd.append('collection_id', String(effectiveCollectionId));
await upload.mutateAsync(fd);
}
@@ -109,6 +117,33 @@ export function UploadModal({ onClose }: Props) {
</div>
)}
{/* Folder selector */}
{collections && collections.length > 0 && (
<div>
<label className="block text-xs text-zinc-500 mb-1">Upload to folder</label>
<div className="flex flex-wrap gap-1.5">
{collections.map((col) => {
const isSelected = (collectionId ?? unsorted?.id) === col.id;
return (
<button
key={col.id}
type="button"
onClick={() => setCollectionId(col.id)}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border transition-colors ${
isSelected
? 'bg-accent/20 border-accent/50 text-purple-300'
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300'
}`}
>
{col.is_default ? <Inbox size={12} /> : <FolderOpen size={12} />}
{col.name}
</button>
);
})}
</div>
</div>
)}
{/* Metadata */}
<div>
<label className="block text-xs text-zinc-500 mb-1">

View File

@@ -1,6 +1,54 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, type ListParams } from '../api/client';
export function useCollections() {
return useQuery({
queryKey: ['collections'],
queryFn: () => api.collections.list(),
staleTime: 30_000,
});
}
export function useCreateCollection() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) => api.collections.create(name),
onSuccess: () => qc.invalidateQueries({ queryKey: ['collections'] }),
});
}
export function useRenameCollection() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) => api.collections.rename(id, name),
onSuccess: () => qc.invalidateQueries({ queryKey: ['collections'] }),
});
}
export function useDeleteCollection() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.collections.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['collections'] });
qc.invalidateQueries({ queryKey: ['memes'] });
},
});
}
export function useMoveMeme() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, collection_id }: { id: string; collection_id: number }) =>
api.memes.move(id, collection_id),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: ['memes'] });
qc.invalidateQueries({ queryKey: ['meme', vars.id] });
qc.invalidateQueries({ queryKey: ['collections'] });
},
});
}
export function useMemes(params: ListParams = {}) {
return useQuery({
queryKey: ['memes', params],

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 && (