feat: full Docker stack on a VPS with improved admin editing

Self-hosted rewrite of the wedding photo app: Node + Hono replaces
Cloudflare Workers, Postgres 16 replaces D1, MinIO replaces R2,
Caddy fronts the stack with automatic Let's Encrypt TLS. Same routes
and feature set as before; storage abstraction is the same S3 client
so MinIO drops in without code changes.

Architecture:
- docker-compose.yml: postgres, minio, minio-init (creates bucket +
  anonymous read + CORS), app, caddy (reverse proxy + media subdomain).
- Dockerfile: multi-stage pnpm build, single runtime image serving
  the API and the SPA dist as static assets from one process.
- Caddyfile: primary domain proxies to app; media subdomain proxies
  to MinIO so guests upload directly and signatures match Host.
- app: tsx runtime, runs SQL migrations idempotently at startup via
  a schema_migrations(name, sha256, applied_at) table.

Admin upgrades requested:
- PATCH /api/admin/uploads/:id to edit author/message.
- POST /api/admin/uploads/bulk for bulk approve/reject/delete.
- POST /api/admin/uploads/:id/cover and DELETE /api/admin/event/cover
  to set/clear a featured image (rendered on Home when set).
- GET /api/admin/uploads gains ?q= text search across author and
  message and ?kind=photo|video filter.
- Dashboard: bulk select checkboxes with a toolbar, edit modal that
  rewrites author and message, search input, kind filter, set-cover
  button per item, cover preview + clear in the event card.

Singletons replace the per-request bindings pattern: initDb() and
initStorage() run once in server.ts; routes call getDb()/getStorage()
directly rather than threading env.DB / env.MEDIA through.

env.ts uses zod to parse process.env and fails fast if anything
mandatory is missing. .env.example documents every variable and
flags the hairpin tradeoff for MinIO access from the app container.
This commit is contained in:
Claude 2026-06-07 22:11:10 +00:00
commit 00baf9bd89
No known key found for this signature in database
53 changed files with 6634 additions and 0 deletions

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
node_modules
**/node_modules
.git
.gitignore
.dockerignore
.env
.env.local
.env.*.local
dist
**/dist
.cache
.turbo
*.log
.DS_Store
.vscode
.idea

34
.env.example Normal file
View File

@ -0,0 +1,34 @@
# =============================================================================
# Edite essas variáveis e salve como .env (mesmo diretório do docker-compose.yml)
# =============================================================================
# ----- Domínios (apontar DNS A/AAAA pra IP do VPS antes do `docker compose up`)
DOMAIN=casamento.exemplo.com.br
MEDIA_DOMAIN=midia.exemplo.com.br
ACME_EMAIL=voce@exemplo.com
# ----- Postgres
POSTGRES_USER=wedding
POSTGRES_PASSWORD=troque-isso-por-uma-senha-forte
POSTGRES_DB=wedding
# ----- MinIO (root credentials = também usadas pelo app como S3 access keys)
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=troque-isso-por-uma-senha-forte
S3_BUCKET=wedding-media
S3_REGION=us-east-1
# Endpoint INTERNO usado pelo app pra falar com o MinIO. Use o do Caddy
# (https://${MEDIA_DOMAIN}) — necessário porque a assinatura S3 inclui o host
# e o browser faz upload direto pra MEDIA_DOMAIN. Hairpin via DNS público é OK.
# Pra evitar hairpin, ajuste extra_hosts no compose pra resolver MEDIA_DOMAIN
# pra IP interno e set S3_INTERNAL_ENDPOINT=http://minio:9000 (avançado).
S3_INTERNAL_ENDPOINT=
# ----- App
COUPLE_NAMES=Stefanie & Leandro
EVENT_DATE=07 de junho de 2026
# ----- Admin (painel dos noivos)
ADMIN_PASSWORD=senha-forte-compartilhada-entre-os-dois
SESSION_SECRET=gere-um-uuid-longo-aqui-min-32-chars
ALLOWED_ADMIN_EMAILS=voce@exemplo.com,outro@exemplo.com

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
node_modules/
dist/
build/
.cache/
.turbo/
*.log
.DS_Store
.env
.env.local
.env.*.local
!.env.example
.vscode/
.idea/

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
auto-install-peers=true
strict-peer-dependencies=false

25
Caddyfile Normal file
View File

@ -0,0 +1,25 @@
{
email {$ACME_EMAIL}
}
{$DOMAIN} {
encode zstd gzip
request_body {
max_size 600MB
}
reverse_proxy app:3000 {
transport http {
response_header_timeout 10m
dial_timeout 30s
}
}
}
{$MEDIA_DOMAIN} {
encode gzip
reverse_proxy minio:9000 {
header_up Host {upstream_hostport}
}
}

35
Dockerfile Normal file
View File

@ -0,0 +1,35 @@
FROM node:22-alpine AS base
RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
FROM base AS deps
WORKDIR /app
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./
COPY packages/shared/package.json packages/shared/
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
RUN pnpm install --frozen-lockfile
FROM deps AS build
WORKDIR /app
COPY tsconfig.base.json ./
COPY packages packages
COPY apps apps
RUN pnpm -F @leetete/web build
FROM base AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV STATIC_DIR=/app/public
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/pnpm-workspace.yaml /app/package.json /app/pnpm-lock.yaml* ./
COPY --from=build /app/tsconfig.base.json ./
COPY --from=build /app/packages packages
COPY --from=build /app/apps/api apps/api
COPY --from=build /app/apps/web/dist public
EXPOSE 3000
CMD ["pnpm", "--filter", "@leetete/api", "start"]

View File

@ -0,0 +1,7 @@
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './src/db/migrations',
dialect: 'postgresql',
} satisfies Config;

40
apps/api/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "@leetete/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/app.ts",
"types": "./src/app.ts",
"exports": {
".": "./src/app.ts",
"./env": "./src/env.ts",
"./db/schema": "./src/db/schema.ts"
},
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "tsx src/server.ts",
"migrate": "tsx src/db/migrate.ts",
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"@leetete/shared": "workspace:*",
"aws4fetch": "^1.0.20",
"drizzle-orm": "^0.36.4",
"hono": "^4.6.14",
"nanoid": "^5.0.9",
"pdf-lib": "^1.17.1",
"postgres": "^3.4.5",
"qrcode": "^1.5.4",
"tsx": "^4.19.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.10.1",
"@types/qrcode": "^1.5.5",
"drizzle-kit": "^0.28.1",
"typescript": "^5.6.3"
}
}

28
apps/api/src/app.ts Normal file
View File

@ -0,0 +1,28 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import type { Bindings } from './env.js';
import { adminRoutes } from './routes/admin.js';
import { publicRoutes } from './routes/public.js';
import { uploadsRoutes } from './routes/uploads.js';
export function createApp() {
const app = new Hono<Bindings>().basePath('/api');
app.use('*', cors({ origin: (origin) => origin ?? '*', credentials: true }));
app.get('/health', (c) => c.json({ ok: true, ts: Date.now() }));
app.route('/', publicRoutes);
app.route('/uploads', uploadsRoutes);
app.route('/admin', adminRoutes);
app.notFound((c) => c.json({ error: 'not_found' }, 404));
app.onError((err, c) => {
console.error('unhandled', err);
return c.json({ error: 'internal_error' }, 500);
});
return app;
}
export type App = ReturnType<typeof createApp>;

30
apps/api/src/db/client.ts Normal file
View File

@ -0,0 +1,30 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema.js';
export type DB = ReturnType<typeof drizzle<typeof schema>>;
let dbInstance: DB | null = null;
let pgInstance: ReturnType<typeof postgres> | null = null;
export function initDb(databaseUrl: string): DB {
if (dbInstance) return dbInstance;
pgInstance = postgres(databaseUrl, { max: 10, idle_timeout: 30 });
dbInstance = drizzle(pgInstance, { schema });
return dbInstance;
}
export function getDb(): DB {
if (!dbInstance) {
throw new Error('DB not initialized. Call initDb() before getDb().');
}
return dbInstance;
}
export async function closeDb(): Promise<void> {
if (pgInstance) {
await pgInstance.end({ timeout: 5 });
pgInstance = null;
dbInstance = null;
}
}

View File

