chore: scaffold Cloudflare Pages monorepo for wedding photos app

Set up pnpm workspaces with three packages: a shared Zod-schema
package, a Hono-based API exported as a library, and a Vite/React/
Tailwind frontend that mounts the API via Cloudflare Pages Functions.
Storage is abstracted behind an S3-compatible provider so the project
can migrate from R2 to a self-hosted MinIO without code changes.

https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
This commit is contained in:
Claude 2026-05-01 23:30:50 +00:00
commit be13179832
No known key found for this signature in database
47 changed files with 1224 additions and 0 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

20
.gitignore vendored Normal file
View File

@ -0,0 +1,20 @@
node_modules/
dist/
build/
out/
.cache/
.turbo/
*.log
.DS_Store
.wrangler/
.dev.vars
worker-configuration.d.ts
.env
.env.local
.env.*.local
!.env.example
.vscode/
.idea/

2
.npmrc Normal file
View File

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

View File

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

View File

@ -0,0 +1,44 @@
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 INTEGER NOT NULL DEFAULT 1,
max_video_seconds INTEGER NOT NULL DEFAULT 300,
welcome_message TEXT,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
INSERT INTO event_config (id, couple_names, max_file_mb, max_video_seconds, allow_video, moderation, gallery_visibility)
VALUES (1, 'Stefanie & Leandro', 500, 300, 1, 'post', 'public');
CREATE TABLE uploads (
id TEXT PRIMARY KEY,
storage_key TEXT NOT NULL,
thumbnail_key TEXT,
mime_type TEXT NOT NULL,
size_bytes INTEGER 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 INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
approved_at INTEGER
);
CREATE INDEX idx_uploads_status_created ON uploads (status, created_at DESC);
CREATE INDEX idx_uploads_source ON uploads (source);
CREATE TABLE audit_log (
id TEXT PRIMARY KEY,
action TEXT NOT NULL,
actor TEXT,
payload TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);

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

@ -0,0 +1,35 @@
{
"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": {
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@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",
"qrcode": "^1.5.4",
"zod": "^3.23.8"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241218.0",
"@types/qrcode": "^1.5.5",
"drizzle-kit": "^0.28.1",
"typescript": "^5.6.3"
}
}

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

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

View File

@ -0,0 +1,9 @@
import type { D1Database } from '@cloudflare/workers-types';
import { drizzle } from 'drizzle-orm/d1';
import * as schema from './schema.js';
export type DB = ReturnType<typeof getDb>;
export function getDb(d1: D1Database) {
return drizzle(d1, { schema });
}

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

@ -0,0 +1,62 @@
import { sql } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const eventConfig = sqliteTable('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: integer('allow_video', { mode: 'boolean' }).notNull().default(true),
maxVideoSeconds: integer('max_video_seconds').notNull().default(300),
welcomeMessage: text('welcome_message'),
updatedAt: integer('updated_at')
.notNull()
.default(sql`(unixepoch() * 1000)`),
});
export const uploads = sqliteTable(
'uploads',
{
id: text('id').primaryKey(),
storageKey: text('storage_key').notNull(),
thumbnailKey: text('thumbnail_key'),
mimeType: text('mime_type').notNull(),
sizeBytes: integer('size_bytes').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: integer('created_at')
.notNull()
.default(sql`(unixepoch() * 1000)`),
approvedAt: integer('approved_at'),
},
(t) => ({
statusCreatedIdx: index('idx_uploads_status_created').on(t.status, t.createdAt),
sourceIdx: index('idx_uploads_source').on(t.source),
}),
);
export const auditLog = sqliteTable('audit_log', {
id: text('id').primaryKey(),
action: text('action').notNull(),
actor: text('actor'),
payload: text('payload'),
createdAt: integer('created_at')
.notNull()
.default(sql`(unixepoch() * 1000)`),
});
export type EventConfigRow = typeof eventConfig.$inferSelect;
export type UploadRow = typeof uploads.$inferSelect;
export type NewUploadRow = typeof uploads.$inferInsert;

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

@ -0,0 +1,32 @@
import type { D1Database, R2Bucket } from '@cloudflare/workers-types';
export interface AppEnv {
DB: D1Database;
MEDIA: R2Bucket;
S3_ENDPOINT: string;
S3_BUCKET: string;
S3_REGION: string;
S3_ACCESS_KEY_ID: string;
S3_SECRET_ACCESS_KEY: string;
S3_PUBLIC_BASE_URL: string;
S3_FORCE_PATH_STYLE?: string;
COUPLE_NAMES: string;
EVENT_DATE?: string;
PUBLIC_BASE_URL: string;
TURNSTILE_SECRET: string;
CF_ACCESS_TEAM: string;
CF_ACCESS_AUD: string;
ALLOWED_ADMIN_EMAILS: string;
}
export interface AppVariables {
adminEmail: string;
}
export type Bindings = {
Bindings: AppEnv;
Variables: AppVariables;
};

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

