build 1
This commit is contained in:
26
backend/package.json
Normal file
26
backend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "memer-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/multipart": "^8.3.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"fastify": "^4.27.0",
|
||||
"sharp": "^0.33.4",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"tsx": "^4.9.3",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
49
backend/src/db.ts
Normal file
49
backend/src/db.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR ?? '/data';
|
||||
const DB_DIR = path.join(DATA_DIR, 'db');
|
||||
const DB_PATH = path.join(DB_DIR, 'memer.db');
|
||||
|
||||
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');
|
||||
|
||||
db.exec(`
|
||||
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'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL COLLATE NOCASE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meme_tags (
|
||||
meme_id TEXT NOT NULL REFERENCES memes(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (meme_id, tag_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_meme_tags_meme_id ON meme_tags(meme_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_meme_tags_tag_id ON meme_tags(tag_id);
|
||||
`);
|
||||
|
||||
export default db;
|
||||
58
backend/src/index.ts
Normal file
58
backend/src/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import Fastify from 'fastify';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { ensureImagesDir, IMAGES_DIR } from './services/storage.js';
|
||||
import { memesRoutes } from './routes/memes.js';
|
||||
import { tagsRoutes } from './routes/tags.js';
|
||||
|
||||
// Ensure data dirs exist
|
||||
ensureImagesDir();
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const app = Fastify({ logger: { level: 'info' } });
|
||||
|
||||
// Multipart for file uploads (100 MB max)
|
||||
await app.register(fastifyMultipart, {
|
||||
limits: { fileSize: 100 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
// Serve uploaded image files at /images/*
|
||||
await app.register(fastifyStatic, {
|
||||
root: IMAGES_DIR,
|
||||
prefix: '/images/',
|
||||
decorateReply: false,
|
||||
});
|
||||
|
||||
// Serve built React frontend at /*
|
||||
const frontendDist = path.join(__dirname, '..', 'public');
|
||||
await app.register(fastifyStatic, {
|
||||
root: frontendDist,
|
||||
prefix: '/',
|
||||
wildcard: false,
|
||||
});
|
||||
|
||||
// API routes
|
||||
await app.register(memesRoutes);
|
||||
await app.register(tagsRoutes);
|
||||
|
||||
// SPA fallback — serve index.html for all non-API, non-image routes
|
||||
app.setNotFoundHandler(async (req, reply) => {
|
||||
if (req.url.startsWith('/api/') || req.url.startsWith('/images/')) {
|
||||
return reply.status(404).send({ error: 'Not found' });
|
||||
}
|
||||
return reply.sendFile('index.html', frontendDist);
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
try {
|
||||
await app.listen({ port, host });
|
||||
console.log(`Memer running at http://${host}:${port}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
235
backend/src/routes/memes.ts
Normal file
235
backend/src/routes/memes.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { MultipartFile } from '@fastify/multipart';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import db from '../db.js';
|
||||
import { buildFilePath, deleteFile, getExtension } from '../services/storage.js';
|
||||
import { extractMeta, resizeImage, saveBuffer } from '../services/image.js';
|
||||
import type { ListQuery, UpdateBody, RescaleBody, Meme } from '../types.js';
|
||||
|
||||
const ALLOWED_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
||||
|
||||
function getMemeTags(memeId: string): string[] {
|
||||
return (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.name FROM tags t
|
||||
JOIN meme_tags mt ON mt.tag_id = t.id
|
||||
WHERE mt.meme_id = ?
|
||||
ORDER BY t.name`
|
||||
)
|
||||
.all(memeId) as { name: string }[]
|
||||
).map((r) => r.name);
|
||||
}
|
||||
|
||||
function getMemeById(id: string): Meme | null {
|
||||
const row = db.prepare('SELECT * FROM memes WHERE id = ?').get(id) as Meme | undefined;
|
||||
if (!row) return null;
|
||||
return { ...row, tags: getMemeTags(id) };
|
||||
}
|
||||
|
||||
function setMemeTags(memeId: string, tagNames: string[]): void {
|
||||
db.prepare('DELETE FROM meme_tags WHERE meme_id = ?').run(memeId);
|
||||
for (const name of tagNames) {
|
||||
const trimmed = name.trim().toLowerCase();
|
||||
if (!trimmed) continue;
|
||||
let tag = db.prepare('SELECT id FROM tags WHERE name = ?').get(trimmed) as
|
||||
| { id: number }
|
||||
| undefined;
|
||||
if (!tag) {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 offset = (Number(page) - 1) * Number(limit);
|
||||
|
||||
let sql = `
|
||||
SELECT DISTINCT m.*
|
||||
FROM memes m
|
||||
`;
|
||||
const params: (string | number)[] = [];
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (parent_only === 'true') {
|
||||
conditions.push('m.parent_id IS NULL');
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
if (q) {
|
||||
conditions.push(`(m.title LIKE ? OR m.description LIKE ?)`);
|
||||
params.push(`%${q}%`, `%${q}%`);
|
||||
}
|
||||
|
||||
if (conditions.length) {
|
||||
sql += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
sql += ' ORDER BY m.created_at DESC LIMIT ? OFFSET ?';
|
||||
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;
|
||||
|
||||
return {
|
||||
memes: memes.map((m) => ({ ...m, tags: getMemeTags(m.id) })),
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
};
|
||||
});
|
||||
|
||||
// Get single meme + children
|
||||
app.get<{ Params: { id: string } }>('/api/memes/:id', async (req, reply) => {
|
||||
const meme = getMemeById(req.params.id);
|
||||
if (!meme) return reply.status(404).send({ error: 'Not found' });
|
||||
|
||||
const children = (
|
||||
db.prepare('SELECT * FROM memes WHERE parent_id = ? ORDER BY created_at ASC').all(meme.id) as Meme[]
|
||||
).map((c) => ({ ...c, tags: getMemeTags(c.id) }));
|
||||
|
||||
return { ...meme, children };
|
||||
});
|
||||
|
||||
// Upload meme
|
||||
app.post('/api/memes', async (req, reply) => {
|
||||
const data = await req.file();
|
||||
if (!data) return reply.status(400).send({ error: 'No file uploaded' });
|
||||
|
||||
const file = data as MultipartFile;
|
||||
const mimeType = file.mimetype;
|
||||
|
||||
if (!ALLOWED_MIMES.has(mimeType)) {
|
||||
return reply.status(400).send({ error: `Unsupported file type: ${mimeType}` });
|
||||
}
|
||||
|
||||
const buffer = await file.toBuffer();
|
||||
const id = uuidv4();
|
||||
const ext = getExtension(mimeType);
|
||||
const filePath = buildFilePath(id, ext);
|
||||
|
||||
await saveBuffer(buffer, filePath);
|
||||
|
||||
const meta = await extractMeta(filePath);
|
||||
|
||||
const fields = file.fields as Record<string, { value: string }>;
|
||||
const title = fields.title?.value ?? file.filename ?? 'Untitled';
|
||||
const description = fields.description?.value ?? null;
|
||||
const tagsRaw = fields.tags?.value ?? '';
|
||||
|
||||
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);
|
||||
|
||||
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', 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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return getMemeById(meme.id);
|
||||
});
|
||||
|
||||
// Delete meme (children cascade)
|
||||
app.delete<{ Params: { id: string } }>('/api/memes/:id', 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);
|
||||
}
|
||||
|
||||
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 }>(
|
||||
'/api/memes/:id/rescale',
|
||||
async (req, reply) => {
|
||||
const parent = getMemeById(req.params.id);
|
||||
if (!parent) return reply.status(404).send({ error: 'Not found' });
|
||||
if (parent.parent_id) {
|
||||
return reply.status(400).send({ error: 'Cannot rescale a derived image. Rescale the original.' });
|
||||
}
|
||||
|
||||
const { width, height, quality = 85, label } = req.body;
|
||||
if (!width && !height) {
|
||||
return reply.status(400).send({ error: 'width or height is required' });
|
||||
}
|
||||
|
||||
const childId = uuidv4();
|
||||
const ext = getExtension(parent.mime_type);
|
||||
const autoLabel = label ?? (width ? `${width}w` : `${height}h`);
|
||||
const childPath = buildFilePath(childId, ext, autoLabel);
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
childId,
|
||||
`${parent.title} (${autoLabel})`,
|
||||
parent.description,
|
||||
childPath,
|
||||
parent.file_name,
|
||||
meta.size,
|
||||
meta.mimeType,
|
||||
meta.width,
|
||||
meta.height,
|
||||
parent.id
|
||||
);
|
||||
|
||||
return reply.status(201).send(getMemeById(childId));
|
||||
}
|
||||
);
|
||||
}
|
||||
43
backend/src/routes/tags.ts
Normal file
43
backend/src/routes/tags.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import db from '../db.js';
|
||||
|
||||
export async function tagsRoutes(app: FastifyInstance) {
|
||||
app.get('/api/tags', async () => {
|
||||
const tags = db
|
||||
.prepare(
|
||||
`SELECT t.id, t.name, COUNT(mt.meme_id) as meme_count
|
||||
FROM tags t
|
||||
LEFT JOIN meme_tags mt ON mt.tag_id = t.id
|
||||
GROUP BY t.id
|
||||
ORDER BY meme_count DESC, t.name ASC`
|
||||
)
|
||||
.all() as { id: number; name: string; meme_count: number }[];
|
||||
return tags;
|
||||
});
|
||||
|
||||
app.post<{ Body: { name: string } }>('/api/tags', async (req, reply) => {
|
||||
const { name } = req.body;
|
||||
if (!name?.trim()) {
|
||||
return reply.status(400).send({ error: 'Tag name is required' });
|
||||
}
|
||||
const trimmed = name.trim().toLowerCase();
|
||||
try {
|
||||
const result = db.prepare('INSERT INTO tags (name) VALUES (?)').run(trimmed);
|
||||
return db
|
||||
.prepare('SELECT id, name, 0 as meme_count FROM tags WHERE id = ?')
|
||||
.get(result.lastInsertRowid);
|
||||
} catch {
|
||||
// Unique constraint — return existing
|
||||
return db
|
||||
.prepare('SELECT id, name FROM tags WHERE name = ?')
|
||||
.get(trimmed);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/tags/:id', async (req, reply) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!id) return reply.status(400).send({ error: 'Invalid tag id' });
|
||||
db.prepare('DELETE FROM tags WHERE id = ?').run(id);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
91
backend/src/services/image.ts
Normal file
91
backend/src/services/image.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import sharp from 'sharp';
|
||||
import fs from 'fs';
|
||||
import { absolutePath, ensureDir } from './storage.js';
|
||||
|
||||
export interface ImageMeta {
|
||||
width: number;
|
||||
height: number;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function extractMeta(filePath: string): Promise<ImageMeta> {
|
||||
const abs = absolutePath(filePath);
|
||||
const meta = await sharp(abs).metadata();
|
||||
const stat = fs.statSync(abs);
|
||||
|
||||
const mimeMap: Record<string, string> = {
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
|
||||
return {
|
||||
width: meta.width ?? 0,
|
||||
height: meta.height ?? 0,
|
||||
mimeType: mimeMap[meta.format ?? ''] ?? 'image/jpeg',
|
||||
size: stat.size,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveBuffer(buffer: Buffer, destRelPath: string): Promise<void> {
|
||||
ensureDir(destRelPath);
|
||||
const abs = absolutePath(destRelPath);
|
||||
fs.writeFileSync(abs, buffer);
|
||||
}
|
||||
|
||||
export interface ResizeOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export async function resizeImage(
|
||||
srcRelPath: string,
|
||||
destRelPath: string,
|
||||
options: ResizeOptions
|
||||
): Promise<ImageMeta> {
|
||||
const srcAbs = absolutePath(srcRelPath);
|
||||
ensureDir(destRelPath);
|
||||
const destAbs = absolutePath(destRelPath);
|
||||
|
||||
const src = await sharp(srcAbs).metadata();
|
||||
const isGif = src.format === 'gif';
|
||||
|
||||
let pipeline = sharp(srcAbs, { animated: isGif });
|
||||
|
||||
if (options.width || options.height) {
|
||||
pipeline = pipeline.resize({
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isGif && options.quality) {
|
||||
if (src.format === 'jpeg') pipeline = pipeline.jpeg({ quality: options.quality });
|
||||
else if (src.format === 'png') pipeline = pipeline.png({ quality: options.quality });
|
||||
else if (src.format === 'webp') pipeline = pipeline.webp({ quality: options.quality });
|
||||
}
|
||||
|
||||
await pipeline.toFile(destAbs);
|
||||
|
||||
const resultMeta = await sharp(destAbs).metadata();
|
||||
const stat = fs.statSync(destAbs);
|
||||
|
||||
const mimeMap: Record<string, string> = {
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
|
||||
return {
|
||||
width: resultMeta.width ?? 0,
|
||||
height: resultMeta.height ?? 0,
|
||||
mimeType: mimeMap[resultMeta.format ?? ''] ?? 'image/jpeg',
|
||||
size: stat.size,
|
||||
};
|
||||
}
|
||||
49
backend/src/services/storage.ts
Normal file
49
backend/src/services/storage.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR ?? '/data';
|
||||
export const IMAGES_DIR = path.join(DATA_DIR, 'images');
|
||||
|
||||
export function ensureImagesDir(): void {
|
||||
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export function getMonthDir(): string {
|
||||
const now = new Date();
|
||||
const yyyy = now.getFullYear();
|
||||
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||
return `${yyyy}-${mm}`;
|
||||
}
|
||||
|
||||
export function buildFilePath(id: string, ext: string, label?: string): string {
|
||||
const monthDir = getMonthDir();
|
||||
const suffix = label ? `-${label}` : '';
|
||||
const filename = `${id}${suffix}.${ext}`;
|
||||
return path.join(monthDir, filename);
|
||||
}
|
||||
|
||||
export function absolutePath(relativePath: string): string {
|
||||
return path.join(IMAGES_DIR, relativePath);
|
||||
}
|
||||
|
||||
export function ensureDir(relativePath: string): void {
|
||||
const dir = path.dirname(absolutePath(relativePath));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export function deleteFile(relativePath: string): void {
|
||||
const abs = absolutePath(relativePath);
|
||||
if (fs.existsSync(abs)) {
|
||||
fs.unlinkSync(abs);
|
||||
}
|
||||
}
|
||||
|
||||
export function getExtension(mimeType: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
};
|
||||
return map[mimeType] ?? 'jpg';
|
||||
}
|
||||
47
backend/src/types.ts
Normal file
47
backend/src/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface Meme {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
file_path: string;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
width: number;
|
||||
height: number;
|
||||
parent_id: string | null;
|
||||
created_at: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
meme_count: number;
|
||||
}
|
||||
|
||||
export interface UploadBody {
|
||||
title?: string;
|
||||
description?: string;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export interface UpdateBody {
|
||||
title?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface RescaleBody {
|
||||
width?: number;
|
||||
height?: number;
|
||||
quality?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ListQuery {
|
||||
tag?: string;
|
||||
q?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
parent_only?: string;
|
||||
}
|
||||
15
backend/tsconfig.json
Normal file
15
backend/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user