Files

833 lines
28 KiB
Markdown

# Marketing Command Center — Frontend Architecture
**What you want:** A self-hosted web dashboard where you log in, see everything your agents produced, cherry-pick ads to push to Postiz, and drop into a Claude session to give feedback — all from the browser. Everything runs in Docker. No external services except Postiz and Claude API.
---
## System Overview
```
┌─────────────────────────────────────────────────────────┐
│ YOUR BROWSER │
│ │
│ ┌──────────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Campaign │ │ Asset │ │ Claude Chat │ │
│ │ Dashboard │ │ Gallery │ │ (feedback/edits) │ │
│ └──────┬───────┘ └────┬─────┘ └────────┬──────────┘ │
└─────────┼───────────────┼─────────────────┼──────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────┐
│ NEXT.JS APP (Docker Container) │
│ │
│ API Routes: │
│ /api/campaigns /api/assets /api/claude │
│ /api/publish /api/agents /api/auth │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │
│ │ SQLite │ │ Local │ │ Claude Agent SDK (TS) │ │
│ │ Database │ │ File │ │ Spawns Claude Code │ │
│ │ (Prisma) │ │ Storage │ │ as subprocess │ │
│ └──────────┘ └──────────┘ └───────────┬───────────┘ │
└──────────────────────────────────────────┼───────────────┘
┌──────────────────────┼──────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌────────┐
│ Postiz │ │ Tavily │ │Nextdoor│
│ (Docker) │ │ API │ │ API │
│ IG+TikTok│ │ Research │ │ Direct │
│ +Media │ └──────────┘ └────────┘
│ Hosting │
└──────────┘
```
**Zero external services for core functionality.** Postiz and your Next.js app both run in Docker on your machine. The only outbound calls are to Tavily (research), Nextdoor API (publishing), and Anthropic (Claude).
---
## Tech Stack
| Layer | Technology | Why |
|-------|-----------|-----|
| **Framework** | Next.js 15+ (App Router) | Full-stack React, API routes, SSR |
| **UI** | shadcn/ui + Tailwind CSS | Clean components, fast to build |
| **Auth** | NextAuth.js (Auth.js v5) | Self-contained — credentials provider, no external auth service |
| **Database** | SQLite via Prisma | Zero infrastructure — single file, runs in Docker volume |
| **File Storage** | Local filesystem (Docker volume) | Pipeline outputs stored locally, Postiz handles public hosting |
| **Claude** | Claude Agent SDK (TypeScript) | Spawn Claude sessions, stream responses |
| **Publishing** | Postiz (@postiz/node SDK) | Media upload + hosting + scheduling + IG/TikTok publishing |
| **Realtime** | Server-Sent Events (SSE) | Pipeline progress streaming — no Redis/WebSocket service needed |
| **Deployment** | Docker Compose | Everything self-hosted, single `docker compose up` |
---
## Database Schema (SQLite via Prisma)
```prisma
// prisma/schema.prisma
datasource db {
provider = "sqlite"
url = "file:./data/marketing.db"
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
email String @unique
password String // bcrypt hashed
name String?
createdAt DateTime @default(now())
}
model Campaign {
id String @id @default(cuid())
name String
status String @default("draft") // draft, running, review, approved, published
prompt String? // the trigger prompt
platforms String // JSON array: ["instagram","tiktok","nextdoor"]
config String? // JSON: full campaign config from form
outputPath String? // local path to outputs/
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agentRuns AgentRun[]
assets Asset[]
claudeSessions ClaudeSession[]
}
model AgentRun {
id String @id @default(cuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id])
agentName String // trend-scout, research, script-writer, etc.
status String @default("pending") // pending, running, completed, failed
startedAt DateTime?
completedAt DateTime?
durationMs Int?
outputSummary String?
outputPath String?
tokenUsage Int?
error String?
assets Asset[]
createdAt DateTime @default(now())
}
model Asset {
id String @id @default(cuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id])
agentRunId String?
agentRun AgentRun? @relation(fields: [agentRunId], references: [id])
type String // image, video, copy, research, script
platform String? // instagram, tiktok, nextdoor, all
format String? // png, mp4, json, txt, html, md
filePath String // local path in outputs/
fileName String
dimensions String? // 1080x1080, 1080x1920, etc.
metadata String? // JSON: caption, hashtags, hook text, scene plan
status String @default("draft") // draft, approved, rejected, published
publishedTo String? // JSON array of platforms published to
postizPostId String? // ID from Postiz after scheduling
postizMediaId String? // media ID from Postiz upload
createdAt DateTime @default(now())
}
model ClaudeSession {
id String @id @default(cuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id])
sessionId String? // Claude Code session ID for --resume
messages String? // JSON array of conversation history
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model TrendReport {
id String @id @default(cuid())
name String
filePath String // path to the HTML/JSON report
summary String? // brief description
createdAt DateTime @default(now())
}
```
**Why SQLite over Postgres:**
- Zero config — no separate database container
- Single file — easy to backup (just copy `marketing.db`)
- Fast enough for a single-user dashboard
- Runs anywhere Docker runs
- Prisma makes it trivial to switch to Postgres later if needed
---
## Authentication (NextAuth.js — Self-Contained)
```typescript
// lib/auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import bcrypt from "bcryptjs";
import { prisma } from "./prisma";
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user) return null;
const valid = await bcrypt.compare(
credentials.password as string,
user.password
);
return valid ? user : null;
},
}),
],
session: { strategy: "jwt" },
secret: process.env.NEXTAUTH_SECRET,
});
```
**First-run setup:** A seed script creates your admin user. No external auth provider needed.
```typescript
// prisma/seed.ts
import bcrypt from "bcryptjs";
import { prisma } from "../lib/prisma";
async function main() {
await prisma.user.upsert({
where: { email: process.env.ADMIN_EMAIL! },
update: {},
create: {
email: process.env.ADMIN_EMAIL!,
password: await bcrypt.hash(process.env.ADMIN_PASSWORD!, 12),
name: "Admin",
},
});
}
main();
```
---
## Real-Time Pipeline Progress (Server-Sent Events)
No Redis, no WebSocket server, no Supabase Realtime. Just native SSE from a Next.js API route:
```typescript
// app/api/campaigns/[id]/stream/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// Subscribe to pipeline events for this campaign
const unsubscribe = pipelineEvents.subscribe(params.id, (event) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
);
});
// Keep connection alive
const keepAlive = setInterval(() => {
controller.enqueue(encoder.encode(`: keepalive\n\n`));
}, 15000);
req.signal.addEventListener("abort", () => {
unsubscribe();
clearInterval(keepAlive);
controller.close();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
```
Frontend hook:
```typescript
// hooks/usePipelineProgress.ts
export function usePipelineProgress(campaignId: string) {
const [agents, setAgents] = useState<AgentRun[]>([]);
useEffect(() => {
const source = new EventSource(`/api/campaigns/${campaignId}/stream`);
source.onmessage = (event) => {
const data = JSON.parse(event.data);
setAgents((prev) => updateAgentStatus(prev, data));
};
return () => source.close();
}, [campaignId]);
return agents;
}
```
---
## Claude Integration
```typescript
// lib/claude.ts
import { spawn } from "child_process";
import { EventEmitter } from "events";
export const pipelineEvents = new EventEmitter();
export async function launchPipeline(campaignId: string, prompt: string, cwd: string) {
// Update campaign status
await prisma.campaign.update({
where: { id: campaignId },
data: { status: "running" },
});
const claude = spawn("claude", [
"-p", prompt,
"--output-format", "stream-json",
"--verbose",
"--allowedTools", "Read,Edit,Write,Bash,Grep,Glob",
], { cwd, env: { ...process.env } });
claude.stdout.on("data", async (chunk) => {
const lines = chunk.toString().split("\n").filter(Boolean);
for (const line of lines) {
try {
const event = JSON.parse(line);
// Detect agent starts/completions from tool use events
const agentEvent = parseAgentEvent(event);
if (agentEvent) {
// Save to database
await upsertAgentRun(campaignId, agentEvent);
// Broadcast to SSE clients
pipelineEvents.emit(campaignId, agentEvent);
}
// Detect new files created
const fileEvent = parseFileCreatedEvent(event);
if (fileEvent) {
await createAsset(campaignId, fileEvent);
pipelineEvents.emit(campaignId, { type: "asset_created", ...fileEvent });
}
} catch {}
}
});
claude.on("close", async (code) => {
const status = code === 0 ? "review" : "failed";
await prisma.campaign.update({
where: { id: campaignId },
data: { status },
});
pipelineEvents.emit(campaignId, { type: "pipeline_complete", status });
});
}
// Claude chat session for feedback
export async function sendChatMessage(
sessionId: string | null,
message: string,
cwd: string
): AsyncGenerator<string> {
const args = ["-p", message, "--output-format", "stream-json", "--verbose"];
if (sessionId) args.push("--resume", sessionId);
args.push("--allowedTools", "Read,Edit,Write,Bash,Grep,Glob");
const claude = spawn("claude", args, { cwd, env: { ...process.env } });
// Stream text deltas back
for await (const chunk of claude.stdout) {
yield chunk.toString();
}
}
```
---
## Postiz Integration (Media Upload + Publishing)
```typescript
// lib/postiz.ts
import { Postiz } from "@postiz/node";
import fs from "fs";
import path from "path";
import FormData from "form-data";
const postiz = new Postiz({
apiKey: process.env.POSTIZ_API_KEY!,
baseUrl: process.env.POSTIZ_URL!,
});
// Upload a local file to Postiz and get back a hosted URL + media ID
export async function uploadToPostiz(filePath: string) {
const form = new FormData();
form.append("file", fs.createReadStream(filePath));
const res = await fetch(`${process.env.POSTIZ_URL}/public/v1/upload`, {
method: "POST",
headers: { Authorization: process.env.POSTIZ_API_KEY! },
body: form,
});
const { id, path: publicUrl } = await res.json();
return { mediaId: id, publicUrl };
}
// Push an approved asset to Postiz for scheduling
export async function pushToPostiz(asset: Asset, scheduledAt: string) {
// Upload media first
const { mediaId, publicUrl } = await uploadToPostiz(asset.filePath);
// Get the right Postiz integration (channel) ID
const integrations = await postiz.integrations.list();
const integration = integrations.find(
(i) => i.providerIdentifier === asset.platform
);
if (!integration) throw new Error(`No Postiz channel for ${asset.platform}`);
// Parse caption from asset metadata
const metadata = JSON.parse(asset.metadata || "{}");
// Create the scheduled post
const post = await postiz.posts.create({
type: "schedule",
date: scheduledAt,
integration: integration.id,
content: metadata.caption || "",
image: [{ id: mediaId, path: publicUrl }],
});
return post;
}
```
---
## File Serving (Local Assets → Browser Preview)
Your pipeline outputs (images, videos) live on the local filesystem. The dashboard needs to serve them for preview:
```typescript
// app/api/files/[...path]/route.ts
import { readFile } from "fs/promises";
import path from "path";
import { auth } from "@/lib/auth";
const PIPELINE_ROOT = process.env.PIPELINE_ROOT || "/app/pipeline";
export async function GET(req: Request, { params }: { params: { path: string[] } }) {
// Auth check — only logged-in users can view files
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const filePath = path.join(PIPELINE_ROOT, ...params.path);
// Security: prevent path traversal
if (!filePath.startsWith(PIPELINE_ROOT)) {
return new Response("Forbidden", { status: 403 });
}
try {
const buffer = await readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const contentType =
{ ".png": "image/png", ".jpg": "image/jpeg", ".mp4": "video/mp4",
".html": "text/html", ".json": "application/json", ".md": "text/markdown",
".txt": "text/plain", ".css": "text/css" }[ext] || "application/octet-stream";
return new Response(buffer, { headers: { "Content-Type": contentType } });
} catch {
return new Response("Not found", { status: 404 });
}
}
```
Asset gallery uses: `<img src="/api/files/outputs/campaign_123/ads/instagram_feed.png" />`
Video preview uses: `<video src="/api/files/outputs/campaign_123/video/tiktok_ad.mp4" />`
---
## Docker Setup
### Dockerfile (Next.js App)
```dockerfile
# Dockerfile
FROM node:20-alpine AS base
# Install Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# Install Playwright browsers (for ad creative generation)
RUN npx playwright install chromium --with-deps
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
# Copy the marketing pipeline
COPY pipeline/ ./pipeline/
EXPOSE 3000
CMD ["node", "server.js"]
```
### Docker Compose (Full Stack)
```yaml
# docker-compose.yml
version: "3.8"
services:
# Your marketing dashboard + pipeline
app:
build: .
ports:
- "3000:3000"
environment:
- NEXTAUTH_URL=http://localhost:3000
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- TAVILY_API_KEY=${TAVILY_API_KEY}
- POSTIZ_URL=http://postiz:5000
- POSTIZ_API_KEY=${POSTIZ_API_KEY}
- NEXTDOOR_API_TOKEN=${NEXTDOOR_API_TOKEN}
- NEXTDOOR_ADVERTISER_ID=${NEXTDOOR_ADVERTISER_ID}
- PIPELINE_ROOT=/app/pipeline
volumes:
- app-data:/app/prisma/data # SQLite database
- pipeline-outputs:/app/pipeline/outputs # Generated content
- pipeline-knowledge:/app/pipeline/knowledge # Brand files (editable)
depends_on:
- postiz
# Self-hosted Postiz (social media scheduling + media hosting)
postiz:
image: ghcr.io/gitroomhq/postiz-app:latest
ports:
- "5000:5000"
environment:
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postiz-db:5432/postiz
- REDIS_URL=redis://redis:6379
- NEXT_PUBLIC_BACKEND_URL=http://postiz:5000
- STORAGE_PROVIDER=local
- UPLOAD_DIRECTORY=/uploads
volumes:
- postiz-uploads:/uploads
- postiz-config:/config
depends_on:
- postiz-db
- redis
# Postiz database (Postgres — required by Postiz, not by your app)
postiz-db:
image: postgres:16-alpine
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=postiz
volumes:
- postiz-pgdata:/var/lib/postgresql/data
# Postiz cache (Redis — required by Postiz, not by your app)
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
volumes:
app-data: # Your SQLite database
pipeline-outputs: # All generated content (images, videos, reports)
pipeline-knowledge: # Brand identity, platform guidelines, campaign docs
postiz-uploads: # Postiz media storage
postiz-config: # Postiz configuration
postiz-pgdata: # Postiz database
redis-data: # Postiz cache
```
### Environment File
```bash
# .env
# --- Your App ---
NEXTAUTH_SECRET=generate-a-random-32-char-string-here
ADMIN_EMAIL=you@yourdomain.com
ADMIN_PASSWORD=your-secure-password
# --- Claude (pipeline engine) ---
ANTHROPIC_API_KEY=sk-ant-your-key
# --- Research ---
TAVILY_API_KEY=tvly-your-key
# --- Postiz (publishing + media hosting) ---
POSTIZ_API_KEY=your-postiz-key
POSTGRES_PASSWORD=a-secure-password-for-postiz-db
# --- Nextdoor (direct API — Postiz doesn't support it) ---
NEXTDOOR_API_TOKEN=your-token
NEXTDOOR_ADVERTISER_ID=your-id
```
### Startup
```bash
# First run
docker compose up -d
docker compose exec app npx prisma db push # Create tables
docker compose exec app npx prisma db seed # Create admin user
# Open in browser
open http://localhost:3000
```
After first run, just `docker compose up -d` and everything starts.
---
## Project Folder Structure
```
marketing-command-center/
├── app/ # Next.js App Router
│ ├── (auth)/
│ │ ├── login/page.tsx
│ │ └── layout.tsx
│ ├── (dashboard)/
│ │ ├── layout.tsx # Sidebar + header
│ │ ├── page.tsx # Dashboard home
│ │ ├── campaigns/
│ │ │ ├── page.tsx # Campaign list
│ │ │ ├── new/page.tsx # New campaign form
│ │ │ └── [id]/
│ │ │ ├── page.tsx # Pipeline progress tab
│ │ │ ├── assets/page.tsx # Asset gallery tab
│ │ │ └── chat/page.tsx # Claude feedback tab
│ │ ├── assets/page.tsx # Global asset library
│ │ ├── trends/page.tsx # Trend reports
│ │ └── queue/page.tsx # Postiz queue / calendar
│ └── api/
│ ├── auth/[...nextauth]/route.ts
│ ├── campaigns/
│ │ ├── route.ts # CRUD
│ │ ├── [id]/
│ │ │ ├── launch/route.ts # Trigger pipeline
│ │ │ └── stream/route.ts # SSE pipeline progress
│ ├── assets/
│ │ └── route.ts # Asset CRUD + filtering
│ ├── claude/
│ │ └── route.ts # Chat sessions (stream)
│ ├── postiz/
│ │ └── route.ts # Upload + schedule to Postiz
│ └── files/
│ └── [...path]/route.ts # Serve local pipeline files
├── components/
│ ├── ui/ # shadcn/ui
│ ├── campaign-card.tsx
│ ├── asset-gallery.tsx
│ ├── asset-card.tsx
│ ├── pipeline-progress.tsx
│ ├── claude-chat.tsx
│ ├── postiz-push-modal.tsx
│ ├── trend-report-viewer.tsx
│ └── campaign-form.tsx
├── hooks/
│ ├── usePipelineProgress.ts # SSE hook
│ └── useClaudeChat.ts # Chat streaming hook
├── lib/
│ ├── auth.ts # NextAuth config
│ ├── prisma.ts # Prisma client
│ ├── claude.ts # Claude SDK wrapper
│ ├── postiz.ts # Postiz SDK wrapper
│ └── utils.ts
├── prisma/
│ ├── schema.prisma # Database schema
│ ├── seed.ts # Admin user creation
│ └── data/ # SQLite file lives here
│ └── marketing.db
├── pipeline/ # Your marketing pipeline
│ ├── .claude/
│ ├── skills/
│ │ ├── trend-scout/SKILL.md
│ │ ├── marketing-research-agent/SKILL.md
│ │ ├── script-writer/SKILL.md
│ │ ├── ad-creative-designer/SKILL.md
│ │ ├── video-ad-producer/SKILL.md
│ │ ├── copywriter-agent/SKILL.md
│ │ └── distribution-agent/SKILL.md
│ ├── knowledge/
│ │ ├── brand_identity.md
│ │ ├── platform_guidelines.md
│ │ └── product_campaign.md
│ ├── assets/ # Brand images, logos
│ ├── outputs/ # All generated content
│ ├── remotion-ad/ # Remotion video project
│ ├── CLAUDE.md
│ └── package.json
├── public/
├── .env
├── docker-compose.yml
├── Dockerfile
├── next.config.ts
├── tailwind.config.ts
├── tsconfig.json
└── package.json
```
---
## Pages & Features
### 1. Dashboard Home (`/`)
- Active campaigns with status badges (Running / Review Needed / Published)
- Recent trend reports from Trend Scout
- Quick stats: assets created, published this week, pending review
- "New Campaign" button
### 2. Campaign View (`/campaigns/[id]`)
**Pipeline Tab** — real-time agent execution progress via SSE:
```
✅ Trend Scout 12s "5 trending hooks found"
✅ Research Agent 2m "Market report generated"
✅ Script Writer 3m "15 scripts written"
🔄 Ad Creative 1m... "Generating static ads..."
⏳ Video Producer — waiting
⏳ Copywriter — waiting
⏳ Distribution — waiting (pending approval)
```
**Assets Tab** — gallery of everything produced:
- Grid of image/video previews with platform badges
- Filter by: platform, type (image/video/copy), status
- Click to expand: full preview + caption + metadata
- Approve / Reject buttons per asset
- "Push to Postiz" on approved assets
**Claude Tab** — live feedback chat:
- Streaming chat interface connected to Claude Code
- Session persists via `--resume`
- Type: "make it snarkier", "add competitor X to research", "regenerate hook B"
- New/updated assets auto-appear in the gallery
### 3. Asset Library (`/assets`)
- All assets across all campaigns
- Search by hook text, caption, platform
- Bulk approve / reject / push to Postiz
### 4. Trend Reports (`/trends`)
- Auto-generated trend reports (iframe HTML viewer)
- "Create Campaign from This Report" button
### 5. Postiz Queue (`/queue`)
- Everything pushed to Postiz with status (scheduled / published / failed)
- Calendar view of upcoming posts
- Link to Postiz dashboard at `localhost:5000` for visual calendar
---
## What Runs Where
| Component | Runs In | External Calls |
|-----------|---------|---------------|
| Next.js dashboard | Docker (your machine) | None — fully self-contained |
| SQLite database | Docker volume | None |
| Auth (NextAuth) | Docker (your machine) | None |
| File serving | Docker (your machine) | None |
| Pipeline progress (SSE) | Docker (your machine) | None |
| Claude Code sessions | Docker (spawns subprocess) | → Anthropic API |
| Research agent | Claude subprocess | → Tavily API |
| Image generation | Claude subprocess | → NanoBanana (Gemini API) |
| Video rendering | Claude subprocess | → Local Remotion render |
| Postiz | Docker (your machine) | → Instagram API, TikTok API |
| Nextdoor publishing | Your app API route | → Nextdoor API |
**Outbound network calls:** Anthropic, Tavily, Gemini (NanoBanana), Instagram (via Postiz), TikTok (via Postiz), Nextdoor. Everything else is local.
---
## Build Phases
### Phase 1: Scaffold (Week 1)
- [ ] Next.js + shadcn/ui + Tailwind setup
- [ ] Prisma + SQLite schema
- [ ] NextAuth credentials provider
- [ ] Docker Compose with Postiz
- [ ] Basic dashboard layout with sidebar nav
### Phase 2: Pipeline Integration (Week 2)
- [ ] Campaign CRUD pages
- [ ] Campaign launch form → spawns Claude Code subprocess
- [ ] SSE pipeline progress streaming
- [ ] File serving API route for asset preview
- [ ] Auto-scan outputs folder → populate assets table
### Phase 3: Asset Gallery + Claude Chat (Week 3)
- [ ] Asset gallery with grid view + filtering
- [ ] Image/video inline preview
- [ ] Approve / reject workflow
- [ ] Claude chat component with streaming
- [ ] Session persistence (--resume)
### Phase 4: Postiz Publishing (Week 4)
- [ ] Postiz upload integration
- [ ] "Push to Postiz" modal (select → schedule → confirm)
- [ ] Queue page with Postiz status sync
- [ ] Nextdoor direct API publishing
- [ ] Trend report viewer
### Phase 5: Polish (Week 5)
- [ ] Bulk actions (approve all, reject all, push batch)
- [ ] Asset search
- [ ] Mobile-responsive
- [ ] Trend Scout auto-scheduling (cron in Docker)
- [ ] Error handling + retry logic