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

@@ -10,11 +10,18 @@ fs.mkdirSync(DB_DIR, { recursive: true });
const db = new Database(DB_PATH); const db = new Database(DB_PATH);
// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
// Core tables
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS memes ( CREATE TABLE IF NOT EXISTS memes (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
@@ -26,6 +33,7 @@ db.exec(`
width INTEGER NOT NULL, width INTEGER NOT NULL,
height INTEGER NOT NULL, height INTEGER NOT NULL,
parent_id TEXT REFERENCES memes(id) ON DELETE CASCADE, parent_id TEXT REFERENCES memes(id) ON DELETE CASCADE,
collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
@@ -42,8 +50,34 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_memes_parent_id ON memes(parent_id); CREATE INDEX IF NOT EXISTS idx_memes_parent_id ON memes(parent_id);
CREATE INDEX IF NOT EXISTS idx_memes_created_at ON memes(created_at DESC); CREATE INDEX IF NOT EXISTS idx_memes_created_at ON memes(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_memes_collection_id ON memes(collection_id);
CREATE INDEX IF NOT EXISTS idx_meme_tags_meme_id ON meme_tags(meme_id); CREATE INDEX IF NOT EXISTS idx_meme_tags_meme_id ON meme_tags(meme_id);
CREATE INDEX IF NOT EXISTS idx_meme_tags_tag_id ON meme_tags(tag_id); CREATE INDEX IF NOT EXISTS idx_meme_tags_tag_id ON meme_tags(tag_id);
`); `);
// Migration: add collection_id column if upgrading from earlier schema
const memesCols = db.prepare('PRAGMA table_info(memes)').all() as { name: string }[];
if (!memesCols.find((c) => c.name === 'collection_id')) {
db.exec('ALTER TABLE memes ADD COLUMN collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL');
db.exec('CREATE INDEX IF NOT EXISTS idx_memes_collection_id ON memes(collection_id)');
}
// Seed the default UNSORTED collection
const defaultCollection = db
.prepare('SELECT id FROM collections WHERE is_default = 1')
.get() as { id: number } | undefined;
if (!defaultCollection) {
db.prepare("INSERT INTO collections (name, is_default) VALUES ('Unsorted', 1)").run();
}
const unsorted = db
.prepare('SELECT id FROM collections WHERE is_default = 1')
.get() as { id: number };
// Assign any existing memes with no collection to UNSORTED
db.prepare('UPDATE memes SET collection_id = ? WHERE collection_id IS NULL').run(unsorted.id);
export const UNSORTED_ID = unsorted.id;
export default db; export default db;

View File

@@ -7,6 +7,7 @@ import { ensureImagesDir, IMAGES_DIR } from './services/storage.js';
import { memesRoutes } from './routes/memes.js'; import { memesRoutes } from './routes/memes.js';
import { tagsRoutes } from './routes/tags.js'; import { tagsRoutes } from './routes/tags.js';
import { authRoutes } from './routes/auth.js'; import { authRoutes } from './routes/auth.js';
import { collectionsRoutes } from './routes/collections.js';
// Ensure data dirs exist // Ensure data dirs exist
ensureImagesDir(); ensureImagesDir();
@@ -37,6 +38,7 @@ await app.register(fastifyStatic, {
// API routes // API routes
await app.register(authRoutes); await app.register(authRoutes);
await app.register(collectionsRoutes);
await app.register(memesRoutes); await app.register(memesRoutes);
await app.register(tagsRoutes); await app.register(tagsRoutes);

View File

@@ -0,0 +1,97 @@
import type { FastifyInstance } from 'fastify';
import db from '../db.js';
import { requireAuth } from '../auth.js';
import type { Collection } from '../types.js';
export async function collectionsRoutes(app: FastifyInstance) {
// List all collections with meme counts
app.get('/api/collections', async () => {
return db
.prepare(
`SELECT c.id, c.name, c.is_default, c.created_at,
COUNT(m.id) as meme_count
FROM collections c
LEFT JOIN memes m ON m.collection_id = c.id AND m.parent_id IS NULL
GROUP BY c.id
ORDER BY c.is_default DESC, c.name ASC`
)
.all() as Collection[];
});
// Create collection
app.post<{ Body: { name: string } }>(
'/api/collections',
{ preHandler: requireAuth },
async (req, reply) => {
const name = req.body?.name?.trim();
if (!name) return reply.status(400).send({ error: 'Name is required' });
try {
const result = db
.prepare('INSERT INTO collections (name) VALUES (?)')
.run(name);
return reply.status(201).send(
db
.prepare(
`SELECT c.id, c.name, c.is_default, c.created_at, 0 as meme_count
FROM collections c WHERE c.id = ?`
)
.get(result.lastInsertRowid)
);
} catch {
return reply.status(409).send({ error: 'A folder with that name already exists' });
}
}
);
// Rename collection
app.put<{ Params: { id: string }; Body: { name: string } }>(
'/api/collections/:id',
{ preHandler: requireAuth },
async (req, reply) => {
const id = Number(req.params.id);
const name = req.body?.name?.trim();
if (!name) return reply.status(400).send({ error: 'Name is required' });
const col = db.prepare('SELECT * FROM collections WHERE id = ?').get(id) as Collection | undefined;
if (!col) return reply.status(404).send({ error: 'Folder not found' });
if (col.is_default) return reply.status(400).send({ error: 'Cannot rename the Unsorted folder' });
try {
db.prepare('UPDATE collections SET name = ? WHERE id = ?').run(name, id);
return db
.prepare(
`SELECT c.id, c.name, c.is_default, c.created_at,
COUNT(m.id) as meme_count
FROM collections c
LEFT JOIN memes m ON m.collection_id = c.id AND m.parent_id IS NULL
WHERE c.id = ?
GROUP BY c.id`
)
.get(id);
} catch {
return reply.status(409).send({ error: 'A folder with that name already exists' });
}
}
);
// Delete collection — moves its memes to Unsorted first
app.delete<{ Params: { id: string } }>(
'/api/collections/:id',
{ preHandler: requireAuth },
async (req, reply) => {
const id = Number(req.params.id);
const col = db.prepare('SELECT * FROM collections WHERE id = ?').get(id) as Collection | undefined;
if (!col) return reply.status(404).send({ error: 'Folder not found' });
if (col.is_default) return reply.status(400).send({ error: 'Cannot delete the Unsorted folder' });
const unsorted = db
.prepare('SELECT id FROM collections WHERE is_default = 1')
.get() as { id: number };
db.prepare('UPDATE memes SET collection_id = ? WHERE collection_id = ?').run(unsorted.id, id);
db.prepare('DELETE FROM collections WHERE id = ?').run(id);
return { ok: true };
}
);
}

View File

@@ -1,11 +1,11 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { MultipartFile } from '@fastify/multipart'; import type { MultipartFile } from '@fastify/multipart';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import db from '../db.js'; import db, { UNSORTED_ID } from '../db.js';
import { buildFilePath, deleteFile, getExtension } from '../services/storage.js'; import { buildFilePath, deleteFile, getExtension } from '../services/storage.js';
import { extractMeta, resizeImage, saveBuffer } from '../services/image.js'; import { extractMeta, resizeImage, saveBuffer } from '../services/image.js';
import { requireAuth } from '../auth.js'; import { requireAuth } from '../auth.js';
import type { ListQuery, UpdateBody, RescaleBody, Meme } from '../types.js'; import type { ListQuery, UpdateBody, RescaleBody, MoveBody, Meme } from '../types.js';
const ALLOWED_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); const ALLOWED_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
@@ -40,23 +40,17 @@ function setMemeTags(memeId: string, tagNames: string[]): void {
const res = db.prepare('INSERT INTO tags (name) VALUES (?)').run(trimmed); const res = db.prepare('INSERT INTO tags (name) VALUES (?)').run(trimmed);
tag = { id: Number(res.lastInsertRowid) }; tag = { id: Number(res.lastInsertRowid) };
} }
db.prepare('INSERT OR IGNORE INTO meme_tags (meme_id, tag_id) VALUES (?, ?)').run( db.prepare('INSERT OR IGNORE INTO meme_tags (meme_id, tag_id) VALUES (?, ?)').run(memeId, tag.id);
memeId,
tag.id
);
} }
} }
export async function memesRoutes(app: FastifyInstance) { export async function memesRoutes(app: FastifyInstance) {
// List memes // List memes
app.get<{ Querystring: ListQuery }>('/api/memes', async (req) => { app.get<{ Querystring: ListQuery }>('/api/memes', async (req) => {
const { tag, q, page = 1, limit = 50, parent_only = 'true' } = req.query; const { tag, q, page = 1, limit = 50, parent_only = 'true', collection_id } = req.query;
const offset = (Number(page) - 1) * Number(limit); const offset = (Number(page) - 1) * Number(limit);
let sql = ` let sql = `SELECT DISTINCT m.* FROM memes m`;
SELECT DISTINCT m.*
FROM memes m
`;
const params: (string | number)[] = []; const params: (string | number)[] = [];
const conditions: string[] = []; const conditions: string[] = [];
@@ -64,16 +58,21 @@ export async function memesRoutes(app: FastifyInstance) {
conditions.push('m.parent_id IS NULL'); conditions.push('m.parent_id IS NULL');
} }
if (collection_id !== undefined) {
conditions.push('m.collection_id = ?');
params.push(Number(collection_id));
}
if (tag) { if (tag) {
sql += ` sql += `
JOIN meme_tags mt_filter ON mt_filter.meme_id = m.id JOIN meme_tags mt_filter ON mt_filter.meme_id = m.id
JOIN tags t_filter ON t_filter.id = mt_filter.tag_id AND t_filter.name = ? JOIN tags t_filter ON t_filter.id = mt_filter.tag_id AND t_filter.name = ?
`; `;
params.push(tag.toLowerCase()); params.unshift(tag.toLowerCase());
} }
if (q) { if (q) {
conditions.push(`(m.title LIKE ? OR m.description LIKE ?)`); conditions.push('(m.title LIKE ? OR m.description LIKE ?)');
params.push(`%${q}%`, `%${q}%`); params.push(`%${q}%`, `%${q}%`);
} }
@@ -85,17 +84,25 @@ export async function memesRoutes(app: FastifyInstance) {
params.push(Number(limit), offset); params.push(Number(limit), offset);
const memes = db.prepare(sql).all(...params) as Meme[]; const memes = db.prepare(sql).all(...params) as Meme[];
const total = (
db // Count query — rebuild without ORDER/LIMIT
.prepare( let countSql = `SELECT COUNT(DISTINCT m.id) as count FROM memes m`;
`SELECT COUNT(DISTINCT m.id) as count FROM memes m const countParams: (string | number)[] = [];
${tag ? `JOIN meme_tags mt_filter ON mt_filter.meme_id = m.id JOIN tags t_filter ON t_filter.id = mt_filter.tag_id AND t_filter.name = ?` : ''} const countConditions = [...conditions];
${conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''}`
) if (tag) {
.get(...(tag ? [tag.toLowerCase(), ...params.slice(1, -2)] : params.slice(0, -2))) as { countSql += `
count: number; JOIN meme_tags mt_filter ON mt_filter.meme_id = m.id
JOIN tags t_filter ON t_filter.id = mt_filter.tag_id AND t_filter.name = ?
`;
countParams.push(tag.toLowerCase());
} }
).count; if (collection_id !== undefined) countParams.push(Number(collection_id));
if (q) countParams.push(`%${q}%`, `%${q}%`);
if (countConditions.length) countSql += ' WHERE ' + countConditions.join(' AND ');
const { count: total } = db.prepare(countSql).get(...countParams) as { count: number };
return { return {
memes: memes.map((m) => ({ ...m, tags: getMemeTags(m.id) })), memes: memes.map((m) => ({ ...m, tags: getMemeTags(m.id) })),
@@ -135,62 +142,81 @@ export async function memesRoutes(app: FastifyInstance) {
const filePath = buildFilePath(id, ext); const filePath = buildFilePath(id, ext);
await saveBuffer(buffer, filePath); await saveBuffer(buffer, filePath);
const meta = await extractMeta(filePath); const meta = await extractMeta(filePath);
const fields = file.fields as Record<string, { value: string }>; const fields = file.fields as Record<string, { value: string }>;
const title = fields.title?.value ?? file.filename ?? 'Untitled'; const title = fields.title?.value ?? file.filename ?? 'Untitled';
const description = fields.description?.value ?? null; const description = fields.description?.value ?? null;
const tagsRaw = fields.tags?.value ?? ''; const tagsRaw = fields.tags?.value ?? '';
const collectionId = fields.collection_id?.value
? Number(fields.collection_id.value)
: UNSORTED_ID;
db.prepare( db.prepare(
`INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height) `INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height, collection_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(id, title, description, filePath, file.filename, meta.size, meta.mimeType, meta.width, meta.height); ).run(id, title, description, filePath, file.filename, meta.size, meta.mimeType, meta.width, meta.height, collectionId);
if (tagsRaw) { if (tagsRaw) setMemeTags(id, tagsRaw.split(','));
setMemeTags(id, tagsRaw.split(','));
}
return reply.status(201).send(getMemeById(id)); return reply.status(201).send(getMemeById(id));
}); });
// Update meme metadata // Update meme metadata
app.put<{ Params: { id: string }; Body: UpdateBody }>('/api/memes/:id', { preHandler: requireAuth }, async (req, reply) => { app.put<{ Params: { id: string }; Body: UpdateBody }>(
'/api/memes/:id',
{ preHandler: requireAuth },
async (req, reply) => {
const meme = getMemeById(req.params.id); const meme = getMemeById(req.params.id);
if (!meme) return reply.status(404).send({ error: 'Not found' }); if (!meme) return reply.status(404).send({ error: 'Not found' });
const { title, description, tags } = req.body; const { title, description, tags } = req.body;
db.prepare('UPDATE memes SET title = ?, description = ? WHERE id = ?').run(
title ?? meme.title,
description ?? meme.description,
meme.id
);
db.prepare( if (tags !== undefined) setMemeTags(meme.id, tags);
`UPDATE memes SET title = ?, description = ? WHERE id = ?`
).run(title ?? meme.title, description ?? meme.description, meme.id);
if (tags !== undefined) {
setMemeTags(meme.id, tags);
}
return getMemeById(meme.id); return getMemeById(meme.id);
}); }
);
// Delete meme (children cascade) // Move meme to a different collection
app.delete<{ Params: { id: string } }>('/api/memes/:id', { preHandler: requireAuth }, async (req, reply) => { app.put<{ Params: { id: string }; Body: MoveBody }>(
'/api/memes/:id/collection',
{ preHandler: requireAuth },
async (req, reply) => {
const meme = getMemeById(req.params.id);
if (!meme) return reply.status(404).send({ error: 'Not found' });
const { collection_id } = req.body;
const col = db.prepare('SELECT id FROM collections WHERE id = ?').get(collection_id);
if (!col) return reply.status(404).send({ error: 'Folder not found' });
db.prepare('UPDATE memes SET collection_id = ? WHERE id = ?').run(collection_id, meme.id);
return getMemeById(meme.id);
}
);
// Delete meme (children cascade)
app.delete<{ Params: { id: string } }>(
'/api/memes/:id',
{ preHandler: requireAuth },
async (req, reply) => {
const meme = getMemeById(req.params.id); const meme = getMemeById(req.params.id);
if (!meme) return reply.status(404).send({ error: 'Not found' }); if (!meme) return reply.status(404).send({ error: 'Not found' });
// Delete child files first
const children = db const children = db
.prepare('SELECT file_path FROM memes WHERE parent_id = ?') .prepare('SELECT file_path FROM memes WHERE parent_id = ?')
.all(meme.id) as { file_path: string }[]; .all(meme.id) as { file_path: string }[];
for (const child of children) { for (const child of children) deleteFile(child.file_path);
deleteFile(child.file_path);
}
deleteFile(meme.file_path); deleteFile(meme.file_path);
db.prepare('DELETE FROM memes WHERE id = ?').run(meme.id); db.prepare('DELETE FROM memes WHERE id = ?').run(meme.id);
return { ok: true }; return { ok: true };
}); }
);
// Non-destructive rescale // Non-destructive rescale
app.post<{ Params: { id: string }; Body: RescaleBody }>( app.post<{ Params: { id: string }; Body: RescaleBody }>(
@@ -204,9 +230,7 @@ export async function memesRoutes(app: FastifyInstance) {
} }
const { width, height, quality = 85, label } = req.body; const { width, height, quality = 85, label } = req.body;
if (!width && !height) { if (!width && !height) return reply.status(400).send({ error: 'width or height is required' });
return reply.status(400).send({ error: 'width or height is required' });
}
const childId = uuidv4(); const childId = uuidv4();
const ext = getExtension(parent.mime_type); const ext = getExtension(parent.mime_type);
@@ -216,8 +240,8 @@ export async function memesRoutes(app: FastifyInstance) {
const meta = await resizeImage(parent.file_path, childPath, { width, height, quality }); const meta = await resizeImage(parent.file_path, childPath, { width, height, quality });
db.prepare( db.prepare(
`INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height, parent_id) `INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height, parent_id, collection_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run( ).run(
childId, childId,
`${parent.title} (${autoLabel})`, `${parent.title} (${autoLabel})`,
@@ -228,7 +252,8 @@ export async function memesRoutes(app: FastifyInstance) {
meta.mimeType, meta.mimeType,
meta.width, meta.width,
meta.height, meta.height,
parent.id parent.id,
parent.collection_id
); );
return reply.status(201).send(getMemeById(childId)); return reply.status(201).send(getMemeById(childId));

View File

@@ -9,10 +9,19 @@ export interface Meme {
width: number; width: number;
height: number; height: number;
parent_id: string | null; parent_id: string | null;
collection_id: number | null;
created_at: string; created_at: string;
tags: string[]; tags: string[];
} }
export interface Collection {
id: number;
name: string;
is_default: number;
created_at: string;
meme_count: number;
}
export interface Tag { export interface Tag {
id: number; id: number;
name: string; name: string;
@@ -23,6 +32,7 @@ export interface UploadBody {
title?: string; title?: string;
description?: string; description?: string;
tags?: string; tags?: string;
collection_id?: string;
} }
export interface UpdateBody { export interface UpdateBody {
@@ -31,6 +41,10 @@ export interface UpdateBody {
tags?: string[]; tags?: string[];
} }
export interface MoveBody {
collection_id: number;
}
export interface RescaleBody { export interface RescaleBody {
width?: number; width?: number;
height?: number; height?: number;
@@ -44,4 +58,5 @@ export interface ListQuery {
page?: number; page?: number;
limit?: number; limit?: number;
parent_only?: string; parent_only?: string;
collection_id?: string;
} }

View File

@@ -9,11 +9,20 @@ export interface Meme {
width: number; width: number;
height: number; height: number;
parent_id: string | null; parent_id: string | null;
collection_id: number | null;
created_at: string; created_at: string;
tags: string[]; tags: string[];
children?: Meme[]; children?: Meme[];
} }
export interface Collection {
id: number;
name: string;
is_default: number;
created_at: string;
meme_count: number;
}
export interface Tag { export interface Tag {
id: number; id: number;
name: string; name: string;
@@ -33,6 +42,7 @@ export interface ListParams {
page?: number; page?: number;
limit?: number; limit?: number;
parent_only?: boolean; parent_only?: boolean;
collection_id?: number;
} }
async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> { 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.page) qs.set('page', String(params.page));
if (params.limit) qs.set('limit', String(params.limit)); if (params.limit) qs.set('limit', String(params.limit));
if (params.parent_only !== undefined) qs.set('parent_only', String(params.parent_only)); 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}`); return apiFetch<MemesResponse>(`/api/memes?${qs}`);
}, },
@@ -83,6 +94,40 @@ export const api = {
body: JSON.stringify(body), 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: { 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 { useState } from 'react';
import { X, Minimize2, Trash2, Edit2, Check, Layers } from 'lucide-react'; import { X, Minimize2, Trash2, Edit2, Check, Layers, FolderOpen, Inbox } from 'lucide-react';
import { useMeme, useDeleteMeme, useUpdateMeme } from '../hooks/useMemes'; import { useMeme, useDeleteMeme, useUpdateMeme, useMoveMeme, useCollections } from '../hooks/useMemes';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { SharePanel } from './SharePanel'; import { SharePanel } from './SharePanel';
import { RescaleModal } from './RescaleModal'; import { RescaleModal } from './RescaleModal';
@@ -29,6 +29,8 @@ export function MemeDetail({ memeId, onClose }: Props) {
const updateMeme = useUpdateMeme(); const updateMeme = useUpdateMeme();
const { data: auth } = useAuth(); const { data: auth } = useAuth();
const isAdmin = auth?.authenticated === true; const isAdmin = auth?.authenticated === true;
const moveMeme = useMoveMeme();
const { data: collections } = useCollections();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState(''); const [editTitle, setEditTitle] = useState('');
@@ -209,6 +211,39 @@ export function MemeDetail({ memeId, onClose }: Props) {
)} )}
</section> </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 */} {/* Metadata */}
<section> <section>
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Info</h3> <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 { useState, useRef, useCallback } from 'react';
import { X, Upload, ImagePlus } from 'lucide-react'; import { X, Upload, ImagePlus, Inbox, FolderOpen } from 'lucide-react';
import { useUploadMeme } from '../hooks/useMemes'; import { useUploadMeme, useCollections } from '../hooks/useMemes';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
defaultCollectionId?: number;
} }
const ALLOWED = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; 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 [files, setFiles] = useState<File[]>([]);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [tags, setTags] = useState(''); const [tags, setTags] = useState('');
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [collectionId, setCollectionId] = useState<number | null>(defaultCollectionId ?? null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const upload = useUploadMeme(); 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 addFiles = useCallback((incoming: FileList | File[]) => {
const valid = Array.from(incoming).filter((f) => ALLOWED.includes(f.type)); 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(/\.[^.]+$/, '')); fd.append('title', title || file.name.replace(/\.[^.]+$/, ''));
if (description) fd.append('description', description); if (description) fd.append('description', description);
if (tags) fd.append('tags', tags); if (tags) fd.append('tags', tags);
if (effectiveCollectionId != null) fd.append('collection_id', String(effectiveCollectionId));
await upload.mutateAsync(fd); await upload.mutateAsync(fd);
} }
@@ -109,6 +117,33 @@ export function UploadModal({ onClose }: Props) {
</div> </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 */} {/* Metadata */}
<div> <div>
<label className="block text-xs text-zinc-500 mb-1"> <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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, type ListParams } from '../api/client'; 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 = {}) { export function useMemes(params: ListParams = {}) {
return useQuery({ return useQuery({
queryKey: ['memes', params], 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 { 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 { useAuth, useLogout } from '../hooks/useAuth';
import { GalleryGrid } from '../components/GalleryGrid'; import { GalleryGrid } from '../components/GalleryGrid';
import { MemeDetail } from '../components/MemeDetail'; import { MemeDetail } from '../components/MemeDetail';
import { UploadModal } from '../components/UploadModal'; import { UploadModal } from '../components/UploadModal';
import { LoginModal } from '../components/LoginModal'; import { LoginModal } from '../components/LoginModal';
import { SharePanel } from '../components/SharePanel'; import { SharePanel } from '../components/SharePanel';
import { CollectionBar } from '../components/CollectionBar';
import type { Meme } from '../api/client'; import type { Meme } from '../api/client';
export function Gallery() { export function Gallery() {
const [activeTag, setActiveTag] = useState<string | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = 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 [selectedMemeId, setSelectedMemeId] = useState<string | null>(null);
const [quickShareMeme, setQuickShareMeme] = useState<Meme | null>(null); const [quickShareMeme, setQuickShareMeme] = useState<Meme | null>(null);
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
@@ -22,6 +24,20 @@ export function Gallery() {
const logout = useLogout(); const logout = useLogout();
const isAdmin = auth?.authenticated === true; 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 // Debounce search
const [searchTimer, setSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null); const [searchTimer, setSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
function handleSearchChange(val: string) { function handleSearchChange(val: string) {
@@ -35,30 +51,33 @@ export function Gallery() {
tag: activeTag ?? undefined, tag: activeTag ?? undefined,
q: debouncedSearch || undefined, q: debouncedSearch || undefined,
parent_only: true, parent_only: true,
collection_id: activeCollectionId ?? undefined,
limit,
}); });
const { data: tags } = useTags(); const { data: tags } = useTags();
const handleOpen = useCallback((meme: Meme) => { const handleOpen = useCallback((meme: Meme) => setSelectedMemeId(meme.id), []);
setSelectedMemeId(meme.id); const handleShare = useCallback((meme: Meme) => setQuickShareMeme(meme), []);
}, []);
const handleShare = useCallback((meme: Meme) => {
setQuickShareMeme(meme);
}, []);
function handleUploadClick() { function handleUploadClick() {
if (isAdmin) { if (isAdmin) setShowUpload(true);
setShowUpload(true); else setShowLogin(true);
} else {
setShowLogin(true);
} }
function handleCollectionSelect(id: number) {
setActiveCollectionId(id);
setActiveTag(null);
setSearch('');
setDebouncedSearch('');
} }
const activeCollection = collections?.find((c) => c.id === activeCollectionId);
return ( return (
<div className="min-h-screen bg-zinc-950"> <div className="min-h-screen bg-zinc-950">
{/* Topbar */} {/* 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"> <div className="max-w-screen-2xl mx-auto px-4 py-3 flex items-center gap-3">
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-2 mr-2 flex-shrink-0"> <div className="flex items-center gap-2 mr-2 flex-shrink-0">
@@ -73,7 +92,7 @@ export function Gallery() {
type="text" type="text"
value={search} value={search}
onChange={(e) => handleSearchChange(e.target.value)} 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" 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 && ( {search && (
@@ -86,19 +105,17 @@ export function Gallery() {
)} )}
</div> </div>
{/* Right side actions */} {/* Right side */}
<div className="ml-auto flex items-center gap-2 flex-shrink-0"> <div className="ml-auto flex items-center gap-2 flex-shrink-0">
{/* Upload button — always visible, gates on auth */}
<button <button
onClick={handleUploadClick} 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" 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'} title={isAdmin ? 'Upload meme' : 'Sign in to upload'}
> >
{isAdmin ? <UploadIcon size={15} /> : <Lock size={15} />} {isAdmin ? <UploadIcon size={15} /> : <Lock size={15} />}
<span className="hidden sm:inline">{isAdmin ? 'Upload' : 'Upload'}</span> <span className="hidden sm:inline">Upload</span>
</button> </button>
{/* Auth state */}
{isAdmin ? ( {isAdmin ? (
<button <button
onClick={() => logout.mutate()} onClick={() => logout.mutate()}
@@ -122,7 +139,7 @@ export function Gallery() {
{/* Tag filter strip */} {/* Tag filter strip */}
{tags && tags.length > 0 && ( {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 <button
onClick={() => setActiveTag(null)} onClick={() => setActiveTag(null)}
className={`text-xs px-2.5 py-1 rounded-full border whitespace-nowrap transition-colors flex-shrink-0 ${ 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' : 'border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300'
}`} }`}
> >
All All tags
</button> </button>
{tags.map((tag) => ( {tags.map((tag) => (
<button <button
@@ -150,17 +167,44 @@ export function Gallery() {
)} )}
</header> </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 */} {/* Gallery */}
<main className="max-w-screen-2xl mx-auto px-4 py-6"> <main className="max-w-screen-2xl mx-auto px-4 py-6">
{/* Section heading */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div>
<p className="text-sm text-zinc-500"> <p className="text-sm text-zinc-500">
{isLoading {isLoading
? 'Loading…' ? 'Loading…'
: isError : isError
? 'Failed to load' ? 'Failed to load'
: `${data?.total ?? 0} meme${data?.total !== 1 ? 's' : ''}${activeTag ? ` tagged "${activeTag}"` : ''}${debouncedSearch ? ` matching "${debouncedSearch}"` : ''}`} : (() => {
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> </p>
</div> </div>
</div>
{isLoading ? ( {isLoading ? (
<div className="columns-1 sm:columns-2 lg:columns-3 xl:columns-4 gap-3"> <div className="columns-1 sm:columns-2 lg:columns-3 xl:columns-4 gap-3">
@@ -168,7 +212,7 @@ export function Gallery() {
<div <div
key={i} key={i}
className="break-inside-avoid mb-3 rounded-xl bg-zinc-900 animate-pulse" 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> </div>
@@ -212,8 +256,13 @@ export function Gallery() {
</div> </div>
)} )}
{/* Upload modal (admin only) */} {/* Upload modal */}
{showUpload && isAdmin && <UploadModal onClose={() => setShowUpload(false)} />} {showUpload && isAdmin && (
<UploadModal
onClose={() => setShowUpload(false)}
defaultCollectionId={activeCollectionId ?? unsorted?.id}
/>
)}
{/* Login modal */} {/* Login modal */}
{showLogin && ( {showLogin && (