@ -0,0 +1,91 @@
import type { Context, Next } from 'hono';
import type { Bindings } from '../env.js';
interface AccessJwtPayload {
email?: string;
sub?: string;
aud?: string | string[];
exp?: number;
iat?: number;
}
interface JwtHeader {
alg: string;
kid: string;
typ?: string;
}
export async function requireAdmin(c: Context<Bindings>, next: Next) {
const token = c.req.header('cf-access-jwt-assertion');
if (!token) return c.json({ error: 'unauthorized' }, 401);
const payload = await verifyAccessJwt(token, c.env.CF_ACCESS_TEAM, c.env.CF_ACCESS_AUD);
if (!payload?.email) return c.json({ error: 'unauthorized' }, 401);
const allowed = c.env.ALLOWED_ADMIN_EMAILS.split(',').map((s) => s.trim().toLowerCase());
if (!allowed.includes(payload.email.toLowerCase())) {
return c.json({ error: 'forbidden' }, 403);
}
c.set('adminEmail', payload.email);
await next();
}
async function verifyAccessJwt(
token: string,
team: string,
expectedAud: string,
): Promise<AccessJwtPayload | null> {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [headerB64, payloadB64, sigB64] = parts as [string, string, string];
let header: JwtHeader;
let payload: AccessJwtPayload;
try {
header = JSON.parse(b64urlDecodeString(headerB64)) as JwtHeader;
payload = JSON.parse(b64urlDecodeString(payloadB64)) as AccessJwtPayload;
} catch {
return null;
}
const audOk = Array.isArray(payload.aud)
? payload.aud.includes(expectedAud)
: payload.aud === expectedAud;
if (!audOk) return null;
if (payload.exp && payload.exp * 1000 < Date.now()) return null;
const certsRes = await fetch(`https://${team}/cdn-cgi/access/certs`);
if (!certsRes.ok) return null;
const certs = (await certsRes.json()) as { keys: Array<JsonWebKey & { kid?: string }> };
const jwk = certs.keys.find((k) => k.kid === header.kid);
if (!jwk) return null;
const cryptoKey = await crypto.subtle.importKey(
'jwk',
jwk,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['verify'],
);
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const valid = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
cryptoKey,
b64urlDecode(sigB64),
data,
);
return valid ? payload : null;
}
function b64urlDecode(s: string): Uint8Array {
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/');
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function b64urlDecodeString(s: string): string {
return new TextDecoder().decode(b64urlDecode(s));
}

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

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

View File

@ -0,0 +1,22 @@
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] ?? '';
const bin = atob(base64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
export function generateQrSvg(text: string): Promise<string> {
return QRCode.toString(text, {
type: 'svg',
errorCorrectionLevel: 'M',
margin: 2,
});
}

View File

@ -0,0 +1,15 @@
import type { AppEnv } from '../env.js';
import { S3Storage } from './storage-s3.js';
import type { StorageProvider } from './storage.js';
export function createStorage(env: AppEnv): StorageProvider {
return 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',
});
}

View File

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

View File

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

View File

@ -0,0 +1,23 @@
interface TurnstileVerifyResponse {
success: boolean;
'error-codes'?: string[];
}
export async function verifyTurnstile(
token: string,
secret: string,
remoteIp?: string,
): Promise<boolean> {
const body = new FormData();
body.append('secret', secret);
body.append('response', token);
if (remoteIp) body.append('remoteip', remoteIp);
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body,
});
if (!res.ok) return false;
const data = (await res.json()) as TurnstileVerifyResponse;
return data.success === true;
}

View File