@ -0,0 +1,71 @@
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import postgres from 'postgres';
const __dirname = dirname(fileURLToPath(import.meta.url));
function sha256(content: string): string {
return createHash('sha256').update(content).digest('hex');
}
export async function runMigrations(databaseUrl: string): Promise<void> {
const sql = postgres(databaseUrl, { max: 1 });
try {
await sql`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
sha256 TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`;
const dir = join(__dirname, 'migrations');
const files = readdirSync(dir)
.filter((f) => f.endsWith('.sql'))
.sort();
for (const file of files) {
const content = readFileSync(join(dir, file), 'utf-8');
const hash = sha256(content);
const existing = await sql<{ sha256: string }[]>`
SELECT sha256 FROM schema_migrations WHERE name = ${file}
`;
if (existing.length) {
if (existing[0]!.sha256 !== hash) {
throw new Error(
`Migration ${file} has been modified after being applied. ` +
`Add a new migration file instead.`,
);
}
console.log(`[migrate] skip ${file}`);
continue;
}
console.log(`[migrate] apply ${file}`);
await sql.unsafe(content);
await sql`
INSERT INTO schema_migrations (name, sha256) VALUES (${file}, ${hash})
`;
}
console.log('[migrate] done');
} finally {
await sql.end({ timeout: 5 });
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const url = process.env['DATABASE_URL'];
if (!url) {
console.error('DATABASE_URL is required');
process.exit(1);
}
runMigrations(url).catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@ -0,0 +1,46 @@
CREATE TABLE event_config (
id INTEGER PRIMARY KEY DEFAULT 1,
couple_names TEXT NOT NULL,
event_date TEXT,
cover_key TEXT,
gallery_visibility TEXT NOT NULL DEFAULT 'public',
moderation TEXT NOT NULL DEFAULT 'post',
max_file_mb INTEGER NOT NULL DEFAULT 500,
allow_video BOOLEAN NOT NULL DEFAULT TRUE,
max_video_seconds INTEGER NOT NULL DEFAULT 300,
welcome_message TEXT,
updated_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT
);
INSERT INTO event_config (id, couple_names)
VALUES (1, 'Stefanie & Leandro')
ON CONFLICT (id) DO NOTHING;
CREATE TABLE uploads (
id TEXT PRIMARY KEY,
storage_key TEXT NOT NULL,
thumbnail_key TEXT,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
duration_seconds INTEGER,
author_name TEXT,
message TEXT,
status TEXT NOT NULL DEFAULT 'approved',
source TEXT NOT NULL DEFAULT 'guest',
ip_hash TEXT,
provider_upload_id TEXT,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
approved_at BIGINT
);
CREATE INDEX idx_uploads_status_created ON uploads (status, created_at DESC);
CREATE INDEX idx_uploads_source ON uploads (source);
CREATE INDEX idx_uploads_author_lower ON uploads (LOWER(author_name));
CREATE TABLE audit_log (
id TEXT PRIMARY KEY,
action TEXT NOT NULL,
actor TEXT,
payload TEXT,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT
);

62
apps/api/src/db/schema.ts Normal file
View File

@ -0,0 +1,62 @@
import { sql } from 'drizzle-orm';
import { bigint, boolean, index, integer, pgTable, text } from 'drizzle-orm/pg-core';
export const eventConfig = pgTable('event_config', {
id: integer('id').primaryKey().default(1),
coupleNames: text('couple_names').notNull(),
eventDate: text('event_date'),
coverKey: text('cover_key'),
galleryVisibility: text('gallery_visibility', { enum: ['public', 'private'] })
.notNull()
.default('public'),
moderation: text('moderation', { enum: ['pre', 'post'] }).notNull().default('post'),
maxFileMb: integer('max_file_mb').notNull().default(500),
allowVideo: boolean('allow_video').notNull().default(true),
maxVideoSeconds: integer('max_video_seconds').notNull().default(300),
welcomeMessage: text('welcome_message'),
updatedAt: bigint('updated_at', { mode: 'number' })
.notNull()
.default(sql`(extract(epoch from now()) * 1000)::bigint`),
});
export const uploads = pgTable(
'uploads',
{
id: text('id').primaryKey(),
storageKey: text('storage_key').notNull(),
thumbnailKey: text('thumbnail_key'),
mimeType: text('mime_type').notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
durationSeconds: integer('duration_seconds'),
authorName: text('author_name'),
message: text('message'),
status: text('status', { enum: ['pending', 'approved', 'rejected'] })
.notNull()
.default('approved'),
source: text('source', { enum: ['guest', 'import'] }).notNull().default('guest'),
ipHash: text('ip_hash'),
providerUploadId: text('provider_upload_id'),
createdAt: bigint('created_at', { mode: 'number' })
.notNull()
.default(sql`(extract(epoch from now()) * 1000)::bigint`),
approvedAt: bigint('approved_at', { mode: 'number' }),
},
(t) => ({
statusCreatedIdx: index('idx_uploads_status_created').on(t.status, t.createdAt),
sourceIdx: index('idx_uploads_source').on(t.source),
}),
);
export const auditLog = pgTable('audit_log', {
id: text('id').primaryKey(),
action: text('action').notNull(),
actor: text('actor'),
payload: text('payload'),
createdAt: bigint('created_at', { mode: 'number' })
.notNull()
.default(sql`(extract(epoch from now()) * 1000)::bigint`),
});
export type EventConfigRow = typeof eventConfig.$inferSelect;
export type UploadRow = typeof uploads.$inferSelect;
export type NewUploadRow = typeof uploads.$inferInsert;

48
apps/api/src/env.ts Normal file
View File

@ -0,0 +1,48 @@
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().min(1),
S3_ENDPOINT: z.string().min(1),
S3_BUCKET: z.string().min(1),
S3_REGION: z.string().default('us-east-1'),
S3_ACCESS_KEY_ID: z.string().min(1),
S3_SECRET_ACCESS_KEY: z.string().min(1),
S3_PUBLIC_BASE_URL: z.string().min(1),
S3_FORCE_PATH_STYLE: z.enum(['true', 'false']).default('true'),
COUPLE_NAMES: z.string().default('Stefanie & Leandro'),
EVENT_DATE: z.string().optional(),
PUBLIC_BASE_URL: z.string().optional(),
ADMIN_PASSWORD: z.string().min(1),
SESSION_SECRET: z.string().min(16),
ALLOWED_ADMIN_EMAILS: z.string().min(1),
PORT: z.coerce.number().int().positive().default(3000),
STATIC_DIR: z.string().default('./public'),
NODE_ENV: z.enum(['development', 'production']).default('production'),
AUTO_MIGRATE: z.enum(['true', 'false']).default('true'),
TRUST_PROXY: z.enum(['true', 'false']).default('true'),
});
export type AppEnv = z.infer<typeof envSchema>;
export interface AppVariables {
adminEmail: string;
}
export type Bindings = {
Bindings: AppEnv;
Variables: AppVariables;
};
export function loadEnv(): AppEnv {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('Invalid environment variables:');
console.error(result.error.flatten().fieldErrors);
process.exit(1);
}
return result.data;
}

56
apps/api/src/lib/auth.ts Normal file
View File

@ -0,0 +1,56 @@
import type { Context, Next } from 'hono';
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import { sign, verify } from 'hono/jwt';
import type { Bindings } from '../env.js';
const COOKIE = 'wedding_admin';
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
interface SessionPayload {
email: string;
exp: number;
}
export async function requireAdmin(c: Context<Bindings>, next: Next) {
const token = getCookie(c, COOKIE);
if (!token) return c.json({ error: 'unauthorized' }, 401);
try {
const payload = (await verify(
token,
c.env.SESSION_SECRET,
'HS256',
)) as unknown as SessionPayload;
if (!payload.email) return c.json({ error: 'unauthorized' }, 401);
c.set('adminEmail', payload.email);
await next();
} catch {
return c.json({ error: 'unauthorized' }, 401);
}
}
export async function createSession(c: Context<Bindings>, email: string) {
const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;
const token = await sign({ email, exp }, c.env.SESSION_SECRET, 'HS256');
const secure = c.env.NODE_ENV === 'production';
setCookie(c, COOKIE, token, {
httpOnly: true,
secure,
sameSite: 'Lax',
path: '/',
maxAge: SESSION_TTL_SECONDS,
});
}
export function destroySession(c: Context<Bindings>) {
deleteCookie(c, COOKIE, { path: '/' });
}
export async function timingSafeEqual(a: string, b: string): Promise<boolean> {
if (a.length !== b.length) return false;
const enc = new TextEncoder();
const aBytes = enc.encode(a);
const bBytes = enc.encode(b);
let result = 0;
for (let i = 0; i < aBytes.length; i++) result |= aBytes[i]! ^ bBytes[i]!;
return result === 0;
}

21
apps/api/src/lib/ids.ts Normal file
View File

@ -0,0 +1,21 @@
import { customAlphabet } from 'nanoid';
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
const newId = customAlphabet(alphabet, 16);
export function uploadId(): string {
return `up_${newId()}`;
}
export function auditId(): string {
return `au_${newId()}`;
}
export async function hashIp(ip: string, salt: string): Promise<string> {
const data = new TextEncoder().encode(`${salt}:${ip}`);
const digest = await crypto.subtle.digest('SHA-256', data);
const bytes = new Uint8Array(digest);
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return hex.slice(0, 32);
}

View File

@ -0,0 +1,84 @@
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import { generateQrPng } from './qrcode.js';
export interface QrPdfOptions {
url: string;
coupleNames: string;
eventDate?: string;
callToAction?: string;
}
const PT_PER_MM = 2.834645669;
const A6_WIDTH = 105 * PT_PER_MM;
const A6_HEIGHT = 148 * PT_PER_MM;
function centerX(
text: string,
fontSize: number,
font: import('pdf-lib').PDFFont,
): number {
const w = font.widthOfTextAtSize(text, fontSize);
return (A6_WIDTH - w) / 2;
}
export async function generateQrPdf(opts: QrPdfOptions): Promise<Uint8Array> {
const pdf = await PDFDocument.create();
const page = pdf.addPage([A6_WIDTH, A6_HEIGHT]);
const titleFont = await pdf.embedFont(StandardFonts.TimesRomanItalic);
const bodyFont = await pdf.embedFont(StandardFonts.Helvetica);
const boldFont = await pdf.embedFont(StandardFonts.HelveticaBold);
const ink = rgb(0.18, 0.16, 0.14);
const muted = rgb(0.45, 0.42, 0.38);
const titleSize = 28;
const titleY = A6_HEIGHT - 28 * PT_PER_MM;
page.drawText(opts.coupleNames, {
x: centerX(opts.coupleNames, titleSize, titleFont),
y: titleY,
size: titleSize,
font: titleFont,
color: ink,
});
if (opts.eventDate) {
const dateSize = 11;
page.drawText(opts.eventDate, {
x: centerX(opts.eventDate, dateSize, bodyFont),
y: titleY - 7 * PT_PER_MM,
size: dateSize,
font: bodyFont,
color: muted,
});
}
const qrPng = await generateQrPng(opts.url, 600);
const qrImage = await pdf.embedPng(qrPng);
const qrSize = 70 * PT_PER_MM;
const qrX = (A6_WIDTH - qrSize) / 2;
const qrY = (A6_HEIGHT - qrSize) / 2 - 8 * PT_PER_MM;
page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize });
const cta = opts.callToAction ?? 'Aponte a câmera do celular';
const ctaSize = 12;
page.drawText(cta, {
x: centerX(cta, ctaSize, boldFont),
y: qrY - 9 * PT_PER_MM,
size: ctaSize,
font: boldFont,
color: ink,
});
const sub = 'para enviar fotos e mensagem';
const subSize = 10;
page.drawText(sub, {
x: centerX(sub, subSize, bodyFont),
y: qrY - 14 * PT_PER_MM,
size: subSize,
font: bodyFont,
color: muted,
});
return await pdf.save();
}

View File

@ -0,0 +1,19 @@
import QRCode from 'qrcode';
export async function generateQrPng(text: string, size = 512): Promise<Uint8Array> {
const dataUrl = await QRCode.toDataURL(text, {
errorCorrectionLevel: 'M',
margin: 2,
width: size,
});
const base64 = dataUrl.split(',')[1] ?? '';
return Uint8Array.from(Buffer.from(base64, 'base64'));
}
export function generateQrSvg(text: string): Promise<string> {
return QRCode.toString(text, {
type: 'svg',
errorCorrectionLevel: 'M',
margin: 2,
});
}

View File

@ -0,0 +1,26 @@
import type { AppEnv } from '../env.js';
import { S3Storage } from './storage-s3.js';
import type { StorageProvider } from './storage.js';
let storageInstance: StorageProvider | null = null;
export function initStorage(env: AppEnv): StorageProvider {
if (storageInstance) return storageInstance;
storageInstance = new S3Storage({
endpoint: env.S3_ENDPOINT,
bucket: env.S3_BUCKET,
region: env.S3_REGION,
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
publicBaseUrl: env.S3_PUBLIC_BASE_URL,
forcePathStyle: env.S3_FORCE_PATH_STYLE === 'true',
});
return storageInstance;
}
export function getStorage(): StorageProvider {
if (!storageInstance) {
throw new Error('Storage not initialized. Call initStorage() before getStorage().');
}
return storageInstance;
}

View File

