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:
commit
00baf9bd89
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
34
.env.example
Normal 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
15
.gitignore
vendored
Normal 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
2
.npmrc
Normal file
@ -0,0 +1,2 @@
|
||||
auto-install-peers=true
|
||||
strict-peer-dependencies=false
|
||||
25
Caddyfile
Normal file
25
Caddyfile
Normal 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
35
Dockerfile
Normal 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"]
|
||||
7
apps/api/drizzle.config.ts
Normal file
7
apps/api/drizzle.config.ts
Normal 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
40
apps/api/package.json
Normal 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
28
apps/api/src/app.ts
Normal 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
30
apps/api/src/db/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
71
apps/api/src/db/migrate.ts
Normal file
71
apps/api/src/db/migrate.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
46
apps/api/src/db/migrations/0001_initial.sql
Normal file
46
apps/api/src/db/migrations/0001_initial.sql
Normal 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
62
apps/api/src/db/schema.ts
Normal 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
48
apps/api/src/env.ts
Normal 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
56
apps/api/src/lib/auth.ts
Normal 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
21
apps/api/src/lib/ids.ts
Normal 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);
|
||||
}
|
||||
84
apps/api/src/lib/qr-pdf.ts
Normal file
84
apps/api/src/lib/qr-pdf.ts
Normal 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();
|
||||
}
|
||||
19
apps/api/src/lib/qrcode.ts
Normal file
19
apps/api/src/lib/qrcode.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
26
apps/api/src/lib/storage-factory.ts
Normal file
26
apps/api/src/lib/storage-factory.ts
Normal 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;
|
||||
}
|
||||
164
apps/api/src/lib/storage-s3.ts
Normal file
164
apps/api/src/lib/storage-s3.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
49
apps/api/src/lib/storage.ts
Normal file
49
apps/api/src/lib/storage.ts
Normal 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>;
|
||||
}
|
||||
322
apps/api/src/routes/admin.ts
Normal file
322
apps/api/src/routes/admin.ts
Normal 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);
|
||||
});
|
||||
131
apps/api/src/routes/public.ts
Normal file
131
apps/api/src/routes/public.ts
Normal 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 });
|
||||
});
|
||||
220
apps/api/src/routes/uploads.ts
Normal file
220
apps/api/src/routes/uploads.ts
Normal 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
65
apps/api/src/server.ts
Normal 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
8
apps/api/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
13
apps/web/index.html
Normal file
13
apps/web/index.html
Normal 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
29
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
18
apps/web/src/App.tsx
Normal file
18
apps/web/src/App.tsx
Normal 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
15
apps/web/src/index.css
Normal 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
23
apps/web/src/lib/api.ts
Normal 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
143
apps/web/src/lib/upload.ts
Normal 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
16
apps/web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
157
apps/web/src/routes/Gallery.tsx
Normal file
157
apps/web/src/routes/Gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/routes/Home.tsx
Normal file
58
apps/web/src/routes/Home.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
262
apps/web/src/routes/Upload.tsx
Normal file
262
apps/web/src/routes/Upload.tsx
Normal 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.';
|
||||
}
|
||||
834
apps/web/src/routes/admin/Dashboard.tsx
Normal file
834
apps/web/src/routes/admin/Dashboard.tsx
Normal 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 (só 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">Só fotos</option>
|
||||
<option value="video">Só 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>
|
||||
);
|
||||
}
|
||||
97
apps/web/src/routes/admin/Login.tsx
Normal file
97
apps/web/src/routes/admin/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/web/tailwind.config.ts
Normal file
19
apps/web/tailwind.config.ts
Normal 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
10
apps/web/tsconfig.json
Normal 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
19
apps/web/vite.config.ts
Normal 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
109
docker-compose.yml
Normal 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
11
infra/minio-cors.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"CORSRules": [
|
||||
{
|
||||
"AllowedOrigins": ["*"],
|
||||
"AllowedMethods": ["PUT", "GET", "HEAD", "POST", "DELETE"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"ExposeHeaders": ["ETag"],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
}
|
||||
20
package.json
Normal file
20
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
21
packages/shared/package.json
Normal file
21
packages/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2
packages/shared/src/index.ts
Normal file
2
packages/shared/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './schemas.js';
|
||||
export * from './types.js';
|
||||
135
packages/shared/src/schemas.ts
Normal file
135
packages/shared/src/schemas.ts
Normal 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>;
|
||||
6
packages/shared/src/types.ts
Normal file
6
packages/shared/src/types.ts
Normal 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';
|
||||
8
packages/shared/tsconfig.json
Normal file
8
packages/shared/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
2956
pnpm-lock.yaml
generated
Normal file
2956
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
20
tsconfig.base.json
Normal file
20
tsconfig.base.json
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user