@ -0,0 +1,51 @@
import { Hono } from 'hono';
import type { Bindings } from '../env.js';
import { requireAdmin } from '../lib/auth.js';
export const adminRoutes = new Hono<Bindings>();
adminRoutes.use('*', requireAdmin);
adminRoutes.get('/me', (c) => c.json({ email: c.get('adminEmail') }));
// Event configuration
adminRoutes.get('/event', async (c) => {
return c.json({ error: 'not_implemented' }, 501);
});
adminRoutes.patch('/event', async (c) => {
return c.json({ error: 'not_implemented' }, 501);
});
// Uploads moderation / management
adminRoutes.get('/uploads', async (c) => {
return c.json({ items: [], nextCursor: null });
});
adminRoutes.post('/uploads/:id/approve', async (c) => {
return c.json({ error: 'not_implemented' }, 501);
});
adminRoutes.post('/uploads/:id/reject', async (c) => {
return c.json({ error: 'not_implemented' }, 501);
});
adminRoutes.delete('/uploads/:id', async (c) => {
return c.json({ error: 'not_implemented' }, 501);
});
// QR code: ?format=png|svg|pdf (PDF = imprimível em A6 com nome do casal)
adminRoutes.get('/qrcode', async (c) => {
return c.json({ error: 'not_implemented' }, 501);
});
// Bulk import (post-event): pre-signs URLs in batches so the couple
// can upload files from a folder later (works for R2 today, MinIO later).
adminRoutes.post('/imports', async (c) => {
return c.json({ error: 'not_implemented' }, 501);
});
// Streamed ZIP export of all uploads.
adminRoutes.get('/export.zip', async (c) => {
return c.json({ error: 'not_implemented' }, 501);
});

View File

@ -0,0 +1,27 @@
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { getDb } from '../db/client.js';
import { eventConfig } from '../db/schema.js';
import type { Bindings } from '../env.js';
export const publicRoutes = new Hono<Bindings>();
publicRoutes.get('/event', async (c) => {
const db = getDb(c.env.DB);
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get();
if (!cfg) return c.json({ error: 'not_configured' }, 404);
return c.json({
coupleNames: cfg.coupleNames,
eventDate: cfg.eventDate,
welcomeMessage: cfg.welcomeMessage,
galleryVisibility: cfg.galleryVisibility,
allowVideo: cfg.allowVideo,
maxFileMb: cfg.maxFileMb,
maxVideoSeconds: cfg.maxVideoSeconds,
});
});
publicRoutes.get('/gallery', async (c) => {
// TODO: paginated list of approved uploads with public URLs
return c.json({ items: [], nextCursor: null });
});

View File

@ -0,0 +1,30 @@
import { Hono } from 'hono';
import type { Bindings } from '../env.js';
export const uploadsRoutes = new Hono<Bindings>();
// POST /api/uploads/init
// Body: UploadInit (zod schema in @leetete/shared)
// - validates Turnstile
// - reads event_config (rejects if file too big / video disabled / over duration)
// - decides single vs multipart based on sizeBytes (threshold ~50 MB)
// - creates row in `uploads` (status pending)
// - returns presigned PUT or multipart presigned URLs
uploadsRoutes.post('/init', async (c) => {
return c.json({ error: 'not_implemented' }, 501);
});
// POST /api/uploads/:id/confirm
// Body: UploadConfirm
// - if multipart: completeMultipart on storage
// - verifies object exists via head()
// - sets status based on event_config.moderation
uploadsRoutes.post('/:id/confirm', async (c) => {
return c.json({ error: 'not_implemented' }, 501);
});
// POST /api/uploads/:id/abort
// Cancels a pending multipart upload (cleanup).
uploadsRoutes.post('/:id/abort', async (c) => {
return c.json({ error: 'not_implemented' }, 501);
});

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

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

View File

@ -0,0 +1,15 @@
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
S3_BUCKET=wedding-media
S3_REGION=auto
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_PUBLIC_BASE_URL=https://media.stefanieeleandro.pages.dev
S3_FORCE_PATH_STYLE=false
COUPLE_NAMES=Stefanie & Leandro
PUBLIC_BASE_URL=http://localhost:5173
TURNSTILE_SECRET=1x0000000000000000000000000000000AA
CF_ACCESS_TEAM=yourteam.cloudflareaccess.com
CF_ACCESS_AUD=
ALLOWED_ADMIN_EMAILS=stefanie@example.com,leandro@example.com

View File

@ -0,0 +1,7 @@
import { createApp } from '@leetete/api';
import type { AppEnv } from '@leetete/api/env';
const app = createApp();
export const onRequest: PagesFunction<AppEnv> = (ctx) =>
app.fetch(ctx.request, ctx.env, ctx);

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

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

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

@ -0,0 +1,34 @@
{
"name": "@leetete/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"deploy": "wrangler pages deploy dist --project-name stefanieeleandro",
"pages:dev": "wrangler pages dev dist --compatibility-flags=nodejs_compat"
},
"dependencies": {
"@leetete/api": "workspace:*",
"@leetete/shared": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241218.0",
"@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",
"wrangler": "^3.95.0"
}
}

View File

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

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

@ -0,0 +1,16 @@
import { Route, Routes } from 'react-router-dom';
import AdminDashboard from './routes/admin/Dashboard.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/*" element={<AdminDashboard />} />
</Routes>
);
}

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

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

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