@ -0,0 +1,164 @@
import { AwsClient } from 'aws4fetch';
import type {
CompleteMultipartInput,
InitMultipartInput,
InitMultipartResult,
ObjectMeta,
PresignPutInput,
PresignPutResult,
StorageProvider,
} from './storage.js';
export interface S3StorageConfig {
endpoint: string;
bucket: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
publicBaseUrl: string;
forcePathStyle?: boolean;
}
const DEFAULT_PART_SIZE = 10 * 1024 * 1024;
export class S3Storage implements StorageProvider {
private aws: AwsClient;
constructor(private config: S3StorageConfig) {
this.aws = new AwsClient({
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
service: 's3',
region: config.region,
});
}
private objectUrl(key: string, query?: string): string {
const base = this.config.endpoint.replace(/\/$/, '');
const path = this.config.forcePathStyle
? `${base}/${this.config.bucket}/${encodeKey(key)}`
: `${base}/${encodeKey(key)}`;
return query ? `${path}?${query}` : path;
}
async presignPut(input: PresignPutInput): Promise<PresignPutResult> {
const expires = input.expiresInSec ?? 600;
const url = this.objectUrl(input.key, `X-Amz-Expires=${expires}`);
const signed = await this.aws.sign(
new Request(url, {
method: 'PUT',
headers: { 'content-type': input.contentType },
}),
{ aws: { signQuery: true } },
);
return {
url: signed.url,
method: 'PUT',
headers: { 'content-type': input.contentType },
};
}
async initMultipart(input: InitMultipartInput): Promise<InitMultipartResult> {
const initUrl = this.objectUrl(input.key, 'uploads=');
const init = await this.aws.fetch(initUrl, {
method: 'POST',
headers: { 'content-type': input.contentType },
});
if (!init.ok) {
throw new Error(`initMultipart failed: ${init.status} ${await init.text()}`);
}
const xml = await init.text();
const uploadId = matchXml(xml, 'UploadId');
if (!uploadId) throw new Error('multipart upload init: missing UploadId');
const expires = input.expiresInSec ?? 3600;
const partUrls: string[] = [];
for (let i = 1; i <= input.partCount; i++) {
const url = this.objectUrl(
input.key,
`partNumber=${i}&uploadId=${encodeURIComponent(uploadId)}&X-Amz-Expires=${expires}`,
);
const signed = await this.aws.sign(new Request(url, { method: 'PUT' }), {
aws: { signQuery: true },
});
partUrls.push(signed.url);
}
return { uploadId, partSize: DEFAULT_PART_SIZE, partUrls };
}
async completeMultipart(input: CompleteMultipartInput): Promise<void> {
const sorted = [...input.parts].sort((a, b) => a.partNumber - b.partNumber);
const body =
`<CompleteMultipartUpload>` +
sorted
.map(
(p) =>
`<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${escapeXml(p.etag)}</ETag></Part>`,
)
.join('') +
`</CompleteMultipartUpload>`;
const url = this.objectUrl(input.key, `uploadId=${encodeURIComponent(input.uploadId)}`);
const res = await this.aws.fetch(url, { method: 'POST', body });
if (!res.ok) {
throw new Error(`completeMultipart failed: ${res.status} ${await res.text()}`);
}
}
async abortMultipart(input: { key: string; uploadId: string }): Promise<void> {
const url = this.objectUrl(input.key, `uploadId=${encodeURIComponent(input.uploadId)}`);
await this.aws.fetch(url, { method: 'DELETE' });
}
publicUrl(key: string): string {
return `${this.config.publicBaseUrl.replace(/\/$/, '')}/${encodeKey(key)}`;
}
async presignGet(key: string, expiresInSec = 3600): Promise<string> {
const url = this.objectUrl(key, `X-Amz-Expires=${expiresInSec}`);
const signed = await this.aws.sign(new Request(url, { method: 'GET' }), {
aws: { signQuery: true },
});
return signed.url;
}
async exists(key: string): Promise<boolean> {
const res = await this.aws.fetch(this.objectUrl(key), { method: 'HEAD' });
return res.ok;
}
async delete(key: string): Promise<void> {
const res = await this.aws.fetch(this.objectUrl(key), { method: 'DELETE' });
if (!res.ok && res.status !== 404) {
throw new Error(`delete failed: ${res.status}`);
}
}
async head(key: string): Promise<ObjectMeta | null> {
const res = await this.aws.fetch(this.objectUrl(key), { method: 'HEAD' });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`head failed: ${res.status}`);
return {
contentType: res.headers.get('content-type') ?? 'application/octet-stream',
contentLength: Number(res.headers.get('content-length') ?? 0),
etag: res.headers.get('etag') ?? '',
};
}
}
function encodeKey(key: string): string {
return key.split('/').map(encodeURIComponent).join('/');
}
function matchXml(xml: string, tag: string): string | null {
const m = xml.match(new RegExp(`<${tag}>([^<]+)</${tag}>`));
return m?.[1] ?? null;
}
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@ -0,0 +1,49 @@
export interface PresignPutInput {
key: string;
contentType: string;
contentLength?: number;
expiresInSec?: number;
}
export interface PresignPutResult {
url: string;
method: 'PUT';
headers: Record<string, string>;
}
export interface InitMultipartInput {
key: string;
contentType: string;
partCount: number;
expiresInSec?: number;
}
export interface InitMultipartResult {
uploadId: string;
partSize: number;
partUrls: string[];
}
export interface CompleteMultipartInput {
key: string;
uploadId: string;
parts: { partNumber: number; etag: string }[];
}
export interface ObjectMeta {
contentType: string;
contentLength: number;
etag: string;
}
export interface StorageProvider {
presignPut(input: PresignPutInput): Promise<PresignPutResult>;
initMultipart(input: InitMultipartInput): Promise<InitMultipartResult>;
completeMultipart(input: CompleteMultipartInput): Promise<void>;
abortMultipart(input: { key: string; uploadId: string }): Promise<void>;
publicUrl(key: string): string;
presignGet(key: string, expiresInSec?: number): Promise<string>;
exists(key: string): Promise<boolean>;
delete(key: string): Promise<void>;
head(key: string): Promise<ObjectMeta | null>;
}

View File

@ -0,0 +1,322 @@
import { and, desc, eq, ilike, inArray, like, lt, or } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import {
adminBulkSchema,
adminUploadUpdateSchema,
eventConfigUpdateSchema,
type AdminStats,
type AdminUploadsResponse,
} from '@leetete/shared';
import { getDb } from '../db/client.js';
import { eventConfig, uploads } from '../db/schema.js';
import type { Bindings } from '../env.js';
import {
createSession,
destroySession,
requireAdmin,
timingSafeEqual,
} from '../lib/auth.js';
import { getStorage } from '../lib/storage-factory.js';
export const adminRoutes = new Hono<Bindings>();
const ADMIN_PAGE_SIZE = 30;
const loginSchema = z.object({
email: z.string().email().toLowerCase(),
password: z.string().min(1),
});
adminRoutes.post('/login', async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'invalid_body' }, 400);
}
const allowed = c.env.ALLOWED_ADMIN_EMAILS.split(',').map((e) => e.trim().toLowerCase());
const emailOk = allowed.includes(parsed.data.email);
const passwordOk = await timingSafeEqual(parsed.data.password, c.env.ADMIN_PASSWORD);
if (!emailOk || !passwordOk) {
return c.json({ error: 'unauthorized' }, 401);
}
await createSession(c, parsed.data.email);
return c.json({ ok: true, email: parsed.data.email });
});
adminRoutes.post('/logout', async (c) => {
destroySession(c);
return c.json({ ok: true });
});
adminRoutes.use('*', requireAdmin);
adminRoutes.get('/me', (c) => c.json({ email: c.get('adminEmail') }));
adminRoutes.get('/event', async (c) => {
const db = getDb();
const rows = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const row = rows[0];
if (!row) return c.json({ error: 'not_configured' }, 404);
const storage = getStorage();
return c.json({
coupleNames: row.coupleNames,
eventDate: row.eventDate,
coverKey: row.coverKey,
coverUrl: row.coverKey ? storage.publicUrl(row.coverKey) : null,
galleryVisibility: row.galleryVisibility,
moderation: row.moderation,
maxFileMb: row.maxFileMb,
allowVideo: row.allowVideo,
maxVideoSeconds: row.maxVideoSeconds,
welcomeMessage: row.welcomeMessage,
updatedAt: row.updatedAt,
});
});
adminRoutes.patch('/event', async (c) => {
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();
await db
.update(eventConfig)
.set({ ...parsed.data, updatedAt: Date.now() })
.where(eq(eventConfig.id, 1));
return c.json({ ok: true });
});
adminRoutes.get('/uploads', async (c) => {
const db = getDb();
const status = c.req.query('status') as 'pending' | 'approved' | 'rejected' | undefined;
const kind = c.req.query('kind') as 'photo' | 'video' | undefined;
const q = c.req.query('q')?.trim();
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));
}
if (kind === 'photo') filters.push(like(uploads.mimeType, 'image/%'));
if (kind === 'video') filters.push(like(uploads.mimeType, 'video/%'));
if (q && q.length > 0) {
const pattern = `%${q.replace(/[%_]/g, (m) => `\\${m}`)}%`;
const cond = or(
ilike(uploads.authorName, pattern),
ilike(uploads.message, pattern),
);
if (cond) filters.push(cond);
}
const where = filters.length ? and(...filters) : undefined;
const rows = await db
.select()
.from(uploads)
.where(where)
.orderBy(desc(uploads.createdAt))
.limit(limit + 1);
const hasMore = rows.length > limit;
const slice = hasMore ? rows.slice(0, limit) : rows;
const storage = getStorage();
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const coverKey = cfg[0]?.coverKey ?? null;
const items = slice.map((r) => ({
id: r.id,
url: storage.publicUrl(r.storageKey),
thumbnailUrl: r.thumbnailKey ? storage.publicUrl(r.thumbnailKey) : null,
storageKey: r.storageKey,
mimeType: r.mimeType,
isVideo: r.mimeType.startsWith('video/'),
sizeBytes: r.sizeBytes,
durationSeconds: r.durationSeconds,
authorName: r.authorName,
message: r.message,
status: r.status,
source: r.source,
createdAt: r.createdAt,
approvedAt: r.approvedAt,
isCover: coverKey === r.storageKey,
}));
const nextCursor = hasMore ? String(slice[slice.length - 1]!.createdAt) : null;
return c.json({ items, nextCursor } satisfies AdminUploadsResponse);
});
adminRoutes.patch('/uploads/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json().catch(() => null);
const parsed = adminUploadUpdateSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
}
const updates: Record<string, string | null> = {};
if (parsed.data.authorName !== undefined) {
updates['authorName'] = parsed.data.authorName?.trim() || null;
}
if (parsed.data.message !== undefined) {
updates['message'] = parsed.data.message?.trim() || null;
}
if (Object.keys(updates).length === 0) {
return c.json({ ok: true, noop: true });
}
const db = getDb();
const result = await db
.update(uploads)
.set(updates)
.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/approve', async (c) => {
const id = c.req.param('id');
const db = getDb();
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) => {
const id = c.req.param('id');
const db = getDb();
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.post('/uploads/:id/cover', async (c) => {
const id = c.req.param('id');
const db = getDb();
const rows = await db.select().from(uploads).where(eq(uploads.id, id));
const row = rows[0];
if (!row) return c.json({ error: 'not_found' }, 404);
await db
.update(eventConfig)
.set({ coverKey: row.storageKey, updatedAt: Date.now() })
.where(eq(eventConfig.id, 1));
return c.json({ ok: true });
});
adminRoutes.delete('/event/cover', async (c) => {
const db = getDb();
await db
.update(eventConfig)
.set({ coverKey: null, updatedAt: Date.now() })
.where(eq(eventConfig.id, 1));
return c.json({ ok: true });
});
adminRoutes.delete('/uploads/:id', async (c) => {
const id = c.req.param('id');
const db = getDb();
const rows = await db.select().from(uploads).where(eq(uploads.id, id));
const row = rows[0];
if (!row) return c.json({ error: 'not_found' }, 404);
const storage = getStorage();
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));
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
if (cfg[0]?.coverKey === row.storageKey) {
await db.update(eventConfig).set({ coverKey: null }).where(eq(eventConfig.id, 1));
}
return c.json({ ok: true });
});
adminRoutes.post('/uploads/bulk', async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = adminBulkSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
}
const db = getDb();
const storage = getStorage();
if (parsed.data.action === 'delete') {
const rows = await db
.select()
.from(uploads)
.where(inArray(uploads.id, parsed.data.ids));
await Promise.all(
rows.map((r) => storage.delete(r.storageKey).catch(() => {})),
);
await db.delete(uploads).where(inArray(uploads.id, parsed.data.ids));
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
if (cfg[0]?.coverKey && rows.some((r) => r.storageKey === cfg[0]!.coverKey)) {
await db.update(eventConfig).set({ coverKey: null }).where(eq(eventConfig.id, 1));
}
} else {
const status = parsed.data.action === 'approve' ? 'approved' : 'rejected';
const approvedAt = status === 'approved' ? Date.now() : null;
await db
.update(uploads)
.set({ status, approvedAt })
.where(inArray(uploads.id, parsed.data.ids));
}
return c.json({ ok: true, count: parsed.data.ids.length });
});
adminRoutes.get('/stats', async (c) => {
const db = getDb();
const all = await db
.select({
status: uploads.status,
mimeType: uploads.mimeType,
sizeBytes: uploads.sizeBytes,
})
.from(uploads);
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);
});
adminRoutes.get('/export.zip', async (c) => {
return c.json({ error: 'not_implemented_yet' }, 501);
});
adminRoutes.post('/imports', async (c) => {
return c.json({ error: 'not_implemented_yet' }, 501);
});

