build 1
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
backend/public
|
||||
**/.env
|
||||
**/*.log
|
||||
.git
|
||||
.gitignore
|
||||
8
.env.example
Normal file
8
.env.example
Normal file
@@ -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
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
backend/public/
|
||||
data/
|
||||
.env
|
||||
*.log
|
||||
50
Dockerfile
Normal file
50
Dockerfile
Normal file
@@ -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"]
|
||||
103
README.md
103
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.
|
||||
|
||||
208
UNRAID.md
Normal file
208
UNRAID.md
Normal file
@@ -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@<unraid-ip>:/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://<unraid-ip>: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-<date>.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 |
|
||||
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"]
|
||||
}
|
||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -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:
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Memer</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎭</text></svg>" />
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100 min-h-screen">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
11
frontend/src/App.tsx
Normal file
11
frontend/src/App.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Gallery } from './pages/Gallery';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Gallery />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
101
frontend/src/api/client.ts
Normal file
101
frontend/src/api/client.ts
Normal file
@@ -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<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
memes: {
|
||||
list(params: ListParams = {}): Promise<MemesResponse> {
|
||||
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<MemesResponse>(`/api/memes?${qs}`);
|
||||
},
|
||||
|
||||
get(id: string): Promise<Meme & { children: Meme[] }> {
|
||||
return apiFetch(`/api/memes/${id}`);
|
||||
},
|
||||
|
||||
upload(formData: FormData): Promise<Meme> {
|
||||
return apiFetch('/api/memes', { method: 'POST', body: formData });
|
||||
},
|
||||
|
||||
update(id: string, body: { title?: string; description?: string; tags?: string[] }): Promise<Meme> {
|
||||
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<Meme> {
|
||||
return apiFetch(`/api/memes/${id}/rescale`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
tags: {
|
||||
list(): Promise<Tag[]> {
|
||||
return apiFetch('/api/tags');
|
||||
},
|
||||
|
||||
delete(id: number): Promise<{ ok: boolean }> {
|
||||
return apiFetch(`/api/tags/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
},
|
||||
|
||||
imageUrl(filePath: string): string {
|
||||
return `/images/${filePath}`;
|
||||
},
|
||||
};
|
||||
31
frontend/src/components/GalleryGrid.tsx
Normal file
31
frontend/src/components/GalleryGrid.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center py-32 text-center">
|
||||
<div className="text-6xl mb-4">🎭</div>
|
||||
<h2 className="text-xl font-semibold text-zinc-400 mb-2">No memes yet</h2>
|
||||
<p className="text-zinc-600 text-sm">Upload your first meme to get started.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="columns-1 sm:columns-2 lg:columns-3 xl:columns-4 gap-3"
|
||||
style={{ columnFill: 'balance' }}
|
||||
>
|
||||
{memes.map((meme) => (
|
||||
<MemeCard key={meme.id} meme={meme} onOpen={onOpen} onShare={onShare} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
frontend/src/components/MemeCard.tsx
Normal file
104
frontend/src/components/MemeCard.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className="relative group break-inside-avoid mb-3 rounded-xl overflow-hidden bg-zinc-900 cursor-pointer shadow-lg hover:shadow-accent/20 hover:shadow-xl transition-shadow duration-300"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={() => onOpen(meme)}
|
||||
>
|
||||
{/* Image */}
|
||||
<img
|
||||
src={api.imageUrl(meme.file_path)}
|
||||
alt={meme.title}
|
||||
loading="lazy"
|
||||
onLoad={() => 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 && (
|
||||
<div
|
||||
className="absolute inset-0 bg-zinc-800 animate-pulse"
|
||||
style={{ paddingTop: `${(meme.height / meme.width) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-t from-zinc-950/90 via-zinc-950/40 to-transparent transition-opacity duration-200 ${
|
||||
hovered ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
<p className="text-sm font-semibold text-white truncate mb-2">{meme.title}</p>
|
||||
|
||||
{/* Tags */}
|
||||
{meme.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{meme.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs px-1.5 py-0.5 rounded bg-accent/30 text-purple-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{meme.tags.length > 3 && (
|
||||
<span className="text-xs text-zinc-400">+{meme.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen(meme);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<Eye size={12} />
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShare(meme);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg bg-accent/30 hover:bg-accent/50 text-purple-200 transition-colors"
|
||||
>
|
||||
<Share2 size={12} />
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Child indicator */}
|
||||
{/* (shown from parent detail) — not needed on card itself */}
|
||||
|
||||
{/* Dimensions badge */}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-zinc-400">
|
||||
{meme.width}×{meme.height}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
278
frontend/src/components/MemeDetail.tsx
Normal file
278
frontend/src/components/MemeDetail.tsx
Normal file
@@ -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<Meme | null>(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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
|
||||
<div className="w-10 h-10 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!meme) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/80 animate-fade-in"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className="fixed inset-4 md:inset-8 z-50 flex flex-col bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-800 overflow-hidden animate-scale-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800 flex-shrink-0">
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editTitle}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<h2 className="text-lg font-semibold truncate flex-1 mr-3">{meme.title}</h2>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{editing ? (
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
disabled={updateMeme.isPending}
|
||||
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-accent hover:bg-accent-hover text-white transition-colors"
|
||||
>
|
||||
<Check size={14} /> Save
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={startEdit}
|
||||
className="text-zinc-500 hover:text-zinc-300 transition-colors p-1"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
{!meme.parent_id && (
|
||||
<button
|
||||
onClick={() => setShowRescale(true)}
|
||||
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors"
|
||||
title="Create rescaled copy"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
<span className="hidden sm:inline">Rescale</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMeme.isPending}
|
||||
className="text-zinc-500 hover:text-red-400 transition-colors p-1"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 transition-colors p-1">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col md:flex-row flex-1 overflow-hidden">
|
||||
{/* Image panel */}
|
||||
<div className="flex-1 flex items-center justify-center bg-zinc-950 p-4 overflow-hidden">
|
||||
{displayMeme && (
|
||||
<img
|
||||
key={displayMeme.id}
|
||||
src={api.imageUrl(displayMeme.file_path)}
|
||||
alt={displayMeme.title}
|
||||
className="max-w-full max-h-full object-contain rounded-lg animate-fade-in"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="md:w-80 border-t md:border-t-0 md:border-l border-zinc-800 flex flex-col overflow-y-auto">
|
||||
<div className="p-5 space-y-5 flex-1">
|
||||
|
||||
{/* Share */}
|
||||
{displayMeme && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Share</h3>
|
||||
<SharePanel meme={displayMeme} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Description</h3>
|
||||
{editing ? (
|
||||
<textarea
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Add a description…"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent resize-none"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-400">{meme.description ?? 'No description.'}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Tags */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Tags</h3>
|
||||
{editing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editTags}
|
||||
onChange={(e) => setEditTags(e.target.value)}
|
||||
placeholder="funny, reaction, wholesome"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{meme.tags.length > 0
|
||||
? meme.tags.map((t) => (
|
||||
<span key={t} className="text-xs px-2 py-0.5 rounded-full bg-accent/20 text-purple-300">
|
||||
{t}
|
||||
</span>
|
||||
))
|
||||
: <span className="text-sm text-zinc-600">No tags</span>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Metadata */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Info</h3>
|
||||
<dl className="space-y-1.5 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-zinc-500">Dimensions</dt>
|
||||
<dd className="text-zinc-300">{meme.width} × {meme.height}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-zinc-500">Size</dt>
|
||||
<dd className="text-zinc-300">{formatBytes(meme.file_size)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-zinc-500">Type</dt>
|
||||
<dd className="text-zinc-300">{meme.mime_type.replace('image/', '')}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-zinc-500">Uploaded</dt>
|
||||
<dd className="text-zinc-300 text-right text-xs">{formatDate(meme.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Rescaled variants */}
|
||||
{meme.children && meme.children.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<Layers size={12} /> Rescaled Copies ({meme.children.length})
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
<button
|
||||
onClick={() => setActiveChild(null)}
|
||||
className={`w-full text-left text-xs px-3 py-2 rounded-lg transition-colors ${
|
||||
activeChild === null
|
||||
? 'bg-accent/20 text-purple-300'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
Original — {meme.width}×{meme.height} ({formatBytes(meme.file_size)})
|
||||
</button>
|
||||
{meme.children.map((child) => (
|
||||
<button
|
||||
key={child.id}
|
||||
onClick={() => setActiveChild(activeChild?.id === child.id ? null : child)}
|
||||
className={`w-full text-left text-xs px-3 py-2 rounded-lg transition-colors ${
|
||||
activeChild?.id === child.id
|
||||
? 'bg-accent/20 text-purple-300'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{child.title.replace(meme.title, '').trim() || child.title} — {child.width}×{child.height} ({formatBytes(child.file_size)})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRescale && (
|
||||
<RescaleModal
|
||||
meme={meme}
|
||||
onClose={() => setShowRescale(false)}
|
||||
onDone={() => {
|
||||
setShowRescale(false);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
131
frontend/src/components/RescaleModal.tsx
Normal file
131
frontend/src/components/RescaleModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import { X, Minimize2 } from 'lucide-react';
|
||||
import type { Meme } from '../api/client';
|
||||
import { useRescaleMeme } from '../hooks/useMemes';
|
||||
|
||||
interface Props {
|
||||
meme: Meme;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function RescaleModal({ meme, onClose, onDone }: Props) {
|
||||
const [width, setWidth] = useState('');
|
||||
const [height, setHeight] = useState('');
|
||||
const [quality, setQuality] = useState('85');
|
||||
const [label, setLabel] = useState('');
|
||||
const rescale = useRescaleMeme();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!width && !height) return;
|
||||
|
||||
await rescale.mutateAsync({
|
||||
id: meme.id,
|
||||
width: width ? Number(width) : undefined,
|
||||
height: height ? Number(height) : undefined,
|
||||
quality: Number(quality),
|
||||
label: label || undefined,
|
||||
});
|
||||
onDone();
|
||||
}
|
||||
|
||||
const isGif = meme.mime_type === 'image/gif';
|
||||
|
||||
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-md 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">
|
||||
<Minimize2 size={18} className="text-accent" />
|
||||
<h2 className="text-lg font-semibold">Rescale Meme</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
<p className="text-sm text-zinc-400">
|
||||
Creates a new derived image. Original ({meme.width}×{meme.height}) is never modified.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Width (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
placeholder={String(meme.width)}
|
||||
min={1}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Height (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
placeholder={String(meme.height)}
|
||||
min={1}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-zinc-500 -mt-1">
|
||||
Aspect ratio is preserved automatically (fit: inside).
|
||||
</p>
|
||||
|
||||
{!isGif && (
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Quality ({quality}%)</label>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={100}
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(e.target.value)}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Label (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={`e.g. "thumb" or "${width || meme.width}w"`}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{rescale.error && (
|
||||
<p className="text-red-400 text-sm">{(rescale.error as Error).message}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={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={(!width && !height) || rescale.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"
|
||||
>
|
||||
{rescale.isPending ? 'Rescaling…' : 'Create Rescaled Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/SharePanel.tsx
Normal file
69
frontend/src/components/SharePanel.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check, Send, MessageSquare, Download } from 'lucide-react';
|
||||
import type { Meme } from '../api/client';
|
||||
import { api } from '../api/client';
|
||||
|
||||
interface Props {
|
||||
meme: Meme;
|
||||
}
|
||||
|
||||
export function SharePanel({ meme }: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const imageUrl = `${window.location.origin}${api.imageUrl(meme.file_path)}`;
|
||||
|
||||
async function copyLink() {
|
||||
await navigator.clipboard.writeText(imageUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(imageUrl)}&text=${encodeURIComponent(meme.title)}`;
|
||||
const smsUrl = `sms:?body=${encodeURIComponent(imageUrl)}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm font-medium transition-colors"
|
||||
title="Copy image link"
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={14} className="text-green-400" />
|
||||
) : (
|
||||
<Copy size={14} className="text-zinc-400" />
|
||||
)}
|
||||
{copied ? 'Copied!' : 'Copy link'}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={telegramUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#229ED9]/20 hover:bg-[#229ED9]/30 text-[#229ED9] text-sm font-medium transition-colors"
|
||||
title="Share on Telegram"
|
||||
>
|
||||
<Send size={14} />
|
||||
Telegram
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={smsUrl}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-green-900/30 hover:bg-green-900/50 text-green-400 text-sm font-medium transition-colors"
|
||||
title="Share via SMS"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
SMS
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={api.imageUrl(meme.file_path)}
|
||||
download={meme.file_name}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm font-medium transition-colors"
|
||||
title="Download original"
|
||||
>
|
||||
<Download size={14} />
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/TagFilter.tsx
Normal file
42
frontend/src/components/TagFilter.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { X } from 'lucide-react';
|
||||
import type { Tag } from '../api/client';
|
||||
|
||||
interface Props {
|
||||
tags: Tag[];
|
||||
activeTag: string | null;
|
||||
onSelect: (tag: string | null) => void;
|
||||
}
|
||||
|
||||
export function TagFilter({ tags, activeTag, onSelect }: Props) {
|
||||
if (tags.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<button
|
||||
onClick={() => onSelect(null)}
|
||||
className={`text-sm px-3 py-1 rounded-full border transition-colors ${
|
||||
activeTag === null
|
||||
? 'bg-accent text-white border-accent'
|
||||
: 'border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => onSelect(activeTag === tag.name ? null : tag.name)}
|
||||
className={`flex items-center gap-1 text-sm px-3 py-1 rounded-full border transition-colors ${
|
||||
activeTag === tag.name
|
||||
? 'bg-accent text-white border-accent'
|
||||
: 'border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
<span className="text-xs opacity-60">({tag.meme_count})</span>
|
||||
{activeTag === tag.name && <X size={12} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
frontend/src/components/UploadModal.tsx
Normal file
174
frontend/src/components/UploadModal.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { X, Upload, ImagePlus } from 'lucide-react';
|
||||
import { useUploadMeme } from '../hooks/useMemes';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ALLOWED = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
export function UploadModal({ onClose }: Props) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const upload = useUploadMeme();
|
||||
|
||||
const addFiles = useCallback((incoming: FileList | File[]) => {
|
||||
const valid = Array.from(incoming).filter((f) => ALLOWED.includes(f.type));
|
||||
setFiles((prev) => [...prev, ...valid]);
|
||||
}, []);
|
||||
|
||||
function onDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
addFiles(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
function removeFile(idx: number) {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (files.length === 0) return;
|
||||
|
||||
for (const file of files) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('title', title || file.name.replace(/\.[^.]+$/, ''));
|
||||
if (description) fd.append('description', description);
|
||||
if (tags) fd.append('tags', tags);
|
||||
await upload.mutateAsync(fd);
|
||||
}
|
||||
|
||||
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-lg border border-zinc-800 animate-scale-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImagePlus size={18} className="text-accent" />
|
||||
<h2 className="text-lg font-semibold">Upload Memes</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||||
dragging
|
||||
? 'border-accent bg-accent/10'
|
||||
: 'border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800/50'
|
||||
}`}
|
||||
>
|
||||
<Upload size={28} className="mx-auto mb-2 text-zinc-500" />
|
||||
<p className="text-sm text-zinc-400">
|
||||
Drag & drop images here, or{' '}
|
||||
<span className="text-accent">browse files</span>
|
||||
</p>
|
||||
<p className="text-xs text-zinc-600 mt-1">JPG, PNG, GIF, WebP — max 100 MB each</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && addFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected files */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{files.map((f, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm px-3 py-1.5 rounded-lg bg-zinc-800">
|
||||
<span className="truncate text-zinc-300">{f.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(i)}
|
||||
className="ml-2 text-zinc-500 hover:text-red-400 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">
|
||||
Title {files.length > 1 && <span className="text-zinc-600">(applied to all)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={files[0]?.name.replace(/\.[^.]+$/, '') ?? 'Untitled'}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Description (optional)</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="What's this meme about?"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Tags (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="funny, reaction, wholesome"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{upload.error && (
|
||||
<p className="text-red-400 text-sm">{(upload.error as Error).message}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={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={files.length === 0 || upload.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"
|
||||
>
|
||||
{upload.isPending
|
||||
? 'Uploading…'
|
||||
: `Upload ${files.length > 0 ? files.length : ''} Meme${files.length !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/hooks/useMemes.ts
Normal file
78
frontend/src/hooks/useMemes.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ListParams } from '../api/client';
|
||||
|
||||
export function useMemes(params: ListParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['memes', params],
|
||||
queryFn: () => api.memes.list(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMeme(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['meme', id],
|
||||
queryFn: () => api.memes.get(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTags() {
|
||||
return useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: () => api.tags.list(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadMeme() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (formData: FormData) => api.memes.upload(formData),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['memes'] });
|
||||
qc.invalidateQueries({ queryKey: ['tags'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMeme() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: { id: string; title?: string; description?: string; tags?: string[] }) =>
|
||||
api.memes.update(id, body),
|
||||
onSuccess: (_, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ['memes'] });
|
||||
qc.invalidateQueries({ queryKey: ['meme', vars.id] });
|
||||
qc.invalidateQueries({ queryKey: ['tags'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMeme() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.memes.delete(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['memes'] });
|
||||
qc.invalidateQueries({ queryKey: ['tags'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRescaleMeme() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
...body
|
||||
}: {
|
||||
id: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
quality?: number;
|
||||
label?: string;
|
||||
}) => api.memes.rescale(id, body),
|
||||
onSuccess: (_, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ['meme', vars.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
24
frontend/src/index.css
Normal file
24
frontend/src/index.css
Normal file
@@ -0,0 +1,24 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
}
|
||||
25
frontend/src/main.tsx
Normal file
25
frontend/src/main.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
182
frontend/src/pages/Gallery.tsx
Normal file
182
frontend/src/pages/Gallery.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Search, Upload as UploadIcon, X, Share2 } from 'lucide-react';
|
||||
import { useMemes, useTags } from '../hooks/useMemes';
|
||||
import { GalleryGrid } from '../components/GalleryGrid';
|
||||
import { MemeDetail } from '../components/MemeDetail';
|
||||
import { UploadModal } from '../components/UploadModal';
|
||||
import { SharePanel } from '../components/SharePanel';
|
||||
import type { Meme } from '../api/client';
|
||||
|
||||
export function Gallery() {
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [selectedMemeId, setSelectedMemeId] = useState<string | null>(null);
|
||||
const [quickShareMeme, setQuickShareMeme] = useState<Meme | null>(null);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
// Debounce search
|
||||
const [searchTimer, setSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
function handleSearchChange(val: string) {
|
||||
setSearch(val);
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
const t = setTimeout(() => setDebouncedSearch(val), 300);
|
||||
setSearchTimer(t);
|
||||
}
|
||||
|
||||
const { data, isLoading, isError } = useMemes({
|
||||
tag: activeTag ?? undefined,
|
||||
q: debouncedSearch || undefined,
|
||||
parent_only: true,
|
||||
});
|
||||
|
||||
const { data: tags } = useTags();
|
||||
|
||||
const handleOpen = useCallback((meme: Meme) => {
|
||||
setSelectedMemeId(meme.id);
|
||||
}, []);
|
||||
|
||||
const handleShare = useCallback((meme: Meme) => {
|
||||
setQuickShareMeme(meme);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
{/* Topbar */}
|
||||
<header className="sticky top-0 z-30 bg-zinc-950/80 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">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 mr-2 flex-shrink-0">
|
||||
<span className="text-2xl">🎭</span>
|
||||
<span className="font-bold text-lg tracking-tight hidden sm:block">Memer</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative max-w-md">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Search memes…"
|
||||
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 && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setDebouncedSearch(''); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
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"
|
||||
>
|
||||
<UploadIcon size={15} />
|
||||
<span className="hidden sm:inline">Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag filter strip */}
|
||||
{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">
|
||||
<button
|
||||
onClick={() => setActiveTag(null)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full border whitespace-nowrap transition-colors flex-shrink-0 ${
|
||||
activeTag === null
|
||||
? 'bg-accent text-white border-accent'
|
||||
: 'border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => setActiveTag(activeTag === tag.name ? null : tag.name)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full border whitespace-nowrap transition-colors flex-shrink-0 ${
|
||||
activeTag === tag.name
|
||||
? 'bg-accent text-white border-accent'
|
||||
: 'border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{tag.name} <span className="opacity-50">({tag.meme_count})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Gallery */}
|
||||
<main className="max-w-screen-2xl mx-auto px-4 py-6">
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-zinc-500">
|
||||
{isLoading
|
||||
? 'Loading…'
|
||||
: isError
|
||||
? 'Failed to load'
|
||||
: `${data?.total ?? 0} meme${data?.total !== 1 ? 's' : ''}${activeTag ? ` tagged "${activeTag}"` : ''}${debouncedSearch ? ` matching "${debouncedSearch}"` : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="columns-1 sm:columns-2 lg:columns-3 xl:columns-4 gap-3">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="break-inside-avoid mb-3 rounded-xl bg-zinc-900 animate-pulse"
|
||||
style={{ height: `${120 + Math.random() * 200}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="text-center py-32 text-red-400">Failed to load memes.</div>
|
||||
) : (
|
||||
<GalleryGrid
|
||||
memes={data?.memes ?? []}
|
||||
onOpen={handleOpen}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Meme detail modal */}
|
||||
{selectedMemeId && (
|
||||
<MemeDetail
|
||||
memeId={selectedMemeId}
|
||||
onClose={() => setSelectedMemeId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quick share popover */}
|
||||
{quickShareMeme && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/60 animate-fade-in">
|
||||
<div className="bg-zinc-900 rounded-2xl border border-zinc-800 p-5 w-full max-w-sm shadow-2xl animate-scale-in">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Share2 size={16} className="text-accent" />
|
||||
<span className="font-semibold text-sm truncate">{quickShareMeme.title}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setQuickShareMeme(null)}
|
||||
className="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<SharePanel meme={quickShareMeme} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload modal */}
|
||||
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/tailwind.config.ts
Normal file
32
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
accent: {
|
||||
DEFAULT: '#a855f7',
|
||||
hover: '#9333ea',
|
||||
muted: '#7c3aed33',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.2s ease-out',
|
||||
'scale-in': 'scaleIn 0.15s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
from: { opacity: '0' },
|
||||
to: { opacity: '1' },
|
||||
},
|
||||
scaleIn: {
|
||||
from: { opacity: '0', transform: 'scale(0.95)' },
|
||||
to: { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
'/images': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../backend/public',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user