From c7900c60d01db8f56e0d7a652b871878eea7a8c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 03:01:08 +0000 Subject: [PATCH] feat: implement admin panel and event management API (apps/api/src/routes/admin.ts): - GET /api/admin/me, /event, /uploads, /stats - PATCH /api/admin/event for live config edits - POST /api/admin/uploads/:id/{approve,reject} - DELETE /api/admin/uploads/:id (also removes the object from R2) - ZIP export and bulk import remain as 501 for the next iteration Web (apps/web/src/routes/admin/Dashboard.tsx): - Single-page dashboard with stats cards, event config form, filterable uploads grid (all/pending/approved/rejected) with per-card approve/reject/delete, infinite "load more", and a PDF QR shortcut in the footer. - Logout via Cloudflare Access /cdn-cgi/access/logout. Shared (packages/shared/src/schemas.ts): - New zod schemas for AdminUpload, AdminUploadsResponse, AdminStats so the client and server agree on the admin payload shape. https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL --- apps/api/src/routes/admin.ts | 166 +++++++- apps/web/src/routes/admin/Dashboard.tsx | 486 +++++++++++++++++++++++- packages/shared/src/schemas.ts | 37 ++ 3 files changed, 665 insertions(+), 24 deletions(-) diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index ab6e138..94c2d4c 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -1,51 +1,179 @@ +import { and, count, desc, eq, lt, sum } from 'drizzle-orm'; import { Hono } from 'hono'; +import { + type AdminStats, + type AdminUploadsResponse, + eventConfigUpdateSchema, +} from '@leetete/shared'; +import { getDb } from '../db/client.js'; +import { eventConfig, uploads } from '../db/schema.js'; import type { Bindings } from '../env.js'; import { requireAdmin } from '../lib/auth.js'; +import { createStorage } from '../lib/storage-factory.js'; export const adminRoutes = new Hono(); adminRoutes.use('*', requireAdmin); +const ADMIN_PAGE_SIZE = 30; + adminRoutes.get('/me', (c) => c.json({ email: c.get('adminEmail') })); -// Event configuration adminRoutes.get('/event', async (c) => { - return c.json({ error: 'not_implemented' }, 501); + const db = getDb(c.env.DB); + const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get(); + if (!cfg) return c.json({ error: 'not_configured' }, 404); + return c.json({ + coupleNames: cfg.coupleNames, + eventDate: cfg.eventDate, + coverKey: cfg.coverKey, + galleryVisibility: cfg.galleryVisibility, + moderation: cfg.moderation, + maxFileMb: cfg.maxFileMb, + allowVideo: cfg.allowVideo, + maxVideoSeconds: cfg.maxVideoSeconds, + welcomeMessage: cfg.welcomeMessage, + updatedAt: cfg.updatedAt, + }); }); adminRoutes.patch('/event', async (c) => { - return c.json({ error: 'not_implemented' }, 501); + const body = await c.req.json().catch(() => null); + const parsed = eventConfigUpdateSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400); + } + const db = getDb(c.env.DB); + await db + .update(eventConfig) + .set({ + ...parsed.data, + updatedAt: Date.now(), + }) + .where(eq(eventConfig.id, 1)); + return c.json({ ok: true }); }); -// Uploads moderation / management adminRoutes.get('/uploads', async (c) => { - return c.json({ items: [], nextCursor: null }); + const db = getDb(c.env.DB); + const status = c.req.query('status') as 'pending' | 'approved' | 'rejected' | undefined; + const cursorParam = c.req.query('cursor'); + const cursor = cursorParam ? Number(cursorParam) : null; + const limit = Math.min(Number(c.req.query('limit') ?? ADMIN_PAGE_SIZE), 100); + + const filters = []; + if (status) filters.push(eq(uploads.status, status)); + if (cursor !== null && Number.isFinite(cursor)) { + filters.push(lt(uploads.createdAt, cursor)); + } + const where = filters.length ? and(...filters) : undefined; + + const rows = await db + .select() + .from(uploads) + .where(where) + .orderBy(desc(uploads.createdAt)) + .limit(limit + 1) + .all(); + + const hasMore = rows.length > limit; + const slice = hasMore ? rows.slice(0, limit) : rows; + const storage = createStorage(c.env); + + const items = slice.map((row) => ({ + id: row.id, + url: storage.publicUrl(row.storageKey), + thumbnailUrl: row.thumbnailKey ? storage.publicUrl(row.thumbnailKey) : null, + mimeType: row.mimeType, + isVideo: row.mimeType.startsWith('video/'), + sizeBytes: row.sizeBytes, + durationSeconds: row.durationSeconds, + authorName: row.authorName, + message: row.message, + status: row.status, + source: row.source, + createdAt: row.createdAt, + approvedAt: row.approvedAt, + })); + + const nextCursor = hasMore ? String(slice[slice.length - 1]!.createdAt) : null; + return c.json({ items, nextCursor } satisfies AdminUploadsResponse); }); adminRoutes.post('/uploads/:id/approve', async (c) => { - return c.json({ error: 'not_implemented' }, 501); + const id = c.req.param('id'); + const db = getDb(c.env.DB); + const result = await db + .update(uploads) + .set({ status: 'approved', approvedAt: Date.now() }) + .where(eq(uploads.id, id)) + .returning({ id: uploads.id }); + if (!result.length) return c.json({ error: 'not_found' }, 404); + return c.json({ ok: true }); }); adminRoutes.post('/uploads/:id/reject', async (c) => { - return c.json({ error: 'not_implemented' }, 501); + const id = c.req.param('id'); + const db = getDb(c.env.DB); + const result = await db + .update(uploads) + .set({ status: 'rejected', approvedAt: null }) + .where(eq(uploads.id, id)) + .returning({ id: uploads.id }); + if (!result.length) return c.json({ error: 'not_found' }, 404); + return c.json({ ok: true }); }); adminRoutes.delete('/uploads/:id', async (c) => { - return c.json({ error: 'not_implemented' }, 501); + const id = c.req.param('id'); + const db = getDb(c.env.DB); + const row = await db.select().from(uploads).where(eq(uploads.id, id)).get(); + if (!row) return c.json({ error: 'not_found' }, 404); + + const storage = createStorage(c.env); + await storage.delete(row.storageKey).catch((err) => { + console.warn('failed to delete from storage', row.storageKey, err); + }); + if (row.thumbnailKey) { + await storage.delete(row.thumbnailKey).catch(() => {}); + } + await db.delete(uploads).where(eq(uploads.id, id)); + return c.json({ ok: true }); }); -// QR code: ?format=png|svg|pdf (PDF = imprimível em A6 com nome do casal) -adminRoutes.get('/qrcode', async (c) => { - return c.json({ error: 'not_implemented' }, 501); +adminRoutes.get('/stats', async (c) => { + const db = getDb(c.env.DB); + const all = await db + .select({ + status: uploads.status, + mimeType: uploads.mimeType, + sizeBytes: uploads.sizeBytes, + }) + .from(uploads) + .all(); + + const stats: AdminStats = { + total: all.length, + approved: 0, + pending: 0, + rejected: 0, + photos: 0, + videos: 0, + totalBytes: 0, + }; + for (const r of all) { + stats[r.status]++; + if (r.mimeType.startsWith('image/')) stats.photos++; + else if (r.mimeType.startsWith('video/')) stats.videos++; + stats.totalBytes += r.sizeBytes; + } + return c.json(stats); }); -// Bulk import (post-event): pre-signs URLs in batches so the couple -// can upload files from a folder later (works for R2 today, MinIO later). -adminRoutes.post('/imports', async (c) => { - return c.json({ error: 'not_implemented' }, 501); -}); - -// Streamed ZIP export of all uploads. adminRoutes.get('/export.zip', async (c) => { - return c.json({ error: 'not_implemented' }, 501); + return c.json({ error: 'not_implemented_yet' }, 501); +}); + +adminRoutes.post('/imports', async (c) => { + return c.json({ error: 'not_implemented_yet' }, 501); }); diff --git a/apps/web/src/routes/admin/Dashboard.tsx b/apps/web/src/routes/admin/Dashboard.tsx index 96a9bc5..cb6c286 100644 --- a/apps/web/src/routes/admin/Dashboard.tsx +++ b/apps/web/src/routes/admin/Dashboard.tsx @@ -1,10 +1,486 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import type { + AdminStats, + AdminUpload, + AdminUploadsResponse, +} from '@leetete/shared'; + +interface AdminEvent { + coupleNames: string; + eventDate: string | null; + galleryVisibility: 'public' | 'private'; + moderation: 'pre' | 'post'; + maxFileMb: number; + allowVideo: boolean; + maxVideoSeconds: number; + welcomeMessage: string | null; +} + +const MB = 1024 * 1024; + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < MB) return `${(n / 1024).toFixed(0)} KB`; + if (n < 1024 * MB) return `${(n / MB).toFixed(1)} MB`; + return `${(n / (1024 * MB)).toFixed(2)} GB`; +} + +function formatDate(ts: number): string { + return new Date(ts).toLocaleString('pt-BR', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + export default function AdminDashboard() { + const [email, setEmail] = useState(null); + const [authError, setAuthError] = useState(null); + const [stats, setStats] = useState(null); + const [event, setEvent] = useState(null); + const [uploads, setUploads] = useState([]); + const [filter, setFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>( + 'all', + ); + const [cursor, setCursor] = useState(null); + const [done, setDone] = useState(false); + const [loading, setLoading] = useState(false); + const [savingEvent, setSavingEvent] = useState(false); + const [eventDirty, setEventDirty] = useState(false); + + useEffect(() => { + fetch('/api/admin/me', { credentials: 'include' }) + .then(async (r) => { + if (r.status === 401 || r.status === 403) { + setAuthError('Você não tem acesso a este painel.'); + return null; + } + return r.json() as Promise<{ email: string }>; + }) + .then((d) => d && setEmail(d.email)) + .catch(() => setAuthError('Falha ao verificar identidade.')); + }, []); + + useEffect(() => { + if (!email) return; + fetch('/api/admin/stats', { credentials: 'include' }) + .then((r) => r.json() as Promise) + .then(setStats); + fetch('/api/admin/event', { credentials: 'include' }) + .then((r) => r.json() as Promise) + .then(setEvent); + }, [email]); + + async function loadUploads(reset: boolean) { + if (!email || loading) return; + setLoading(true); + try { + const url = new URL('/api/admin/uploads', window.location.origin); + if (filter !== 'all') url.searchParams.set('status', filter); + if (!reset && cursor) url.searchParams.set('cursor', cursor); + const r = await fetch(url, { credentials: 'include' }); + const data = (await r.json()) as AdminUploadsResponse; + setUploads((prev) => (reset ? data.items : [...prev, ...data.items])); + setCursor(data.nextCursor); + setDone(!data.nextCursor); + } finally { + setLoading(false); + } + } + + useEffect(() => { + if (!email) return; + setUploads([]); + setCursor(null); + setDone(false); + loadUploads(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [email, filter]); + + async function refreshStats() { + const r = await fetch('/api/admin/stats', { credentials: 'include' }); + setStats((await r.json()) as AdminStats); + } + + async function approve(id: string) { + await fetch(`/api/admin/uploads/${id}/approve`, { + method: 'POST', + credentials: 'include', + }); + setUploads((u) => + u.map((x) => + x.id === id ? { ...x, status: 'approved', approvedAt: Date.now() } : x, + ), + ); + refreshStats(); + } + + async function reject(id: string) { + await fetch(`/api/admin/uploads/${id}/reject`, { + method: 'POST', + credentials: 'include', + }); + setUploads((u) => + u.map((x) => (x.id === id ? { ...x, status: 'rejected', approvedAt: null } : x)), + ); + refreshStats(); + } + + async function remove(id: string) { + if (!confirm('Apagar essa foto/vídeo? Não dá pra desfazer.')) return; + await fetch(`/api/admin/uploads/${id}`, { + method: 'DELETE', + credentials: 'include', + }); + setUploads((u) => u.filter((x) => x.id !== id)); + refreshStats(); + } + + async function saveEvent(e: React.FormEvent) { + e.preventDefault(); + if (!event) return; + setSavingEvent(true); + try { + await fetch('/api/admin/event', { + method: 'PATCH', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(event), + }); + setEventDirty(false); + } finally { + setSavingEvent(false); + } + } + + function patchEvent(patch: Partial) { + setEvent((prev) => (prev ? { ...prev, ...patch } : prev)); + setEventDirty(true); + } + + if (authError) { + return ( +
+
+

Sem acesso

+

{authError}

+ + Voltar pra página inicial + +
+
+ ); + } + + if (!email) { + return ( +
+ Carregando… +
+ ); + } + return ( -
-
-

Painel dos noivos

-

Acesso protegido por Cloudflare Access.

-
+
+
+
+

Painel dos noivos

+

Logado como {email}

+
+ + Sair + +
+ + {stats && ( +
+ + 0} /> + + +
+ )} + + {event && ( +
+

Configuração

+
+ + patchEvent({ coupleNames: e.target.value })} + className="w-full rounded border border-stone-300 px-3 py-2" + /> + + + + patchEvent({ eventDate: e.target.value.trim() || null }) + } + className="w-full rounded border border-stone-300 px-3 py-2" + /> + + +