View File

@ -0,0 +1,131 @@
import { and, desc, eq, lt } from 'drizzle-orm';
import { Hono } from 'hono';
import type { GalleryResponse } from '@leetete/shared';
import { getDb } from '../db/client.js';
import { eventConfig, uploads } from '../db/schema.js';
import type { Bindings } from '../env.js';
import { generateQrPdf } from '../lib/qr-pdf.js';
import { generateQrPng, generateQrSvg } from '../lib/qrcode.js';
import { getStorage } from '../lib/storage-factory.js';
export const publicRoutes = new Hono<Bindings>();
const GALLERY_PAGE_SIZE = 24;
publicRoutes.get('/event', async (c) => {
const db = getDb();
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const row = cfg[0];
if (!row) return c.json({ error: 'not_configured' }, 404);
const storage = getStorage();
return c.json({
coupleNames: row.coupleNames,
eventDate: row.eventDate,
welcomeMessage: row.welcomeMessage,
galleryVisibility: row.galleryVisibility,
allowVideo: row.allowVideo,
maxFileMb: row.maxFileMb,
maxVideoSeconds: row.maxVideoSeconds,
coverUrl: row.coverKey ? storage.publicUrl(row.coverKey) : null,
});
});
publicRoutes.get('/gallery', async (c) => {
const db = getDb();
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const row = cfg[0];
if (!row || row.galleryVisibility !== 'public') {
return c.json({ items: [], nextCursor: null } satisfies GalleryResponse);
}
const cursorParam = c.req.query('cursor');
const cursor = cursorParam ? Number(cursorParam) : null;
const limit = Math.min(Number(c.req.query('limit') ?? GALLERY_PAGE_SIZE), 60);
const baseCondition = eq(uploads.status, 'approved');
const where =
cursor !== null && Number.isFinite(cursor)
? and(baseCondition, lt(uploads.createdAt, cursor))
: baseCondition;
const rows = await db
.select()
.from(uploads)
.where(where)
.orderBy(desc(uploads.createdAt))
.limit(limit + 1);
const hasMore = rows.length > limit;
const slice = hasMore ? rows.slice(0, limit) : rows;
const storage = getStorage();
const items = slice.map((r) => ({
id: r.id,
url: storage.publicUrl(r.storageKey),
thumbnailUrl: r.thumbnailKey ? storage.publicUrl(r.thumbnailKey) : null,
mimeType: r.mimeType,
isVideo: r.mimeType.startsWith('video/'),
authorName: r.authorName,
message: r.message,
createdAt: r.createdAt,
}));
const nextCursor = hasMore ? String(slice[slice.length - 1]!.createdAt) : null;
return c.json({ items, nextCursor } satisfies GalleryResponse);
});
publicRoutes.get('/qrcode', async (c) => {
const format = (c.req.query('format') ?? 'png').toLowerCase();
const overrideUrl = c.req.query('url');
const reqUrl = new URL(c.req.url);
const base = overrideUrl ?? c.env.PUBLIC_BASE_URL ?? `${reqUrl.protocol}//${reqUrl.host}`;
const target = base.replace(/\/$/, '') + '/enviar';
if (format === 'svg') {
const svg = await generateQrSvg(target);
return new Response(svg, {
headers: {
'content-type': 'image/svg+xml; charset=utf-8',
'cache-control': 'public, max-age=300',
},
});
}
if (format === 'pdf') {
const db = getDb();
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const row = cfg[0];
const pdf = await generateQrPdf({
url: target,
coupleNames: row?.coupleNames ?? c.env.COUPLE_NAMES,
eventDate: row?.eventDate ?? c.env.EVENT_DATE,
});
return new Response(pdf, {
headers: {
'content-type': 'application/pdf',
'content-disposition': 'inline; filename="qr-mesa.pdf"',
'cache-control': 'no-store',
},
});
}
const png = await generateQrPng(target, 800);
return new Response(png, {
headers: {
'content-type': 'image/png',
'cache-control': 'public, max-age=300',
},
});
});
publicRoutes.get('/stats', async (c) => {
const db = getDb();
const all = await db
.select({ id: uploads.id, mimeType: uploads.mimeType })
.from(uploads)
.where(eq(uploads.status, 'approved'));
const photos = all.filter((u) => u.mimeType.startsWith('image/')).length;
const videos = all.filter((u) => u.mimeType.startsWith('video/')).length;
return c.json({ photos, videos, total: all.length });
});

View File

@ -0,0 +1,220 @@
import { eq } from 'drizzle-orm';
import { Hono, type Context } from 'hono';
import {
uploadConfirmSchema,
uploadInitSchema,
type UploadInitResponse,
} from '@leetete/shared';
import { getDb } from '../db/client.js';
import { eventConfig, uploads } from '../db/schema.js';
import type { Bindings } from '../env.js';
import { hashIp, uploadId } from '../lib/ids.js';
import { getStorage } from '../lib/storage-factory.js';
export const uploadsRoutes = new Hono<Bindings>();
const SINGLE_PUT_MAX = 50 * 1024 * 1024;
const PART_SIZE = 10 * 1024 * 1024;
const KEY_PREFIX = 'uploads';
const IP_HASH_SALT = 'wedding-uploads-v1';
function extFromFilename(filename: string, mimeType: string): string {
const dot = filename.lastIndexOf('.');
if (dot >= 0 && dot < filename.length - 1) {
const ext = filename.slice(dot + 1).toLowerCase();
if (/^[a-z0-9]{1,6}$/.test(ext)) return ext;
}
const map: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/heic': 'heic',
'image/heif': 'heif',
'image/gif': 'gif',
'video/mp4': 'mp4',
'video/quicktime': 'mov',
'video/webm': 'webm',
};
return map[mimeType.toLowerCase()] ?? 'bin';
}
function buildKey(id: string, filename: string, mimeType: string): string {
const now = new Date();
const yyyy = now.getUTCFullYear();
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
const ext = extFromFilename(filename, mimeType);
return `${KEY_PREFIX}/${yyyy}/${mm}/${id}.${ext}`;
}
function getClientIp(c: Context<Bindings>): string {
return (
c.req.header('cf-connecting-ip') ??
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ??
c.req.header('x-real-ip') ??
'unknown'
);
}
uploadsRoutes.post('/init', async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = uploadInitSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
}
const input = parsed.data;
const db = getDb();
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const event = cfg[0];
if (!event) return c.json({ error: 'not_configured' }, 500);
const isVideo = input.mimeType.startsWith('video/');
if (isVideo && !event.allowVideo) {
return c.json({ error: 'video_not_allowed' }, 400);
}
if (input.sizeBytes > event.maxFileMb * 1024 * 1024) {
return c.json({ error: 'file_too_large', maxFileMb: event.maxFileMb }, 400);
}
if (
isVideo &&
input.durationSeconds !== undefined &&
input.durationSeconds > event.maxVideoSeconds
) {
return c.json({ error: 'video_too_long', maxVideoSeconds: event.maxVideoSeconds }, 400);
}
const id = uploadId();
const storageKey = buildKey(id, input.filename, input.mimeType);
const storage = getStorage();
const ipHashValue = await hashIp(getClientIp(c), IP_HASH_SALT);
if (input.sizeBytes <= SINGLE_PUT_MAX) {
const presigned = await storage.presignPut({
key: storageKey,
contentType: input.mimeType,
contentLength: input.sizeBytes,
expiresInSec: 900,
});
await db.insert(uploads).values({
id,
storageKey,
mimeType: input.mimeType,
sizeBytes: input.sizeBytes,
durationSeconds: input.durationSeconds ?? null,
authorName: input.authorName?.trim() || null,
message: input.message?.trim() || null,
status: 'pending',
source: 'guest',
ipHash: ipHashValue,
});
const response: UploadInitResponse = {
uploadId: id,
storageKey,
mode: 'single',
putUrl: presigned.url,
putHeaders: presigned.headers,
};
return c.json(response);
}
const partCount = Math.ceil(input.sizeBytes / PART_SIZE);
const multipart = await storage.initMultipart({
key: storageKey,
contentType: input.mimeType,
partCount,
expiresInSec: 3600 * 6,
});
await db.insert(uploads).values({
id,
storageKey,
mimeType: input.mimeType,
sizeBytes: input.sizeBytes,
durationSeconds: input.durationSeconds ?? null,
authorName: input.authorName?.trim() || null,
message: input.message?.trim() || null,
status: 'pending',
source: 'guest',
ipHash: ipHashValue,
providerUploadId: multipart.uploadId,
});
const response: UploadInitResponse = {
uploadId: id,
storageKey,
mode: 'multipart',
multipart: {
providerUploadId: multipart.uploadId,
partSize: multipart.partSize,
partUrls: multipart.partUrls,
},
};
return c.json(response);
});
uploadsRoutes.post('/:id/confirm', async (c) => {
const id = c.req.param('id');
const body = await c.req.json().catch(() => ({}));
const parsed = uploadConfirmSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
}
const db = getDb();
const rows = await db.select().from(uploads).where(eq(uploads.id, id));
const upload = rows[0];
if (!upload) return c.json({ error: 'not_found' }, 404);
if (upload.status === 'approved') return c.json({ ok: true, alreadyApproved: true });
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const event = cfg[0];
if (!event) return c.json({ error: 'not_configured' }, 500);
const storage = getStorage();
if (parsed.data.multipart) {
if (parsed.data.multipart.providerUploadId !== upload.providerUploadId) {
return c.json({ error: 'multipart_mismatch' }, 400);
}
await storage.completeMultipart({
key: upload.storageKey,
uploadId: parsed.data.multipart.providerUploadId,
parts: parsed.data.multipart.parts,
});
}
const head = await storage.head(upload.storageKey);
if (!head) {
return c.json({ error: 'not_uploaded' }, 400);
}
const finalStatus = event.moderation === 'pre' ? 'pending' : 'approved';
await db
.update(uploads)
.set({
status: finalStatus,
approvedAt: finalStatus === 'approved' ? Date.now() : null,
})
.where(eq(uploads.id, id));
return c.json({ ok: true, status: finalStatus });
});
uploadsRoutes.post('/:id/abort', async (c) => {
const id = c.req.param('id');
const db = getDb();
const rows = await db.select().from(uploads).where(eq(uploads.id, id));
const upload = rows[0];
if (!upload) return c.json({ error: 'not_found' }, 404);
if (upload.providerUploadId) {
const storage = getStorage();
await storage
.abortMultipart({ key: upload.storageKey, uploadId: upload.providerUploadId })
.catch(() => {});
}
await db.delete(uploads).where(eq(uploads.id, id));
return c.json({ ok: true });
});

