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
This commit is contained in:
Claude 2026-05-04 03:01:08 +00:00
parent 913dc2b93d
commit c7900c60d0
No known key found for this signature in database
3 changed files with 665 additions and 24 deletions

View File

@ -1,51 +1,179 @@
import { and, count, desc, eq, lt, sum } from 'drizzle-orm';
import { Hono } from 'hono'; 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 type { Bindings } from '../env.js';
import { requireAdmin } from '../lib/auth.js'; import { requireAdmin } from '../lib/auth.js';
import { createStorage } from '../lib/storage-factory.js';
export const adminRoutes = new Hono<Bindings>(); export const adminRoutes = new Hono<Bindings>();
adminRoutes.use('*', requireAdmin); adminRoutes.use('*', requireAdmin);
const ADMIN_PAGE_SIZE = 30;
adminRoutes.get('/me', (c) => c.json({ email: c.get('adminEmail') })); adminRoutes.get('/me', (c) => c.json({ email: c.get('adminEmail') }));
// Event configuration
adminRoutes.get('/event', async (c) => { 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) => { 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) => { 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) => { 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) => { 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) => { 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('/stats', async (c) => {
adminRoutes.get('/qrcode', async (c) => { const db = getDb(c.env.DB);
return c.json({ error: 'not_implemented' }, 501); 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) => { 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);
}); });

View File

@ -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() { export default function AdminDashboard() {
const [email, setEmail] = useState<string | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [stats, setStats] = useState<AdminStats | null>(null);
const [event, setEvent] = useState<AdminEvent | null>(null);
const [uploads, setUploads] = useState<AdminUpload[]>([]);
const [filter, setFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>(
'all',
);
const [cursor, setCursor] = useState<string | null>(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<AdminStats>)
.then(setStats);
fetch('/api/admin/event', { credentials: 'include' })
.then((r) => r.json() as Promise<AdminEvent>)
.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<AdminEvent>) {
setEvent((prev) => (prev ? { ...prev, ...patch } : prev));
setEventDirty(true);
}
if (authError) {
return ( return (
<main className="min-h-full flex items-center justify-center p-6"> <main className="min-h-full flex items-center justify-center p-6 text-center">
<div className="text-center text-stone-700"> <div>
<h2 className="font-display text-3xl mb-3">Painel dos noivos</h2> <h2 className="font-display text-2xl text-stone-800 mb-2">Sem acesso</h2>
<p>Acesso protegido por Cloudflare Access.</p> <p className="text-stone-600">{authError}</p>
<Link to="/" className="mt-4 inline-block text-sm text-stone-500 underline">
Voltar pra página inicial
</Link>
</div> </div>
</main> </main>
); );
}
if (!email) {
return (
<main className="min-h-full flex items-center justify-center p-6 text-stone-500">
Carregando
</main>
);
}
return (
<main className="min-h-full max-w-4xl mx-auto px-4 py-8">
<header className="flex items-center justify-between mb-8">
<div>
<h1 className="font-display text-3xl text-stone-800">Painel dos noivos</h1>
<p className="text-xs text-stone-500 mt-1">Logado como {email}</p>
</div>
<a
href="/cdn-cgi/access/logout"
className="text-sm text-stone-500 hover:text-stone-700 underline"
>
Sair
</a>
</header>
{stats && (
<section className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
<StatCard label="Aprovadas" value={stats.approved} />
<StatCard label="Pendentes" value={stats.pending} highlight={stats.pending > 0} />
<StatCard label="Fotos / Vídeos" value={`${stats.photos} / ${stats.videos}`} />
<StatCard label="Storage" value={formatBytes(stats.totalBytes)} />
</section>
)}
{event && (
<section className="bg-white rounded-lg border border-stone-200 p-5 mb-8">
<h2 className="font-display text-xl text-stone-800 mb-4">Configuração</h2>
<form onSubmit={saveEvent} className="space-y-4">
<Field label="Nome do casal">
<input
type="text"
value={event.coupleNames}
onChange={(e) => patchEvent({ coupleNames: e.target.value })}
className="w-full rounded border border-stone-300 px-3 py-2"
/>
</Field>
<Field label="Data do evento (texto livre)">
<input
type="text"
placeholder="ex: 07 de junho de 2026"
value={event.eventDate ?? ''}
onChange={(e) =>
patchEvent({ eventDate: e.target.value.trim() || null })
}
className="w-full rounded border border-stone-300 px-3 py-2"
/>
</Field>
<Field label="Mensagem de boas-vindas (na home)">
<textarea
value={event.welcomeMessage ?? ''}
onChange={(e) =>
patchEvent({ welcomeMessage: e.target.value || null })
}
rows={2}
className="w-full rounded border border-stone-300 px-3 py-2"
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="Galeria">
<select
value={event.galleryVisibility}
onChange={(e) =>
patchEvent({
galleryVisibility: e.target.value as 'public' | 'private',
})
}
className="w-full rounded border border-stone-300 px-3 py-2"
>
<option value="public">Pública</option>
<option value="private">Privada ( vocês)</option>
</select>
</Field>
<Field label="Moderação">
<select
value={event.moderation}
onChange={(e) =>
patchEvent({ moderation: e.target.value as 'pre' | 'post' })
}
className="w-full rounded border border-stone-300 px-3 py-2"
>
<option value="post">Liberar tudo (moderar depois)</option>
<option value="pre">Aprovar antes de aparecer</option>
</select>
</Field>
<Field label="Tamanho máx. por arquivo (MB)">
<input
type="number"
min={1}
max={2000}
value={event.maxFileMb}
onChange={(e) => patchEvent({ maxFileMb: Number(e.target.value) })}
className="w-full rounded border border-stone-300 px-3 py-2"
/>
</Field>
<Field label="Duração máx. de vídeo (segundos)">
<input
type="number"
min={1}
max={3600}
value={event.maxVideoSeconds}
onChange={(e) =>
patchEvent({ maxVideoSeconds: Number(e.target.value) })
}
className="w-full rounded border border-stone-300 px-3 py-2"
/>
</Field>
</div>
<label className="flex items-center gap-2 text-sm text-stone-700">
<input
type="checkbox"
checked={event.allowVideo}
onChange={(e) => patchEvent({ allowVideo: e.target.checked })}
/>
Permitir vídeos
</label>
<button
type="submit"
disabled={!eventDirty || savingEvent}
className="bg-stone-800 text-cream rounded-full py-2 px-6 disabled:bg-stone-400 hover:bg-stone-700 transition"
>
{savingEvent ? 'Salvando…' : eventDirty ? 'Salvar' : 'Salvo'}
</button>
</form>
</section>
)}
<section className="bg-white rounded-lg border border-stone-200 p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="font-display text-xl text-stone-800">Uploads</h2>
<select
value={filter}
onChange={(e) =>
setFilter(e.target.value as 'all' | 'pending' | 'approved' | 'rejected')
}
className="text-sm rounded border border-stone-300 px-2 py-1"
>
<option value="all">Todos</option>
<option value="pending">Pendentes</option>
<option value="approved">Aprovados</option>
<option value="rejected">Rejeitados</option>
</select>
</div>
{uploads.length === 0 && !loading && (
<p className="text-stone-500 text-sm py-8 text-center">Nenhum upload.</p>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{uploads.map((u) => (
<UploadCard
key={u.id}
upload={u}
onApprove={() => approve(u.id)}
onReject={() => reject(u.id)}
onDelete={() => remove(u.id)}
/>
))}
</div>
{!done && (
<div className="text-center mt-6">
<button
type="button"
onClick={() => loadUploads(false)}
disabled={loading}
className="text-sm border border-stone-400 text-stone-700 rounded-full py-2 px-5 hover:bg-stone-50 disabled:opacity-50"
>
{loading ? 'Carregando…' : 'Carregar mais'}
</button>
</div>
)}
</section>
<footer className="mt-10 text-xs text-stone-500 flex flex-wrap gap-4 justify-between">
<Link to="/" className="hover:text-stone-700">
Página pública
</Link>
<a
href="/api/qrcode?format=pdf"
target="_blank"
rel="noreferrer"
className="hover:text-stone-700"
>
Baixar QR Code (PDF A6)
</a>
</footer>
</main>
);
}
function StatCard({
label,
value,
highlight,
}: {
label: string;
value: string | number;
highlight?: boolean;
}) {
return (
<div
className={`rounded-lg border p-3 ${
highlight ? 'border-rose-300 bg-rose-50' : 'border-stone-200 bg-white'
}`}
>
<div className="text-xs text-stone-500">{label}</div>
<div className="text-2xl font-display text-stone-800 mt-1">{value}</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="block text-xs font-medium text-stone-600 mb-1">{label}</span>
{children}
</label>
);
}
function UploadCard({
upload,
onApprove,
onReject,
onDelete,
}: {
upload: AdminUpload;
onApprove: () => void;
onReject: () => void;
onDelete: () => void;
}) {
const statusColor =
upload.status === 'approved'
? 'bg-emerald-100 text-emerald-800'
: upload.status === 'pending'
? 'bg-amber-100 text-amber-800'
: 'bg-rose-100 text-rose-800';
return (
<div className="rounded-md border border-stone-200 overflow-hidden bg-white">
<div className="aspect-square bg-stone-100 relative">
{upload.isVideo ? (
<>
<video src={upload.url} preload="metadata" className="w-full h-full object-cover" muted />
<span className="absolute inset-0 flex items-center justify-center bg-black/30 text-white text-2xl">
</span>
</>
) : (
<img
src={upload.url}
alt=""
loading="lazy"
className="w-full h-full object-cover"
/>
)}
<span
className={`absolute top-2 left-2 text-[10px] uppercase tracking-wide px-2 py-0.5 rounded ${statusColor}`}
>
{upload.status}
</span>
</div>
<div className="p-2 text-xs text-stone-700 space-y-1">
<div className="flex justify-between text-stone-500">
<span>{formatDate(upload.createdAt)}</span>
<span>{formatBytes(upload.sizeBytes)}</span>
</div>
{upload.authorName && <div className="font-medium">{upload.authorName}</div>}
{upload.message && (
<p className="text-stone-600 line-clamp-2">{upload.message}</p>
)}
<div className="flex gap-1 pt-1">
{upload.status !== 'approved' && (
<button
type="button"
onClick={onApprove}
className="flex-1 text-[11px] bg-emerald-600 text-white rounded py-1 hover:bg-emerald-700"
>
Aprovar
</button>
)}
{upload.status === 'approved' && (
<button
type="button"
onClick={onReject}
className="flex-1 text-[11px] bg-amber-600 text-white rounded py-1 hover:bg-amber-700"
>
Ocultar
</button>
)}
<button
type="button"
onClick={onDelete}
className="flex-1 text-[11px] bg-stone-700 text-white rounded py-1 hover:bg-stone-800"
>
Apagar
</button>
</div>
</div>
</div>
);
} }

View File

@ -17,6 +17,43 @@ export type EventConfig = z.infer<typeof eventConfigSchema>;
export const eventConfigUpdateSchema = eventConfigSchema.partial(); export const eventConfigUpdateSchema = eventConfigSchema.partial();
export type EventConfigUpdate = z.infer<typeof eventConfigUpdateSchema>; export type EventConfigUpdate = z.infer<typeof eventConfigUpdateSchema>;
export const adminUploadSchema = z.object({
id: z.string(),
url: z.string().url(),
thumbnailUrl: z.string().url().nullable(),
mimeType: z.string(),
isVideo: z.boolean(),
sizeBytes: z.number().int(),
durationSeconds: z.number().int().nullable(),
authorName: z.string().nullable(),
message: z.string().nullable(),
status: z.enum(['pending', 'approved', 'rejected']),
source: z.enum(['guest', 'import']),
createdAt: z.number(),
approvedAt: z.number().nullable(),
});
export type AdminUpload = z.infer<typeof adminUploadSchema>;
export const adminUploadsResponseSchema = z.object({
items: z.array(adminUploadSchema),
nextCursor: z.string().nullable(),
});
export type AdminUploadsResponse = z.infer<typeof adminUploadsResponseSchema>;
export const adminStatsSchema = z.object({
total: z.number().int(),
approved: z.number().int(),
pending: z.number().int(),
rejected: z.number().int(),
photos: z.number().int(),
videos: z.number().int(),
totalBytes: z.number().int(),
});
export type AdminStats = z.infer<typeof adminStatsSchema>;
export const uploadInitSchema = z.object({ export const uploadInitSchema = z.object({
filename: z.string().min(1).max(255), filename: z.string().min(1).max(255),
mimeType: z.string().regex(/^(image|video)\/[a-z0-9.+-]+$/i), mimeType: z.string().regex(/^(image|video)\/[a-z0-9.+-]+$/i),