@ -0,0 +1,20 @@
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;
}

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

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

View File

@ -0,0 +1,10 @@
export default function Gallery() {
return (
<main className="min-h-full flex items-center justify-center p-6">
<div className="text-center text-stone-700">
<h2 className="font-display text-3xl mb-3">Galeria</h2>
<p>Em breve.</p>
</div>
</main>
);
}

View File

@ -0,0 +1,48 @@
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;
}
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">
<h1 className="font-display text-5xl text-stone-800 mb-3">
{event?.coupleNames ?? 'Stefanie & Leandro'}
</h1>
{event?.eventDate ? (
<p className="text-stone-600 mb-8">{event.eventDate}</p>
) : null}
<p className="max-w-md text-stone-700 mb-10">
{event?.welcomeMessage ??
'Compartilhe suas fotos, vídeos e mensagens deste dia especial.'}
</p>
<div className="flex flex-col gap-3 w-full max-w-xs">
<Link
to="/enviar"
className="bg-stone-800 text-cream rounded-full py-3 px-6 hover:bg-stone-700 transition"
>
Enviar fotos e mensagem
</Link>
<Link
to="/galeria"
className="border border-stone-400 text-stone-700 rounded-full py-3 px-6 hover:bg-white/40 transition"
>
Ver galeria
</Link>
</div>
</main>
);
}

View File

@ -0,0 +1,10 @@
export default function Upload() {
return (
<main className="min-h-full flex items-center justify-center p-6">
<div className="text-center text-stone-700">
<h2 className="font-display text-3xl mb-3">Enviar fotos</h2>
<p>Em breve.</p>
</div>
</main>
);
}

View File

@ -0,0 +1,10 @@
export default function AdminDashboard() {
return (
<main className="min-h-full flex items-center justify-center p-6">
<div className="text-center text-stone-700">
<h2 className="font-display text-3xl mb-3">Painel dos noivos</h2>
<p>Acesso protegido por Cloudflare Access.</p>
</div>
</main>
);
}

View File

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

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

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client", "@cloudflare/workers-types"]
},
"include": ["src/**/*", "functions/**/*"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
},
"include": ["vite.config.ts"]
}

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

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

29
apps/web/wrangler.toml Normal file
View File

@ -0,0 +1,29 @@
name = "stefanieeleandro"
compatibility_date = "2026-04-01"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = "dist"
[[d1_databases]]
binding = "DB"
database_name = "wedding-db"
database_id = "REPLACE_WITH_D1_ID"
migrations_dir = "../api/migrations"
[[r2_buckets]]
binding = "MEDIA"
bucket_name = "wedding-media"
[vars]
COUPLE_NAMES = "Stefanie & Leandro"
PUBLIC_BASE_URL = "https://stefanieeleandro.pages.dev"
S3_REGION = "auto"
S3_BUCKET = "wedding-media"
S3_FORCE_PATH_STYLE = "false"
# S3_ENDPOINT, S3_PUBLIC_BASE_URL, CF_ACCESS_TEAM, CF_ACCESS_AUD are
# set after Cloudflare config (see README of deploy steps).
#
# Secrets (set via `wrangler pages secret put <NAME> --project-name stefanieeleandro`):
# S3_ACCESS_KEY_ID
# S3_SECRET_ACCESS_KEY
# TURNSTILE_SECRET
# ALLOWED_ADMIN_EMAILS

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "stefanieeleandro",
"private": true,
"version": "0.1.0",
"packageManager": "pnpm@9.12.0",
"scripts": {
"dev": "pnpm -F @leetete/web dev",
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
"deploy": "pnpm -F @leetete/web deploy",
"db:generate": "pnpm -F @leetete/api db:generate",
"db:migrate:local": "pnpm -F @leetete/web exec wrangler d1 migrations apply wedding-db --local",
"db:migrate:remote": "pnpm -F @leetete/web exec wrangler d1 migrations apply wedding-db --remote"
},
"devDependencies": {
"typescript": "^5.6.3"
},
"engines": {
"node": ">=20",
"pnpm": ">=9"
}
}

View File

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

View File

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

View File

@ -0,0 +1,83 @@
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(),
turnstileToken: z.string().min(1),
});
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>;

View File

@ -0,0 +1,4 @@
export type UploadStatus = 'pending' | 'approved' | 'rejected';
export type UploadSource = 'guest' | 'import';
export type GalleryVisibility = 'public' | 'private';
export type ModerationMode = 'pre' | 'post';

View File

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

3
pnpm-workspace.yaml Normal file
View File

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

20
tsconfig.base.json Normal file
View File

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