build collections
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
97
backend/src/routes/collections.ts
Normal file
97
backend/src/routes/collections.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
101
frontend/src/components/CollectionBar.tsx
Normal file
101
frontend/src/components/CollectionBar.tsx
Normal 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)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
frontend/src/components/CollectionModal.tsx
Normal file
104
frontend/src/components/CollectionModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user