diff --git a/backend/src/db.ts b/backend/src/db.ts index 3cd13c0..d7ebe45 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -10,23 +10,31 @@ fs.mkdirSync(DB_DIR, { recursive: true }); const db = new Database(DB_PATH); -// Enable WAL mode for better concurrent read performance db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); +// Core tables 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 ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - description TEXT, - file_path TEXT NOT NULL, - file_name TEXT NOT NULL, - file_size INTEGER NOT NULL, - mime_type TEXT NOT NULL, - width INTEGER NOT NULL, - height INTEGER NOT NULL, - parent_id TEXT REFERENCES memes(id) ON DELETE CASCADE, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + file_path TEXT NOT NULL, + file_name TEXT NOT NULL, + file_size INTEGER NOT NULL, + mime_type TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + 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')) ); CREATE TABLE IF NOT EXISTS tags ( @@ -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_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_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; diff --git a/backend/src/index.ts b/backend/src/index.ts index 4301aa3..7c2b8c7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,6 +7,7 @@ import { ensureImagesDir, IMAGES_DIR } from './services/storage.js'; import { memesRoutes } from './routes/memes.js'; import { tagsRoutes } from './routes/tags.js'; import { authRoutes } from './routes/auth.js'; +import { collectionsRoutes } from './routes/collections.js'; // Ensure data dirs exist ensureImagesDir(); @@ -37,6 +38,7 @@ await app.register(fastifyStatic, { // API routes await app.register(authRoutes); +await app.register(collectionsRoutes); await app.register(memesRoutes); await app.register(tagsRoutes); diff --git a/backend/src/routes/collections.ts b/backend/src/routes/collections.ts new file mode 100644 index 0000000..3a683b3 --- /dev/null +++ b/backend/src/routes/collections.ts @@ -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 }; + } + ); +} diff --git a/backend/src/routes/memes.ts b/backend/src/routes/memes.ts index c25da87..b316b6d 100644 --- a/backend/src/routes/memes.ts +++ b/backend/src/routes/memes.ts @@ -1,11 +1,11 @@ import type { FastifyInstance } from 'fastify'; import type { MultipartFile } from '@fastify/multipart'; 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 { extractMeta, resizeImage, saveBuffer } from '../services/image.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']); @@ -40,23 +40,17 @@ function setMemeTags(memeId: string, tagNames: string[]): void { const res = db.prepare('INSERT INTO tags (name) VALUES (?)').run(trimmed); tag = { id: Number(res.lastInsertRowid) }; } - db.prepare('INSERT OR IGNORE INTO meme_tags (meme_id, tag_id) VALUES (?, ?)').run( - memeId, - tag.id - ); + db.prepare('INSERT OR IGNORE INTO meme_tags (meme_id, tag_id) VALUES (?, ?)').run(memeId, tag.id); } } export async function memesRoutes(app: FastifyInstance) { // List memes 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); - let sql = ` - SELECT DISTINCT m.* - FROM memes m - `; + let sql = `SELECT DISTINCT m.* FROM memes m`; const params: (string | number)[] = []; const conditions: string[] = []; @@ -64,16 +58,21 @@ export async function memesRoutes(app: FastifyInstance) { conditions.push('m.parent_id IS NULL'); } + if (collection_id !== undefined) { + conditions.push('m.collection_id = ?'); + params.push(Number(collection_id)); + } + if (tag) { sql += ` 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 = ? `; - params.push(tag.toLowerCase()); + params.unshift(tag.toLowerCase()); } if (q) { - conditions.push(`(m.title LIKE ? OR m.description LIKE ?)`); + conditions.push('(m.title LIKE ? OR m.description LIKE ?)'); params.push(`%${q}%`, `%${q}%`); } @@ -85,17 +84,25 @@ export async function memesRoutes(app: FastifyInstance) { params.push(Number(limit), offset); const memes = db.prepare(sql).all(...params) as Meme[]; - const total = ( - db - .prepare( - `SELECT COUNT(DISTINCT m.id) as count FROM memes m - ${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 = ?` : ''} - ${conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''}` - ) - .get(...(tag ? [tag.toLowerCase(), ...params.slice(1, -2)] : params.slice(0, -2))) as { - count: number; - } - ).count; + + // Count query — rebuild without ORDER/LIMIT + let countSql = `SELECT COUNT(DISTINCT m.id) as count FROM memes m`; + const countParams: (string | number)[] = []; + const countConditions = [...conditions]; + + if (tag) { + countSql += ` + 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()); + } + 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 { memes: memes.map((m) => ({ ...m, tags: getMemeTags(m.id) })), @@ -135,62 +142,81 @@ export async function memesRoutes(app: FastifyInstance) { const filePath = buildFilePath(id, ext); await saveBuffer(buffer, filePath); - const meta = await extractMeta(filePath); const fields = file.fields as Record; const title = fields.title?.value ?? file.filename ?? 'Untitled'; const description = fields.description?.value ?? null; const tagsRaw = fields.tags?.value ?? ''; + const collectionId = fields.collection_id?.value + ? Number(fields.collection_id.value) + : UNSORTED_ID; db.prepare( - `INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run(id, title, description, filePath, file.filename, meta.size, meta.mimeType, meta.width, meta.height); + `INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height, collection_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run(id, title, description, filePath, file.filename, meta.size, meta.mimeType, meta.width, meta.height, collectionId); - if (tagsRaw) { - setMemeTags(id, tagsRaw.split(',')); - } + if (tagsRaw) setMemeTags(id, tagsRaw.split(',')); return reply.status(201).send(getMemeById(id)); }); // Update meme metadata - app.put<{ Params: { id: string }; Body: UpdateBody }>('/api/memes/:id', { preHandler: requireAuth }, async (req, reply) => { - const meme = getMemeById(req.params.id); - if (!meme) return reply.status(404).send({ error: 'Not found' }); + app.put<{ Params: { id: string }; Body: UpdateBody }>( + '/api/memes/:id', + { preHandler: requireAuth }, + async (req, reply) => { + const meme = getMemeById(req.params.id); + 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( - `UPDATE memes SET title = ?, description = ? WHERE id = ?` - ).run(title ?? meme.title, description ?? meme.description, meme.id); - - if (tags !== undefined) { - setMemeTags(meme.id, tags); + if (tags !== undefined) setMemeTags(meme.id, tags); + return getMemeById(meme.id); } + ); - return getMemeById(meme.id); - }); + // Move meme to a different collection + 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); - if (!meme) return reply.status(404).send({ error: 'Not found' }); + app.delete<{ Params: { id: string } }>( + '/api/memes/:id', + { preHandler: requireAuth }, + async (req, reply) => { + const meme = getMemeById(req.params.id); + if (!meme) return reply.status(404).send({ error: 'Not found' }); - // Delete child files first - const children = db - .prepare('SELECT file_path FROM memes WHERE parent_id = ?') - .all(meme.id) as { file_path: string }[]; - for (const child of children) { - deleteFile(child.file_path); + const children = db + .prepare('SELECT file_path FROM memes WHERE parent_id = ?') + .all(meme.id) as { file_path: string }[]; + for (const child of children) deleteFile(child.file_path); + + deleteFile(meme.file_path); + db.prepare('DELETE FROM memes WHERE id = ?').run(meme.id); + return { ok: true }; } - - deleteFile(meme.file_path); - db.prepare('DELETE FROM memes WHERE id = ?').run(meme.id); - - return { ok: true }; - }); + ); // Non-destructive rescale 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; - if (!width && !height) { - return reply.status(400).send({ error: 'width or height is required' }); - } + if (!width && !height) return reply.status(400).send({ error: 'width or height is required' }); const childId = uuidv4(); 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 }); db.prepare( - `INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height, parent_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height, parent_id, collection_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( childId, `${parent.title} (${autoLabel})`, @@ -228,7 +252,8 @@ export async function memesRoutes(app: FastifyInstance) { meta.mimeType, meta.width, meta.height, - parent.id + parent.id, + parent.collection_id ); return reply.status(201).send(getMemeById(childId)); diff --git a/backend/src/types.ts b/backend/src/types.ts index 6dcd0b2..e5009ff 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -9,10 +9,19 @@ export interface Meme { width: number; height: number; parent_id: string | null; + collection_id: number | null; created_at: string; tags: string[]; } +export interface Collection { + id: number; + name: string; + is_default: number; + created_at: string; + meme_count: number; +} + export interface Tag { id: number; name: string; @@ -23,6 +32,7 @@ export interface UploadBody { title?: string; description?: string; tags?: string; + collection_id?: string; } export interface UpdateBody { @@ -31,6 +41,10 @@ export interface UpdateBody { tags?: string[]; } +export interface MoveBody { + collection_id: number; +} + export interface RescaleBody { width?: number; height?: number; @@ -44,4 +58,5 @@ export interface ListQuery { page?: number; limit?: number; parent_only?: string; + collection_id?: string; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ec1d80e..3f5689e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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(url: string, init?: RequestInit): Promise { @@ -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(`/api/memes?${qs}`); }, @@ -83,6 +94,40 @@ export const api = { body: JSON.stringify(body), }); }, + + move(id: string, collection_id: number): Promise { + return apiFetch(`/api/memes/${id}/collection`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ collection_id }), + }); + }, + }, + + collections: { + list(): Promise { + return apiFetch('/api/collections'); + }, + + create(name: string): Promise { + return apiFetch('/api/collections', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + }, + + rename(id: number, name: string): Promise { + 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: { diff --git a/frontend/src/components/CollectionBar.tsx b/frontend/src/components/CollectionBar.tsx new file mode 100644 index 0000000..37639ea --- /dev/null +++ b/frontend/src/components/CollectionBar.tsx @@ -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(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 ( + <> +
+ {collections.map((col) => { + const isActive = activeId === col.id; + const isDefault = col.is_default === 1; + + return ( +
+ + + {/* Admin controls — appear on hover for non-default collections */} + {isAdmin && !isDefault && ( +
+ + +
+ )} +
+ ); + })} + + {/* New folder button (admin only) */} + {isAdmin && ( + + )} +
+ + {showCreate && ( + setShowCreate(false)} /> + )} + {renaming && ( + setRenaming(null)} /> + )} + + ); +} diff --git a/frontend/src/components/CollectionModal.tsx b/frontend/src/components/CollectionModal.tsx new file mode 100644 index 0000000..b6b3217 --- /dev/null +++ b/frontend/src/components/CollectionModal.tsx @@ -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(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 ( +
+
+
+
+ {isRename ? ( + + ) : ( + + )} +

+ {isRename ? 'Rename Folder' : 'New Folder'} +

+
+ +
+ +
+
+ + 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} + /> +
+ + {error && ( +

{(error as Error).message}

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/MemeDetail.tsx b/frontend/src/components/MemeDetail.tsx index 29f7d13..ef35304 100644 --- a/frontend/src/components/MemeDetail.tsx +++ b/frontend/src/components/MemeDetail.tsx @@ -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) { )} + {/* Folder */} + {collections && collections.length > 0 && ( +
+

Folder

+ {isAdmin ? ( +
+ {collections.map((col) => { + const isActive = meme.collection_id === col.id; + return ( + + ); + })} +
+ ) : ( +

+ {collections.find((c) => c.id === meme.collection_id)?.name ?? 'Unsorted'} +

+ )} +
+ )} + {/* Metadata */}

Info

diff --git a/frontend/src/components/UploadModal.tsx b/frontend/src/components/UploadModal.tsx index 9e26da3..4c892c8 100644 --- a/frontend/src/components/UploadModal.tsx +++ b/frontend/src/components/UploadModal.tsx @@ -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([]); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const [tags, setTags] = useState(''); const [dragging, setDragging] = useState(false); + const [collectionId, setCollectionId] = useState(defaultCollectionId ?? null); const inputRef = useRef(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) { )} + {/* Folder selector */} + {collections && collections.length > 0 && ( +
+ +
+ {collections.map((col) => { + const isSelected = (collectionId ?? unsorted?.id) === col.id; + return ( + + ); + })} +
+
+ )} + {/* Metadata */}