diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3b897a7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/node_modules +**/dist +backend/public +**/.env +**/*.log +.git +.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a7392c9 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Public-facing URL used to build share links (no trailing slash) +PUBLIC_URL=https://meme.alwisp.com + +# Internal server port (default: 3000) +PORT=3000 + +# Data directory inside the container (default: /data) +DATA_DIR=/data diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6ec2f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +backend/public/ +data/ +.env +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d6e384c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# ── Stage 1: Build frontend ──────────────────────────────────────────────── +FROM node:20-alpine AS frontend-build +WORKDIR /app/frontend + +COPY frontend/package*.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build +# Output lands in /app/backend/public via vite outDir + +# ── Stage 2: Build backend ───────────────────────────────────────────────── +FROM node:20-alpine AS backend-build +WORKDIR /app/backend + +COPY backend/package*.json ./ +RUN npm ci + +COPY backend/ ./ +# Copy the built frontend into backend/public before TS compile (static refs) +COPY --from=frontend-build /app/backend/public ./public +RUN npm run build + +# ── Stage 3: Runtime ─────────────────────────────────────────────────────── +FROM node:20-alpine AS runtime +WORKDIR /app + +# Install production deps only +COPY backend/package*.json ./ +RUN npm ci --omit=dev + +# Copy compiled backend +COPY --from=backend-build /app/backend/dist ./dist + +# Copy frontend assets +COPY --from=backend-build /app/backend/public ./public + +# Data volumes +VOLUME ["/data/images", "/data/db"] + +ENV NODE_ENV=production +ENV PORT=3000 +ENV DATA_DIR=/data + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ + CMD wget -qO- http://localhost:3000/api/tags || exit 1 + +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index 2a16f86..dacd648 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,76 @@ -# Drop-In Agent Instruction Suite +# Memer -This repository is a portable markdown instruction pack for coding agents. +A self-hosted meme gallery with quick-share for text message and Telegram. Runs as a single Docker container, designed for Unraid. -Copy these files into another repository to give the agent: -- a root `AGENTS.md` entrypoint, -- a central skill index, -- category hubs for routing, -- specialized skill files for common software, docs, UX, marketing, and ideation tasks. +## Features -## Structure +- **Masonry gallery** — responsive, dark-themed grid +- **Upload** — drag & drop or click, supports JPG/PNG/GIF/WebP (up to 100 MB) +- **Tags** — organize with comma-separated tags, filter by tag in the gallery +- **Search** — full-text search across titles and descriptions +- **Quick share** — copy link, Telegram, SMS, or download from any card or detail view +- **Non-destructive rescale** — creates a child image at a new size without touching the original +- **Persistent** — SQLite database + image files on Docker volumes (easy to back up or export) -- `AGENTS.md` - base instructions and routing rules -- `DEPLOYMENT-PROFILE.md` - agent-readable prefilled deployment defaults -- `INSTALL.md` - copy and customization guide for other repositories -- `PROJECT-PROFILE-WORKBOOK.md` - one-time questionnaire for staging defaults -- `SKILLS.md` - canonical skill index -- `ROUTING-EXAMPLES.md` - representative prompt-to-skill routing examples -- `hubs/` - category-level routing guides -- `skills/` - specialized reusable skill files +## Quick Start -## Design Goals +```bash +cp .env.example .env +# Edit .env: set PUBLIC_URL to your domain +docker compose up --build -d +``` -- Plain markdown only -- Cross-agent portability -- Implementation-first defaults -- On-demand skill loading instead of loading everything every session -- Context-efficient routing for large skill libraries -- Prefilled deployment defaults without per-install questioning -- Repo-local instructions take precedence over this bundle +Open `http://localhost:3000`. -## Intended Workflow +## Unraid Setup -1. The agent reads `AGENTS.md`. -2. The agent reads `DEPLOYMENT-PROFILE.md` when it is filled in. -3. The agent checks `SKILLS.md`. -4. The agent opens only the relevant hub and skill files for the task. -5. The agent combines multiple skills when the task spans several domains. +1. In Unraid, go to **Docker > Add Container** (or use Community Applications). +2. Use the image `memer:latest` (build locally or push to a registry). +3. Map port **3000** to your desired host port. +4. Add two path mappings: + - `/data/images` → `/mnt/user/appdata/memer/images` + - `/data/db` → `/mnt/user/appdata/memer/db` +5. Set environment variable `PUBLIC_URL` to `https://meme.alwisp.com`. +6. Set up your reverse proxy (Nginx Proxy Manager, Swag, etc.) to point `meme.alwisp.com` → port 3000. -## Core Categories +## Environment Variables -- Software development -- Debugging -- Documentation -- UI/UX -- Marketing -- Brainstorming +| Variable | Default | Description | +|---|---|---| +| `PUBLIC_URL` | `http://localhost:3000` | Used to build absolute share links | +| `PORT` | `3000` | Port the server listens on | +| `DATA_DIR` | `/data` | Root for images and DB inside the container | + +## API + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/memes` | List memes (`?tag=`, `?q=`, `?page=`, `?limit=`, `?parent_only=`) | +| `POST` | `/api/memes` | Upload meme (multipart: `file`, `title`, `description`, `tags`) | +| `GET` | `/api/memes/:id` | Get meme + children | +| `PUT` | `/api/memes/:id` | Update title/description/tags | +| `DELETE` | `/api/memes/:id` | Delete meme and all rescaled copies | +| `POST` | `/api/memes/:id/rescale` | Create rescaled child (`{ width?, height?, quality?, label? }`) | +| `GET` | `/api/tags` | List tags with counts | +| `DELETE` | `/api/tags/:id` | Delete tag | + +## Local Development + +```bash +# Terminal 1 — backend +cd backend && npm install && npm run dev + +# Terminal 2 — frontend (proxies /api and /images to :3000) +cd frontend && npm install && npm run dev +``` + +Frontend dev server: `http://localhost:5173` +Backend API: `http://localhost:3000` + +## Backup + +All data lives in two directories: +- `memer-images` volume — the actual image files +- `memer-db` volume — `memer.db` (SQLite) + +Copy these two directories to back up everything. diff --git a/UNRAID.md b/UNRAID.md new file mode 100644 index 0000000..34ecc7c --- /dev/null +++ b/UNRAID.md @@ -0,0 +1,208 @@ +# Unraid Installation Guide + +Two methods: **GUI** (Docker tab) and **CLI** (terminal). Both result in the same running container. + +--- + +## Prerequisites + +- Unraid 6.10 or later +- Docker enabled (Settings > Docker > Enable Docker: Yes) +- The `memer` image available — either: + - Built locally and transferred, **or** + - Pushed to a registry (Docker Hub, GHCR, your own) and pulled by Unraid +- A reverse proxy pointed at the container port if you want HTTPS (Nginx Proxy Manager, Swag, Traefik, etc.) + +### Build and push the image (from your dev machine) + +```bash +docker build -t youruser/memer:latest . +docker push youruser/memer:latest +``` + +Or load a local tarball directly on the Unraid terminal: + +```bash +# On dev machine +docker save memer:latest | gzip > memer.tar.gz +scp memer.tar.gz root@:/tmp/ + +# On Unraid terminal +docker load -i /tmp/memer.tar.gz +``` + +--- + +## Method 1 — Unraid GUI (Docker Tab) + +1. Go to **Docker** tab → **Add Container** +2. Fill in the fields as follows: + +### Basic + +| Field | Value | +|---|---| +| Name | `memer` | +| Repository | `youruser/memer:latest` (or `memer:latest` if loaded locally) | +| Network Type | `bridge` | +| Restart Policy | `unless-stopped` | +| Console shell | `sh` | + +### Port Mappings + +Click **Add another Path, Port, Variable, Label or Device** → select **Port** + +| Config Type | Name | Container Port | Host Port | Connection Type | +|---|---|---|---|---| +| Port | Web UI | `3000` | `3000` | TCP | + +> Change the host port (e.g. `3001`) if 3000 is already in use on your server. + +### Volume Mappings + +Add two path mappings (click **Add another Path** → select **Path** for each): + +| Config Type | Name | Container Path | Host Path | Access Mode | +|---|---|---|---|---| +| Path | Images | `/data/images` | `/mnt/user/appdata/memer/images` | Read/Write | +| Path | Database | `/data/db` | `/mnt/user/appdata/memer/db` | Read/Write | + +> You can substitute `/mnt/user/appdata/` with any share path — just keep both sub-paths consistent. + +### Environment Variables + +Add three variables (click **Add another Path** → select **Variable** for each): + +| Config Type | Name | Key | Value | +|---|---|---|---| +| Variable | Public URL | `PUBLIC_URL` | `https://meme.alwisp.com` | +| Variable | Port | `PORT` | `3000` | +| Variable | Data Dir | `DATA_DIR` | `/data` | + +> `PUBLIC_URL` is what gets embedded in share links (copy link, Telegram, SMS). Set it to your actual external URL. + +3. Click **Apply**. Unraid will pull/start the container. +4. Check the container log (click the container name → **Log**) — you should see: + + ``` + Memer running at http://0.0.0.0:3000 + ``` + +5. Navigate to `http://:3000` to confirm it's working. + +--- + +## Method 2 — CLI (Unraid Terminal) + +SSH into your Unraid server or open a terminal via the web UI (Tools > Terminal). + +### Create appdata directories + +```bash +mkdir -p /mnt/user/appdata/memer/images +mkdir -p /mnt/user/appdata/memer/db +``` + +### Run the container + +```bash +docker run -d \ + --name memer \ + --restart unless-stopped \ + -p 3000:3000 \ + -v /mnt/user/appdata/memer/images:/data/images \ + -v /mnt/user/appdata/memer/db:/data/db \ + -e PUBLIC_URL="https://meme.alwisp.com" \ + -e PORT="3000" \ + -e DATA_DIR="/data" \ + memer:latest +``` + +### Verify it started + +```bash +docker ps | grep memer +docker logs memer +``` + +You should see the server startup line and no errors. + +### Quick health check + +```bash +curl -s http://localhost:3000/api/tags +# Should return: [] +``` + +--- + +## Reverse Proxy (Nginx Proxy Manager) + +If you're using Nginx Proxy Manager to serve `meme.alwisp.com`: + +1. **Proxy Hosts** → **Add Proxy Host** +2. Set: + - **Domain Names:** `meme.alwisp.com` + - **Scheme:** `http` + - **Forward Hostname / IP:** your Unraid LAN IP (e.g. `192.168.1.100`) + - **Forward Port:** `3000` + - Enable **Block Common Exploits** +3. On the **SSL** tab, request a Let's Encrypt certificate. +4. Make sure `PUBLIC_URL` in the container matches `https://meme.alwisp.com` exactly — this is what share links are built from. + +--- + +## Updating the Container + +### GUI + +Docker tab → click the container → **Force Update** (or remove and re-add with the same settings). + +### CLI + +```bash +# Pull or load the new image first, then: +docker stop memer +docker rm memer + +docker run -d \ + --name memer \ + --restart unless-stopped \ + -p 3000:3000 \ + -v /mnt/user/appdata/memer/images:/data/images \ + -v /mnt/user/appdata/memer/db:/data/db \ + -e PUBLIC_URL="https://meme.alwisp.com" \ + -e PORT="3000" \ + -e DATA_DIR="/data" \ + memer:latest +``` + +Data is safe — it lives in the host paths, not in the container. + +--- + +## Backup + +Everything is in two host directories: + +```bash +# Backup +tar -czf memer-backup-$(date +%F).tar.gz \ + /mnt/user/appdata/memer/images \ + /mnt/user/appdata/memer/db + +# Restore +tar -xzf memer-backup-.tar.gz -C / +``` + +The SQLite database file is `/mnt/user/appdata/memer/db/memer.db`. Image files are organized by upload month under `/mnt/user/appdata/memer/images/YYYY-MM/`. + +--- + +## Variable Reference + +| Variable | Default | Description | +|---|---|---| +| `PUBLIC_URL` | `http://localhost:3000` | External URL embedded in share links — must match your domain | +| `PORT` | `3000` | Port the Node server listens on inside the container | +| `DATA_DIR` | `/data` | Root path for images and DB inside the container — do not change unless remapping volumes | diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..b2533cf --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..3cd13c0 --- /dev/null +++ b/backend/src/db.ts @@ -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; diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..d9db72e --- /dev/null +++ b/backend/src/index.ts @@ -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); +} diff --git a/backend/src/routes/memes.ts b/backend/src/routes/memes.ts new file mode 100644 index 0000000..25f93f3 --- /dev/null +++ b/backend/src/routes/memes.ts @@ -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; + 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)); + } + ); +} diff --git a/backend/src/routes/tags.ts b/backend/src/routes/tags.ts new file mode 100644 index 0000000..dc0c74b --- /dev/null +++ b/backend/src/routes/tags.ts @@ -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 }; + }); +} diff --git a/backend/src/services/image.ts b/backend/src/services/image.ts new file mode 100644 index 0000000..b86bd37 --- /dev/null +++ b/backend/src/services/image.ts @@ -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 { + const abs = absolutePath(filePath); + const meta = await sharp(abs).metadata(); + const stat = fs.statSync(abs); + + const mimeMap: Record = { + 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 { + 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 { + 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 = { + 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, + }; +} diff --git a/backend/src/services/storage.ts b/backend/src/services/storage.ts new file mode 100644 index 0000000..5477d16 --- /dev/null +++ b/backend/src/services/storage.ts @@ -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 = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', + }; + return map[mimeType] ?? 'jpg'; +} diff --git a/backend/src/types.ts b/backend/src/types.ts new file mode 100644 index 0000000..6dcd0b2 --- /dev/null +++ b/backend/src/types.ts @@ -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; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..be4ca73 --- /dev/null +++ b/backend/tsconfig.json @@ -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"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..48abf54 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + memer: + build: . + image: memer:latest + container_name: memer + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - memer-images:/data/images + - memer-db:/data/db + environment: + PORT: "3000" + DATA_DIR: /data + PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000} + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/tags"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + +volumes: + memer-images: + memer-db: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f0b71df --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + Memer + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..55636f3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "memer-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.28.6", + "lucide-react": "^0.370.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.22.3" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3", + "typescript": "^5.4.5", + "vite": "^5.2.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..40025f1 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,11 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { Gallery } from './pages/Gallery'; + +export default function App() { + return ( + + } /> + } /> + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..ec1d80e --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,101 @@ +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[]; + children?: Meme[]; +} + +export interface Tag { + id: number; + name: string; + meme_count: number; +} + +export interface MemesResponse { + memes: Meme[]; + total: number; + page: number; + limit: number; +} + +export interface ListParams { + tag?: string; + q?: string; + page?: number; + limit?: number; + parent_only?: boolean; +} + +async function apiFetch(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error ?? res.statusText); + } + return res.json() as Promise; +} + +export const api = { + memes: { + list(params: ListParams = {}): Promise { + const qs = new URLSearchParams(); + if (params.tag) qs.set('tag', params.tag); + if (params.q) qs.set('q', params.q); + 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)); + return apiFetch(`/api/memes?${qs}`); + }, + + get(id: string): Promise { + return apiFetch(`/api/memes/${id}`); + }, + + upload(formData: FormData): Promise { + return apiFetch('/api/memes', { method: 'POST', body: formData }); + }, + + update(id: string, body: { title?: string; description?: string; tags?: string[] }): Promise { + return apiFetch(`/api/memes/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + }, + + delete(id: string): Promise<{ ok: boolean }> { + return apiFetch(`/api/memes/${id}`, { method: 'DELETE' }); + }, + + rescale(id: string, body: { width?: number; height?: number; quality?: number; label?: string }): Promise { + return apiFetch(`/api/memes/${id}/rescale`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + }, + }, + + tags: { + list(): Promise { + return apiFetch('/api/tags'); + }, + + delete(id: number): Promise<{ ok: boolean }> { + return apiFetch(`/api/tags/${id}`, { method: 'DELETE' }); + }, + }, + + imageUrl(filePath: string): string { + return `/images/${filePath}`; + }, +}; diff --git a/frontend/src/components/GalleryGrid.tsx b/frontend/src/components/GalleryGrid.tsx new file mode 100644 index 0000000..9231543 --- /dev/null +++ b/frontend/src/components/GalleryGrid.tsx @@ -0,0 +1,31 @@ +import type { Meme } from '../api/client'; +import { MemeCard } from './MemeCard'; + +interface Props { + memes: Meme[]; + onOpen: (meme: Meme) => void; + onShare: (meme: Meme) => void; +} + +export function GalleryGrid({ memes, onOpen, onShare }: Props) { + if (memes.length === 0) { + return ( +
+
🎭
+

No memes yet

+

Upload your first meme to get started.

+
+ ); + } + + return ( +
+ {memes.map((meme) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/MemeCard.tsx b/frontend/src/components/MemeCard.tsx new file mode 100644 index 0000000..5bdd218 --- /dev/null +++ b/frontend/src/components/MemeCard.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { Share2, Eye, Layers } from 'lucide-react'; +import type { Meme } from '../api/client'; +import { api } from '../api/client'; + +interface Props { + meme: Meme; + onOpen: (meme: Meme) => void; + onShare: (meme: Meme) => void; +} + +export function MemeCard({ meme, onOpen, onShare }: Props) { + const [loaded, setLoaded] = useState(false); + const [hovered, setHovered] = useState(false); + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onClick={() => onOpen(meme)} + > + {/* Image */} + {meme.title} setLoaded(true)} + className={`w-full block transition-all duration-500 ${ + loaded ? 'opacity-100' : 'opacity-0' + } ${hovered ? 'scale-[1.02]' : 'scale-100'} transition-transform duration-300`} + /> + + {/* Skeleton while loading */} + {!loaded && ( +
+ )} + + {/* Hover overlay */} +
+
+