65
apps/api/src/server.ts Normal file
View File

@ -0,0 +1,65 @@
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
import { createApp } from './app.js';
import { closeDb, initDb } from './db/client.js';
import { runMigrations } from './db/migrate.js';
import type { Bindings } from './env.js';
import { loadEnv } from './env.js';
import { initStorage } from './lib/storage-factory.js';
async function main() {
const env = loadEnv();
if (env.AUTO_MIGRATE === 'true') {
console.log('[startup] running migrations');
await runMigrations(env.DATABASE_URL);
}
initDb(env.DATABASE_URL);
initStorage(env);
const apiApp = createApp();
const app = new Hono<Bindings>();
app.route('/', apiApp);
app.use(
'/assets/*',
serveStatic({
root: env.STATIC_DIR,
rewriteRequestPath: (path) => path,
}),
);
for (const f of ['/favicon.ico', '/robots.txt']) {
app.get(f, serveStatic({ path: `${env.STATIC_DIR}${f}` }));
}
app.get('*', serveStatic({ path: `${env.STATIC_DIR}/index.html` }));
const server = serve(
{
fetch: (req) => app.fetch(req, env),
port: env.PORT,
hostname: '0.0.0.0',
},
(info) => {
console.log(`[startup] listening on http://0.0.0.0:${info.port}`);
},
);
const shutdown = async (signal: string) => {
console.log(`[shutdown] received ${signal}`);
server.close();
await closeDb();
process.exit(0);
};
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
}
main().catch((err) => {
console.error('[startup] failed', err);
process.exit(1);
});

8
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"],
"noEmit": true
},
"include": ["src/**/*"]
}

13
apps/web/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<meta name="theme-color" content="#fdf6ec" />
<title>Stefanie & Leandro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
apps/web/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "@leetete/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@leetete/shared": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

18
apps/web/src/App.tsx Normal file
View File

@ -0,0 +1,18 @@
import { Route, Routes } from 'react-router-dom';
import AdminDashboard from './routes/admin/Dashboard.js';
import AdminLogin from './routes/admin/Login.js';
import Gallery from './routes/Gallery.js';
import Home from './routes/Home.js';
import Upload from './routes/Upload.js';
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/enviar" element={<Upload />} />
<Route path="/galeria" element={<Gallery />} />
<Route path="/admin/login" element={<AdminLogin />} />
<Route path="/admin/*" element={<AdminDashboard />} />
</Routes>
);
}

15
apps/web/src/index.css Normal file
View File

@ -0,0 +1,15 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
}
body {
font-family: theme('fontFamily.sans');
background: theme('colors.cream');
color: #2c2a26;
}

23
apps/web/src/lib/api.ts Normal file
View File

@ -0,0 +1,23 @@
const API_BASE = '/api';
export class ApiError extends Error {
constructor(
public status: number,
public body: string,
) {
super(`API ${status}: ${body}`);
}
}
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
...init,
headers: {
'content-type': 'application/json',
...(init?.headers ?? {}),
},
});
if (!res.ok) throw new ApiError(res.status, await res.text());
return (await res.json()) as T;
}

143
apps/web/src/lib/upload.ts Normal file
View File

@ -0,0 +1,143 @@
import type { UploadInit, UploadInitResponse } from '@leetete/shared';
export type UploadProgress = (loaded: number, total: number) => void;
export interface UploadOptions {
signal?: AbortSignal;
onProgress?: UploadProgress;
}
export interface UploadOk {
uploadId: string;
}
async function getDuration(file: File): Promise<number | undefined> {
if (!file.type.startsWith('video/')) return undefined;
return await new Promise<number | undefined>((resolve) => {
const url = URL.createObjectURL(file);
const v = document.createElement('video');
v.preload = 'metadata';
v.src = url;
v.onloadedmetadata = () => {
URL.revokeObjectURL(url);
resolve(Number.isFinite(v.duration) ? Math.round(v.duration) : undefined);
};
v.onerror = () => {
URL.revokeObjectURL(url);
resolve(undefined);
};
});
}
function putWithProgress(
url: string,
body: Blob,
headers: Record<string, string>,
onProgress?: UploadProgress,
signal?: AbortSignal,
): Promise<{ etag: string }> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress?.(e.loaded, e.total);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const etag = xhr.getResponseHeader('etag') ?? xhr.getResponseHeader('ETag') ?? '';
resolve({ etag: etag.replace(/^"|"$/g, '') });
} else {
reject(new Error(`PUT failed: ${xhr.status} ${xhr.statusText}`));
}
};
xhr.onerror = () => reject(new Error('network error'));
xhr.onabort = () => reject(new DOMException('aborted', 'AbortError'));
if (signal) {
if (signal.aborted) {
xhr.abort();
return;
}
signal.addEventListener('abort', () => xhr.abort(), { once: true });
}
xhr.send(body);
});
}
export async function uploadFile(
file: File,
meta: { authorName?: string; message?: string },
opts: UploadOptions = {},
): Promise<UploadOk> {
const duration = await getDuration(file);
const initBody: UploadInit = {
filename: file.name || 'upload',
mimeType: file.type || 'application/octet-stream',
sizeBytes: file.size,
durationSeconds: duration,
authorName: meta.authorName?.trim() || undefined,
message: meta.message?.trim() || undefined,
};
const initRes = await fetch('/api/uploads/init', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(initBody),
signal: opts.signal,
});
if (!initRes.ok) {
const text = await initRes.text();
throw new Error(`init failed: ${initRes.status} ${text}`);
}
const init = (await initRes.json()) as UploadInitResponse;
if (init.mode === 'single') {
if (!init.putUrl) throw new Error('init: missing putUrl');
await putWithProgress(
init.putUrl,
file,
init.putHeaders ?? { 'content-type': initBody.mimeType },
opts.onProgress,
opts.signal,
);
} else if (init.mode === 'multipart' && init.multipart) {
const { partSize, partUrls, providerUploadId } = init.multipart;
const parts: { partNumber: number; etag: string }[] = [];
let uploaded = 0;
for (let i = 0; i < partUrls.length; i++) {
const start = i * partSize;
const end = Math.min(start + partSize, file.size);
const blob = file.slice(start, end);
const partProgress: UploadProgress = (loaded) => {
opts.onProgress?.(uploaded + loaded, file.size);
};
const url = partUrls[i];
if (!url) throw new Error(`missing part url for part ${i + 1}`);
const { etag } = await putWithProgress(url, blob, {}, partProgress, opts.signal);
uploaded += blob.size;
opts.onProgress?.(uploaded, file.size);
if (!etag) throw new Error(`part ${i + 1}: missing ETag (CORS exposes ETag?)`);
parts.push({ partNumber: i + 1, etag });
}
const confirmRes = await fetch(`/api/uploads/${init.uploadId}/confirm`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ multipart: { providerUploadId, parts } }),
signal: opts.signal,
});
if (!confirmRes.ok) throw new Error(`confirm failed: ${confirmRes.status}`);
return { uploadId: init.uploadId };
} else {
throw new Error('init: invalid mode');
}
const confirmRes = await fetch(`/api/uploads/${init.uploadId}/confirm`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
signal: opts.signal,
});
if (!confirmRes.ok) throw new Error(`confirm failed: ${confirmRes.status}`);
return { uploadId: init.uploadId };
}

16
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.js';
import './index.css';
const root = document.getElementById('root');
if (!root) throw new Error('root element not found');
ReactDOM.createRoot(root).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);

View File

@ -0,0 +1,157 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import type { GalleryItem, GalleryResponse } from '@leetete/shared';
export default function Gallery() {
const [items, setItems] = useState<GalleryItem[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [active, setActive] = useState<GalleryItem | null>(null);
async function loadMore(reset = false) {
if (loading) return;
setLoading(true);
setError(null);
try {
const url = new URL('/api/gallery', window.location.origin);
if (!reset && cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url);
if (!res.ok) throw new Error(`API ${res.status}`);
const data = (await res.json()) as GalleryResponse;
setItems((prev) => (reset ? data.items : [...prev, ...data.items]));
setCursor(data.nextCursor);
if (!data.nextCursor) setDone(true);
} catch (e) {
setError(e instanceof Error ? e.message : 'erro');
} finally {
setLoading(false);
}
}
useEffect(() => {
loadMore(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className="min-h-full px-4 py-8 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<Link to="/" className="text-sm text-stone-500 hover:text-stone-700">
Voltar
</Link>
<Link
to="/enviar"
className="text-sm bg-stone-800 text-cream rounded-full py-2 px-4 hover:bg-stone-700"
>
+ Enviar
</Link>
</div>
<h2 className="font-display text-3xl text-stone-800 mb-6">Galeria</h2>
{items.length === 0 && !loading && !error && (
<div className="text-center text-stone-600 py-16">
Ainda sem fotos. Seja a primeira pessoa a compartilhar!
</div>
)}
{error && (
<div className="text-rose-700 bg-rose-50 border border-rose-200 rounded p-3 text-sm mb-4">
{error}
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setActive(item)}
className="relative aspect-square overflow-hidden rounded-md bg-stone-200 group"
>
{item.isVideo ? (
<>
<video
src={item.url}
preload="metadata"
muted
className="w-full h-full object-cover"
/>
<span className="absolute inset-0 flex items-center justify-center bg-black/30 text-white text-2xl">
</span>
</>
) : (
<img
src={item.url}
alt={item.message ?? ''}
loading="lazy"
className="w-full h-full object-cover transition group-hover:scale-105"
/>
)}
</button>
))}
</div>
{!done && (
<div className="text-center mt-8">
<button
type="button"
onClick={() => loadMore(false)}
disabled={loading}
className="border border-stone-400 text-stone-700 rounded-full py-2 px-6 disabled:opacity-50 hover:bg-white/40"
>
{loading ? 'Carregando…' : 'Carregar mais'}
</button>
</div>
)}
{active && (
<div
className="fixed inset-0 z-50 bg-black/85 flex flex-col"
onClick={() => setActive(null)}
>
<button
type="button"
className="self-end m-4 text-white text-2xl leading-none"
onClick={() => setActive(null)}
aria-label="Fechar"
>
×
</button>
<div
className="flex-1 flex items-center justify-center px-4"
onClick={(e) => e.stopPropagation()}
>
{active.isVideo ? (
<video
src={active.url}
controls
autoPlay
className="max-h-full max-w-full rounded"
/>
) : (
<img
src={active.url}
alt={active.message ?? ''}
className="max-h-full max-w-full rounded object-contain"
/>
)}
</div>
{(active.authorName || active.message) && (
<div
className="bg-black/60 text-cream px-6 py-4 text-sm"
onClick={(e) => e.stopPropagation()}
>
{active.authorName && (
<p className="font-medium">{active.authorName}</p>
)}
{active.message && <p className="text-stone-200 mt-1">{active.message}</p>}
</div>
)}
</div>
)}
</main>
);
}

View File

@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../lib/api.js';
interface EventInfo {
coupleNames: string;
eventDate: string | null;
welcomeMessage: string | null;
coverUrl: string | null;
}
export default function Home() {
const [event, setEvent] = useState<EventInfo | null>(null);
useEffect(() => {
api<EventInfo>('/event')
.then(setEvent)
.catch(() => setEvent(null));
}, []);
return (
<main className="min-h-full flex flex-col items-center justify-center px-6 py-12 text-center">
{event?.coverUrl && (
<div className="w-full max-w-md mb-8 overflow-hidden rounded-xl shadow-sm">
<img
src={event.coverUrl}
alt=""
className="w-full aspect-[4/3] object-cover"
/>
</div>
)}
<h1 className="font-display text-5xl text-stone-800 mb-3">
{event?.coupleNames ?? 'Stefanie & Leandro'}
</h1>
{event?.eventDate ? (
<p className="text-stone-600 mb-8">{event.eventDate}</p>
) : null}
<p className="max-w-md text-stone-700 mb-10">
{event?.welcomeMessage ??
'Compartilhe suas fotos, vídeos e mensagens deste dia especial.'}
</p>
<div className="flex flex-col gap-3 w-full max-w-xs">
<Link
to="/enviar"
className="bg-stone-800 text-cream rounded-full py-3 px-6 hover:bg-stone-700 transition"
>
Enviar fotos e mensagem
</Link>
<Link
to="/galeria"
className="border border-stone-400 text-stone-700 rounded-full py-3 px-6 hover:bg-white/40 transition"
>
Ver galeria
</Link>
</div>
</main>
);
}

View File

@ -0,0 +1,262 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { uploadFile } from '../lib/upload.js';
interface EventInfo {
coupleNames: string;
allowVideo: boolean;
maxFileMb: number;
maxVideoSeconds: number;
}
type Status = 'idle' | 'preparing' | 'uploading' | 'success' | 'error';
const MB = 1024 * 1024;
function formatBytes(bytes: number): string {
if (bytes < MB) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / MB).toFixed(1)} MB`;
}
export default function Upload() {
const [event, setEvent] = useState<EventInfo | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [authorName, setAuthorName] = useState('');
const [message, setMessage] = useState('');
const [status, setStatus] = useState<Status>('idle');
const [progress, setProgress] = useState({ index: 0, loaded: 0, total: 0 });
const [error, setError] = useState<string | null>(null);
const [successCount, setSuccessCount] = useState(0);
const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
fetch('/api/event')
.then((r) => (r.ok ? (r.json() as Promise<EventInfo>) : null))
.then(setEvent)
.catch(() => setEvent(null));
}, []);
function reset() {
setFiles([]);
setStatus('idle');
setProgress({ index: 0, loaded: 0, total: 0 });
setError(null);
setSuccessCount(0);
if (inputRef.current) inputRef.current.value = '';
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!files.length || status === 'uploading') return;
const ac = new AbortController();
abortRef.current = ac;
setStatus('uploading');
setError(null);
setSuccessCount(0);
try {
for (let i = 0; i < files.length; i++) {
const file = files[i]!;
setProgress({ index: i, loaded: 0, total: file.size });
await uploadFile(
file,
{ authorName, message: i === 0 ? message : undefined },
{
signal: ac.signal,
onProgress: (loaded, total) => setProgress({ index: i, loaded, total }),
},
);
setSuccessCount((n) => n + 1);
}
setStatus('success');
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') {
setStatus('idle');
return;
}
console.error(err);
setError(humanError(err));
setStatus('error');
} finally {
abortRef.current = null;
}
}
function cancelUpload() {
abortRef.current?.abort();
}
const totalBytes = files.reduce((s, f) => s + f.size, 0);
const overLimit =
event &&
files.some(
(f) =>
f.size > event.maxFileMb * MB ||
(f.type.startsWith('video/') && !event.allowVideo),
);
if (status === 'success') {
return (
<main className="min-h-full flex flex-col items-center justify-center px-6 py-12 text-center">
<h2 className="font-display text-4xl text-stone-800 mb-3">Obrigado!</h2>
<p className="max-w-md text-stone-700 mb-8">
{successCount === 1
? 'Sua foto/vídeo foi enviado com sucesso.'
: `${successCount} arquivos enviados com sucesso.`}
{message ? ' Sua mensagem aos noivos foi guardada.' : ''}
</p>
<div className="flex flex-col gap-3 w-full max-w-xs">
<button
type="button"
onClick={reset}
className="bg-stone-800 text-cream rounded-full py-3 px-6 hover:bg-stone-700 transition"
>
Enviar mais
</button>
<Link
to="/galeria"
className="border border-stone-400 text-stone-700 rounded-full py-3 px-6 hover:bg-white/40 transition"
>
Ver galeria
</Link>
</div>
</main>
);
}
return (
<main className="min-h-full px-6 py-10 max-w-xl mx-auto">
<Link to="/" className="text-sm text-stone-500 hover:text-stone-700">
Voltar
</Link>
<h2 className="font-display text-3xl text-stone-800 mt-4 mb-2">Enviar fotos</h2>
<p className="text-stone-600 mb-6 text-sm">
Compartilhe momentos do casamento de {event?.coupleNames ?? 'Stefanie & Leandro'}.
{event?.allowVideo
? ` Vídeos curtos (até ${Math.floor(event.maxVideoSeconds / 60)} min) também são bem-vindos.`
: ' Apenas fotos por enquanto.'}
</p>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-stone-700 mb-2">
Foto ou vídeo
</label>
<input
ref={inputRef}
type="file"
accept={event?.allowVideo ? 'image/*,video/*' : 'image/*'}
multiple
disabled={status === 'uploading'}
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
className="block w-full text-sm text-stone-700 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-medium file:bg-stone-200 file:text-stone-700 hover:file:bg-stone-300"
/>
{files.length > 0 && (
<p className="mt-2 text-xs text-stone-500">
{files.length} arquivo{files.length > 1 ? 's' : ''} ({formatBytes(totalBytes)})
</p>
)}
{overLimit && (
<p className="mt-2 text-xs text-rose-700">
Algum arquivo excede o limite ({event?.maxFileMb} MB) ou é um vídeo (não permitido).
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-2">
Seu nome <span className="font-normal text-stone-500">(opcional)</span>
</label>
<input
type="text"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
disabled={status === 'uploading'}
maxLength={80}
placeholder="Como você quer aparecer na galeria"
className="w-full rounded-lg border border-stone-300 bg-white px-3 py-2 text-stone-800 focus:border-stone-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-2">
Mensagem aos noivos <span className="font-normal text-stone-500">(opcional)</span>
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
disabled={status === 'uploading'}
maxLength={2000}
rows={4}
placeholder="Deixe um recadinho carinhoso"
className="w-full rounded-lg border border-stone-300 bg-white px-3 py-2 text-stone-800 focus:border-stone-500 focus:outline-none"
/>
<p className="mt-1 text-xs text-stone-400 text-right">{message.length}/2000</p>
</div>
{status === 'uploading' && (
<div className="rounded-lg bg-stone-100 p-4">
<div className="flex justify-between text-xs text-stone-600 mb-1">
<span>
Enviando {progress.index + 1}/{files.length}
</span>
<span>
{formatBytes(progress.loaded)} / {formatBytes(progress.total)}
</span>
</div>
<div className="h-2 bg-stone-300 rounded overflow-hidden">
<div
className="h-full bg-stone-700 transition-all"
style={{
width: `${
progress.total
? Math.min(100, (progress.loaded / progress.total) * 100)
: 0
}%`,
}}
/>
</div>
</div>
)}
{error && (
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded p-3">
{error}
</p>
)}
<div className="flex gap-3">
{status === 'uploading' ? (
<button
type="button"
onClick={cancelUpload}
className="flex-1 border border-stone-400 text-stone-700 rounded-full py-3 hover:bg-white/40"
>
Cancelar
</button>
) : (
<button
type="submit"
disabled={!files.length || Boolean(overLimit)}
className="flex-1 bg-stone-800 text-cream rounded-full py-3 disabled:bg-stone-400 hover:bg-stone-700 transition"
>
{files.length > 1 ? `Enviar ${files.length} arquivos` : 'Enviar'}
</button>
)}
</div>
</form>
</main>
);
}
function humanError(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('file_too_large')) return 'Arquivo grande demais.';
if (msg.includes('video_not_allowed')) return 'Vídeos não são permitidos.';
if (msg.includes('video_too_long')) return 'Vídeo muito longo.';
if (msg.toLowerCase().includes('cors')) return 'Erro de CORS no storage — me avisa.';
if (msg.includes('network')) return 'Falha de rede. Tenta de novo.';
return 'Erro ao enviar. Tenta de novo em alguns segundos.';
}

View File

@ -0,0 +1,834 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type {
AdminStats,
AdminUpload,
AdminUploadsResponse,
} from '@leetete/shared';
interface AdminEvent {
coupleNames: string;
eventDate: string | null;
coverKey: string | null;
coverUrl: string | null;
galleryVisibility: 'public' | 'private';
moderation: 'pre' | 'post';
maxFileMb: number;
allowVideo: boolean;
maxVideoSeconds: number;
welcomeMessage: string | null;
}
type StatusFilter = 'all' | 'pending' | 'approved' | 'rejected';
type KindFilter = 'all' | 'photo' | 'video';
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',
});
}
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const id = setTimeout(() => setV(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return v;
}
export default function AdminDashboard() {
const navigate = useNavigate();
const [email, setEmail] = useState<string | null>(null);
const [stats, setStats] = useState<AdminStats | null>(null);
const [event, setEvent] = useState<AdminEvent | null>(null);
const [uploads, setUploads] = useState<AdminUpload[]>([]);
const [status, setStatus] = useState<StatusFilter>('all');
const [kind, setKind] = useState<KindFilter>('all');
const [search, setSearch] = useState('');
const debouncedSearch = useDebounced(search, 350);
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);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<AdminUpload | null>(null);
useEffect(() => {
fetch('/api/admin/me', { credentials: 'include' })
.then(async (r) => {
if (r.status === 401 || r.status === 403) {
navigate('/admin/login', { replace: true });
return null;
}
return r.json() as Promise<{ email: string }>;
})
.then((d) => d && setEmail(d.email))
.catch(() => navigate('/admin/login', { replace: true }));
}, [navigate]);
useEffect(() => {
if (!email) return;
refreshStats();
refreshEvent();
}, [email]);
async function refreshStats() {
const r = await fetch('/api/admin/stats', { credentials: 'include' });
if (r.ok) setStats((await r.json()) as AdminStats);
}
async function refreshEvent() {
const r = await fetch('/api/admin/event', { credentials: 'include' });
if (r.ok) setEvent((await r.json()) as AdminEvent);
}
async function loadUploads(reset: boolean) {
if (!email || loading) return;
setLoading(true);
try {
const url = new URL('/api/admin/uploads', window.location.origin);
if (status !== 'all') url.searchParams.set('status', status);
if (kind !== 'all') url.searchParams.set('kind', kind);
if (debouncedSearch.trim()) url.searchParams.set('q', debouncedSearch.trim());
if (!reset && cursor) url.searchParams.set('cursor', cursor);
const r = await fetch(url, { credentials: 'include' });
if (!r.ok) return;
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);
setSelected(new Set());
loadUploads(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, status, kind, debouncedSearch]);
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));
setSelected((s) => {
const next = new Set(s);
next.delete(id);
return next;
});
refreshStats();
refreshEvent();
}
async function setCover(item: AdminUpload) {
await fetch(`/api/admin/uploads/${item.id}/cover`, {
method: 'POST',
credentials: 'include',
});
setUploads((u) => u.map((x) => ({ ...x, isCover: x.id === item.id })));
refreshEvent();
}
async function clearCover() {
await fetch('/api/admin/event/cover', {
method: 'DELETE',
credentials: 'include',
});
setUploads((u) => u.map((x) => ({ ...x, isCover: false })));
refreshEvent();
}
async function bulk(action: 'approve' | 'reject' | 'delete') {
if (selected.size === 0) return;
if (action === 'delete' && !confirm(`Apagar ${selected.size} arquivos? Não dá pra desfazer.`))
return;
const ids = Array.from(selected);
await fetch('/api/admin/uploads/bulk', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action, ids }),
});
if (action === 'delete') {
setUploads((u) => u.filter((x) => !selected.has(x.id)));
} else {
const newStatus = action === 'approve' ? 'approved' : 'rejected';
setUploads((u) =>
u.map((x) => (selected.has(x.id) ? { ...x, status: newStatus } : x)),
);
}
setSelected(new Set());
refreshStats();
refreshEvent();
}
async function saveEvent(e: React.FormEvent) {
e.preventDefault();
if (!event) return;
setSavingEvent(true);
try {
const body = {
coupleNames: event.coupleNames,
eventDate: event.eventDate,
galleryVisibility: event.galleryVisibility,
moderation: event.moderation,
maxFileMb: event.maxFileMb,
allowVideo: event.allowVideo,
maxVideoSeconds: event.maxVideoSeconds,
welcomeMessage: event.welcomeMessage,
};
await fetch('/api/admin/event', {
method: 'PATCH',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
setEventDirty(false);
} finally {
setSavingEvent(false);
}
}
function patchEvent(patch: Partial<AdminEvent>) {
setEvent((prev) => (prev ? { ...prev, ...patch } : prev));
setEventDirty(true);
}
async function saveEdit(authorName: string, message: string) {
if (!editing) return;
await fetch(`/api/admin/uploads/${editing.id}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
authorName: authorName.trim() || null,
message: message.trim() || null,
}),
});
setUploads((u) =>
u.map((x) =>
x.id === editing.id
? {
...x,
authorName: authorName.trim() || null,
message: message.trim() || null,
}
: x,
),
);
setEditing(null);
}
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function selectAllOnPage() {
setSelected(new Set(uploads.map((u) => u.id)));
}
async function logout() {
await fetch('/api/admin/logout', { method: 'POST', credentials: 'include' });
navigate('/admin/login', { replace: true });
}
const allOnPageSelected = useMemo(
() => uploads.length > 0 && uploads.every((u) => selected.has(u.id)),
[uploads, selected],
);
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-5xl 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>
<button
type="button"
onClick={logout}
className="text-sm text-stone-500 hover:text-stone-700 underline"
>
Sair
</button>
</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>
{event.coverUrl && (
<div className="mb-4 flex items-center gap-3">
<img
src={event.coverUrl}
alt="capa"
className="w-24 h-24 object-cover rounded border border-stone-200"
/>
<div className="text-sm">
<p className="text-stone-700">Foto de capa definida.</p>
<button
type="button"
onClick={clearCover}
className="text-xs text-rose-600 hover:underline"
>
Remover capa
</button>
</div>
</div>
)}
<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 flex-wrap items-center gap-2 mb-4">
<h2 className="font-display text-xl text-stone-800 mr-auto">Uploads</h2>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar autor ou mensagem"
className="text-sm rounded border border-stone-300 px-2 py-1 w-full sm:w-64"
/>
<select
value={status}
onChange={(e) => setStatus(e.target.value as StatusFilter)}
className="text-sm rounded border border-stone-300 px-2 py-1"
>
<option value="all">Todos status</option>
<option value="pending">Pendentes</option>
<option value="approved">Aprovados</option>
<option value="rejected">Rejeitados</option>
</select>
<select
value={kind}
onChange={(e) => setKind(e.target.value as KindFilter)}
className="text-sm rounded border border-stone-300 px-2 py-1"
>
<option value="all">Foto+Vídeo</option>
<option value="photo"> fotos</option>
<option value="video"> vídeos</option>
</select>
</div>
{selected.size > 0 && (
<div className="bg-stone-100 border border-stone-200 rounded p-2 mb-3 flex items-center gap-2 text-sm">
<span className="text-stone-700">
{selected.size} selecionad{selected.size === 1 ? 'o' : 'os'}
</span>
<button
type="button"
onClick={() => bulk('approve')}
className="bg-emerald-600 text-white rounded py-1 px-3 text-xs hover:bg-emerald-700"
>
Aprovar
</button>
<button
type="button"
onClick={() => bulk('reject')}
className="bg-amber-600 text-white rounded py-1 px-3 text-xs hover:bg-amber-700"
>
Rejeitar
</button>
<button
type="button"
onClick={() => bulk('delete')}
className="bg-stone-800 text-white rounded py-1 px-3 text-xs hover:bg-stone-900"
>
Apagar
</button>
<button
type="button"
onClick={() => setSelected(new Set())}
className="ml-auto text-stone-500 text-xs hover:underline"
>
Limpar seleção
</button>
</div>
)}
{uploads.length > 0 && (
<label className="flex items-center gap-2 text-xs text-stone-600 mb-2">
<input
type="checkbox"
checked={allOnPageSelected}
onChange={() => {
if (allOnPageSelected) setSelected(new Set());
else selectAllOnPage();
}}
/>
Selecionar todos desta página
</label>
)}
{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}
selected={selected.has(u.id)}
onToggle={() => toggleSelect(u.id)}
onApprove={() => approve(u.id)}
onReject={() => reject(u.id)}
onDelete={() => remove(u.id)}
onEdit={() => setEditing(u)}
onSetCover={() => setCover(u)}
/>
))}
</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>
{editing && (
<EditModal
upload={editing}
onClose={() => setEditing(null)}
onSave={saveEdit}
/>
)}
</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,
selected,
onToggle,
onApprove,
onReject,
onDelete,
onEdit,
onSetCover,
}: {
upload: AdminUpload;
selected: boolean;
onToggle: () => void;
onApprove: () => void;
onReject: () => void;
onDelete: () => void;
onEdit: () => void;
onSetCover: () => 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 overflow-hidden bg-white ${
selected ? 'border-stone-700 ring-2 ring-stone-700' : 'border-stone-200'
}`}
>
<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"
/>
)}
<label className="absolute top-2 right-2 bg-white/90 rounded p-1 cursor-pointer">
<input
type="checkbox"
checked={selected}
onChange={onToggle}
className="cursor-pointer"
/>
</label>
<span
className={`absolute top-2 left-2 text-[10px] uppercase tracking-wide px-2 py-0.5 rounded ${statusColor}`}
>
{upload.status}
</span>
{upload.isCover && (
<span className="absolute bottom-2 left-2 text-[10px] uppercase tracking-wide px-2 py-0.5 rounded bg-stone-800 text-cream">
capa
</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>
<div className="font-medium truncate">
{upload.authorName || <span className="text-stone-400 italic">sem nome</span>}
</div>
{upload.message && (
<p className="text-stone-600 line-clamp-2">{upload.message}</p>
)}
<div className="flex flex-wrap gap-1 pt-1">
{upload.status !== 'approved' && (
<button
type="button"
onClick={onApprove}
className="text-[11px] bg-emerald-600 text-white rounded py-1 px-2 hover:bg-emerald-700"
>
Aprovar
</button>
)}
{upload.status === 'approved' && (
<button
type="button"
onClick={onReject}
className="text-[11px] bg-amber-600 text-white rounded py-1 px-2 hover:bg-amber-700"
>
Ocultar
</button>
)}
<button
type="button"
onClick={onEdit}
className="text-[11px] bg-stone-200 text-stone-800 rounded py-1 px-2 hover:bg-stone-300"
>
Editar
</button>
{!upload.isVideo && !upload.isCover && (
<button
type="button"
onClick={onSetCover}
className="text-[11px] bg-stone-200 text-stone-800 rounded py-1 px-2 hover:bg-stone-300"
>
Capa
</button>
)}
<button
type="button"
onClick={onDelete}
className="ml-auto text-[11px] bg-stone-700 text-white rounded py-1 px-2 hover:bg-stone-800"
>
Apagar
</button>
</div>
</div>
</div>
);
}
function EditModal({
upload,
onClose,
onSave,
}: {
upload: AdminUpload;
onClose: () => void;
onSave: (authorName: string, message: string) => Promise<void>;
}) {
const [authorName, setAuthorName] = useState(upload.authorName ?? '');
const [message, setMessage] = useState(upload.message ?? '');
const [saving, setSaving] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
await onSave(authorName, message);
} finally {
setSaving(false);
}
}
return (
<div
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4"
onClick={onClose}
>
<form
onSubmit={submit}
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-lg p-5 w-full max-w-md space-y-4"
>
<h3 className="font-display text-xl text-stone-800">Editar mensagem</h3>
<div className="aspect-video bg-stone-100 rounded overflow-hidden">
{upload.isVideo ? (
<video
src={upload.url}
controls
className="w-full h-full object-contain"
/>
) : (
<img src={upload.url} alt="" className="w-full h-full object-contain" />
)}
</div>
<label className="block">
<span className="block text-xs font-medium text-stone-600 mb-1">Autor</span>
<input
type="text"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
maxLength={80}
className="w-full rounded border border-stone-300 px-3 py-2"
/>
</label>
<label className="block">
<span className="block text-xs font-medium text-stone-600 mb-1">Mensagem</span>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
maxLength={2000}
rows={4}
className="w-full rounded border border-stone-300 px-3 py-2"
/>
</label>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 border border-stone-400 text-stone-700 rounded-full py-2 hover:bg-stone-50"
>
Cancelar
</button>
<button
type="submit"
disabled={saving}
className="flex-1 bg-stone-800 text-cream rounded-full py-2 disabled:bg-stone-400 hover:bg-stone-700"
>
{saving ? 'Salvando…' : 'Salvar'}
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,97 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
export default function AdminLogin() {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const r = await fetch('/api/admin/login', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password }),
});
if (r.status === 401) {
setError('Email ou senha incorretos.');
return;
}
if (!r.ok) {
setError('Erro inesperado. Tenta de novo.');
return;
}
navigate('/admin', { replace: true });
} catch {
setError('Falha de rede.');
} finally {
setLoading(false);
}
}
return (
<main className="min-h-full flex items-center justify-center px-6 py-12">
<form
onSubmit={submit}
className="w-full max-w-sm bg-white border border-stone-200 rounded-lg p-6 space-y-4"
>
<h1 className="font-display text-2xl text-stone-800 text-center">
Painel dos noivos
</h1>
<p className="text-sm text-stone-500 text-center -mt-2">
Acesso restrito a Stefanie & Leandro.
</p>
<label className="block">
<span className="block text-xs font-medium text-stone-600 mb-1">Email</span>
<input
type="email"
required
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded border border-stone-300 px-3 py-2"
/>
</label>
<label className="block">
<span className="block text-xs font-medium text-stone-600 mb-1">Senha</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border border-stone-300 px-3 py-2"
/>
</label>
{error && (
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded p-2">
{error}
</p>
)}
<button
type="submit"
disabled={loading || !email || !password}
className="w-full bg-stone-800 text-cream rounded-full py-3 disabled:bg-stone-400 hover:bg-stone-700 transition"
>
{loading ? 'Entrando…' : 'Entrar'}
</button>
<Link
to="/"
className="block text-center text-xs text-stone-500 hover:text-stone-700"
>
Voltar pra página pública
</Link>
</form>
</main>
);
}

View File

@ -0,0 +1,19 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
fontFamily: {
display: ['"Cormorant Garamond"', 'Georgia', 'serif'],
sans: ['Inter', 'system-ui', 'sans-serif'],
},
colors: {
cream: '#fdf6ec',
sage: '#a3b18a',
rose: '#d4a5a5',
},
},
},
plugins: [],
} satisfies Config;

10
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src/**/*", "vite.config.ts"]
}

19
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,19 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
});

109
docker-compose.yml Normal file
View File

@ -0,0 +1,109 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-wedding}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-wedding}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wedding} -d ${POSTGRES_DB:-wedding}"]
interval: 10s
timeout: 5s
retries: 10
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 15s
timeout: 10s
retries: 10
minio-init:
image: minio/mc:latest
restart: "no"
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
S3_BUCKET: ${S3_BUCKET:-wedding-media}
entrypoint:
- /bin/sh
- -c
- |
set -e
mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD"
mc mb --ignore-existing local/"$$S3_BUCKET"
mc anonymous set download local/"$$S3_BUCKET"
mc cors set local/"$$S3_BUCKET" /tmp/cors.json || true
echo "minio init done"
volumes:
- ./infra/minio-cors.json:/tmp/cors.json:ro
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-wedding}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-wedding}
S3_ENDPOINT: ${S3_INTERNAL_ENDPOINT:-https://${MEDIA_DOMAIN}}
S3_BUCKET: ${S3_BUCKET:-wedding-media}
S3_REGION: ${S3_REGION:-us-east-1}
S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
S3_PUBLIC_BASE_URL: https://${MEDIA_DOMAIN}/${S3_BUCKET:-wedding-media}
S3_FORCE_PATH_STYLE: "true"
COUPLE_NAMES: ${COUPLE_NAMES:-Stefanie & Leandro}
EVENT_DATE: ${EVENT_DATE:-}
PUBLIC_BASE_URL: https://${DOMAIN}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
SESSION_SECRET: ${SESSION_SECRET}
ALLOWED_ADMIN_EMAILS: ${ALLOWED_ADMIN_EMAILS}
AUTO_MIGRATE: "true"
NODE_ENV: production
PORT: 3000
expose:
- "3000"
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
DOMAIN: ${DOMAIN}
MEDIA_DOMAIN: ${MEDIA_DOMAIN}
ACME_EMAIL: ${ACME_EMAIL}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
- minio
volumes:
postgres_data:
minio_data:
caddy_data:
caddy_config:

11
infra/minio-cors.json Normal file
View File

@ -0,0 +1,11 @@
{
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["PUT", "GET", "HEAD", "POST", "DELETE"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
}

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "stefanieeleandro",
"private": true,
"version": "0.1.0",
"packageManager": "pnpm@9.12.0",
"scripts": {
"dev:api": "pnpm -F @leetete/api dev",
"dev:web": "pnpm -F @leetete/web dev",
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
"migrate": "pnpm -F @leetete/api migrate"
},
"devDependencies": {
"typescript": "^5.6.3"
},
"engines": {
"node": ">=20",
"pnpm": ">=9"
}
}

View File

@ -0,0 +1,21 @@
{
"name": "@leetete/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.6.3"
}
}

View File

@ -0,0 +1,2 @@
export * from './schemas.js';
export * from './types.js';

View File

@ -0,0 +1,135 @@
import { z } from 'zod';
export const eventConfigSchema = z.object({
coupleNames: z.string().min(1).max(120),
eventDate: z.string().nullable(),
coverKey: z.string().nullable(),
galleryVisibility: z.enum(['public', 'private']),
moderation: z.enum(['pre', 'post']),
maxFileMb: z.number().int().positive(),
allowVideo: z.boolean(),
maxVideoSeconds: z.number().int().positive(),
welcomeMessage: z.string().max(2000).nullable(),
});
export type EventConfig = z.infer<typeof eventConfigSchema>;
export const eventConfigUpdateSchema = eventConfigSchema.partial();
export type EventConfigUpdate = z.infer<typeof eventConfigUpdateSchema>;
export const uploadInitSchema = z.object({
filename: z.string().min(1).max(255),
mimeType: z.string().regex(/^(image|video)\/[a-z0-9.+-]+$/i),
sizeBytes: z.number().int().positive(),
durationSeconds: z.number().int().positive().optional(),
authorName: z.string().max(80).optional(),
message: z.string().max(2000).optional(),
});
export type UploadInit = z.infer<typeof uploadInitSchema>;
export const uploadInitResponseSchema = z.object({
uploadId: z.string(),
storageKey: z.string(),
mode: z.enum(['single', 'multipart']),
putUrl: z.string().url().optional(),
putHeaders: z.record(z.string()).optional(),
multipart: z
.object({
providerUploadId: z.string(),
partSize: z.number().int().positive(),
partUrls: z.array(z.string().url()),
})
.optional(),
});
export type UploadInitResponse = z.infer<typeof uploadInitResponseSchema>;
export const uploadConfirmSchema = z.object({
multipart: z
.object({
providerUploadId: z.string(),
parts: z.array(
z.object({
partNumber: z.number().int().positive(),
etag: z.string().min(1),
}),
),
})
.optional(),
});
export type UploadConfirm = z.infer<typeof uploadConfirmSchema>;
export const galleryItemSchema = z.object({
id: z.string(),
url: z.string().url(),
thumbnailUrl: z.string().url().nullable(),
mimeType: z.string(),
isVideo: z.boolean(),
authorName: z.string().nullable(),
message: z.string().nullable(),
createdAt: z.number(),
});
export type GalleryItem = z.infer<typeof galleryItemSchema>;
export const galleryResponseSchema = z.object({
items: z.array(galleryItemSchema),
nextCursor: z.string().nullable(),
});
export type GalleryResponse = z.infer<typeof galleryResponseSchema>;
export const adminUploadSchema = z.object({
id: z.string(),
url: z.string().url(),
thumbnailUrl: z.string().url().nullable(),
storageKey: z.string(),
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(),
isCover: z.boolean(),
});
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 adminUploadUpdateSchema = z.object({
authorName: z.string().max(80).nullable().optional(),
message: z.string().max(2000).nullable().optional(),
});
export type AdminUploadUpdate = z.infer<typeof adminUploadUpdateSchema>;
export const adminBulkSchema = z.object({
action: z.enum(['approve', 'reject', 'delete']),
ids: z.array(z.string().min(1)).min(1).max(200),
});
export type AdminBulk = z.infer<typeof adminBulkSchema>;
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>;

View File

@ -0,0 +1,6 @@
export type UploadStatus = 'pending' | 'approved' | 'rejected';
export type UploadSource = 'guest' | 'import';
export type GalleryVisibility = 'public' | 'private';
export type ModerationMode = 'pre' | 'post';
export type BulkAction = 'approve' | 'reject' | 'delete';
export type UploadKind = 'photo' | 'video';

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"noEmit": true
},
"include": ["src/**/*"]
}

2956
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

20
tsconfig.base.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"allowJs": false,
"declaration": false,
"sourceMap": true
}
}