{meme.title}

+ + {/* Tags */} + {meme.tags.length > 0 && ( +
+ {meme.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {meme.tags.length > 3 && ( + +{meme.tags.length - 3} + )} +
+ )} + +
+ + +
+
+
+ + {/* Child indicator */} + {/* (shown from parent detail) — not needed on card itself */} + + {/* Dimensions badge */} +
+ + {meme.width}×{meme.height} + +
+
+ ); +} diff --git a/frontend/src/components/MemeDetail.tsx b/frontend/src/components/MemeDetail.tsx new file mode 100644 index 0000000..7961c1a --- /dev/null +++ b/frontend/src/components/MemeDetail.tsx @@ -0,0 +1,278 @@ +import { useState } from 'react'; +import { X, Minimize2, Trash2, Edit2, Check, Layers } from 'lucide-react'; +import { useMeme, useDeleteMeme, useUpdateMeme } from '../hooks/useMemes'; +import { SharePanel } from './SharePanel'; +import { RescaleModal } from './RescaleModal'; +import { api, type Meme } from '../api/client'; + +interface Props { + memeId: string; + onClose: () => void; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { + year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', + }); +} + +export function MemeDetail({ memeId, onClose }: Props) { + const { data, isLoading, refetch } = useMeme(memeId); + const deleteMeme = useDeleteMeme(); + const updateMeme = useUpdateMeme(); + + const [editing, setEditing] = useState(false); + const [editTitle, setEditTitle] = useState(''); + const [editDesc, setEditDesc] = useState(''); + const [editTags, setEditTags] = useState(''); + const [showRescale, setShowRescale] = useState(false); + const [activeChild, setActiveChild] = useState(null); + + const meme = data; + const displayMeme = activeChild ?? meme; + + function startEdit() { + if (!meme) return; + setEditTitle(meme.title); + setEditDesc(meme.description ?? ''); + setEditTags(meme.tags.join(', ')); + setEditing(true); + } + + async function saveEdit() { + if (!meme) return; + await updateMeme.mutateAsync({ + id: meme.id, + title: editTitle, + description: editDesc || undefined, + tags: editTags.split(',').map((t) => t.trim()).filter(Boolean), + }); + setEditing(false); + } + + async function handleDelete() { + if (!meme) return; + if (!confirm(`Delete "${meme.title}"? This also removes all rescaled copies.`)) return; + await deleteMeme.mutateAsync(meme.id); + onClose(); + } + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!meme) return null; + + return ( + <> +
+ +
+ {/* Header */} +
+ {editing ? ( + setEditTitle(e.target.value)} + className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm font-semibold focus:outline-none focus:border-accent mr-3" + /> + ) : ( +

{meme.title}

+ )} +
+ {editing ? ( + + ) : ( + + )} + {!meme.parent_id && ( + + )} + + +
+
+ + {/* Body */} +
+ {/* Image panel */} +
+ {displayMeme && ( + {displayMeme.title} + )} +
+ + {/* Sidebar */} +
+
+ + {/* Share */} + {displayMeme && ( +
+

Share

+ +
+ )} + + {/* Description */} +
+

Description

+ {editing ? ( +