feat: implement guest upload flow and public gallery

Backend:
- POST /api/uploads/init validates against event_config (size, video
  policy, duration), creates an uploads row, and returns either a
  single presigned PUT or a multipart batch of presigned part URLs
  depending on file size (50 MB threshold, 10 MB parts).
- POST /api/uploads/:id/confirm completes multipart on R2 if needed,
  HEADs the object to verify upload, and flips status to approved
  (post-moderation) or pending (pre-moderation).
- POST /api/uploads/:id/abort cancels in-flight multipart and removes
  the row.
- GET /api/gallery returns approved uploads in cursor-paginated
  reverse chronological order, with public R2 URLs.
- GET /api/stats returns lightweight counts for future home/admin use.

Frontend:
- lib/upload.ts handles single and multipart uploads via XHR with
  progress callbacks, video duration extraction, and abort signals.
- /enviar: real form with file picker (image/video), author name,
  message, per-file progress, multi-file support, and a success state.
- /galeria: responsive grid of approved items with lazy-loaded images,
  video preview tiles, infinite "load more", and a fullscreen lightbox
  showing the author and message.

Schema: turnstileToken is now optional so the MVP works without
Turnstile wired up; we layer it back in later.

https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
This commit is contained in:
Claude 2026-05-04 02:08:00 +00:00
parent 4f85dd8721
commit dbfd917e50
No known key found for this signature in database
6 changed files with 808 additions and 31 deletions

View File

@ -1,11 +1,15 @@
import { eq } from 'drizzle-orm'; import { and, desc, eq, lt } from 'drizzle-orm';
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { GalleryResponse } from '@leetete/shared';
import { getDb } from '../db/client.js'; import { getDb } from '../db/client.js';
import { eventConfig } from '../db/schema.js'; import { eventConfig, uploads } from '../db/schema.js';
import type { Bindings } from '../env.js'; import type { Bindings } from '../env.js';
import { createStorage } from '../lib/storage-factory.js';
export const publicRoutes = new Hono<Bindings>(); export const publicRoutes = new Hono<Bindings>();
const GALLERY_PAGE_SIZE = 24;
publicRoutes.get('/event', async (c) => { publicRoutes.get('/event', async (c) => {
const db = getDb(c.env.DB); const db = getDb(c.env.DB);
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get(); const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get();
@ -22,6 +26,58 @@ publicRoutes.get('/event', async (c) => {
}); });
publicRoutes.get('/gallery', async (c) => { publicRoutes.get('/gallery', async (c) => {
// TODO: paginated list of approved uploads with public URLs const db = getDb(c.env.DB);
return c.json({ items: [], nextCursor: null }); const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get();
if (!cfg) return c.json({ items: [], nextCursor: null } satisfies GalleryResponse);
if (cfg.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)
.all();
const hasMore = rows.length > limit;
const slice = hasMore ? rows.slice(0, limit) : rows;
const storage = createStorage(c.env);
const items = slice.map((row) => ({
id: row.id,
url: storage.publicUrl(row.storageKey),
thumbnailUrl: row.thumbnailKey ? storage.publicUrl(row.thumbnailKey) : null,
mimeType: row.mimeType,
isVideo: row.mimeType.startsWith('video/'),
authorName: row.authorName,
message: row.message,
createdAt: row.createdAt,
}));
const nextCursor = hasMore ? String(slice[slice.length - 1]!.createdAt) : null;
return c.json({ items, nextCursor } satisfies GalleryResponse);
});
publicRoutes.get('/stats', async (c) => {
const db = getDb(c.env.DB);
const all = await db
.select({ id: uploads.id, mimeType: uploads.mimeType })
.from(uploads)
.where(eq(uploads.status, 'approved'))
.all();
const photos = all.filter((u) => u.mimeType.startsWith('image/')).length;
const videos = all.filter((u) => u.mimeType.startsWith('video/')).length;
return c.json({ photos, videos, total: all.length });
}); });

View File

@ -1,30 +1,213 @@
import { eq } from 'drizzle-orm';
import { Hono } from 'hono'; import { Hono } 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 type { Bindings } from '../env.js';
import { hashIp, uploadId } from '../lib/ids.js';
import { createStorage } from '../lib/storage-factory.js';
export const uploadsRoutes = new Hono<Bindings>(); export const uploadsRoutes = new Hono<Bindings>();
// POST /api/uploads/init const SINGLE_PUT_MAX = 50 * 1024 * 1024;
// Body: UploadInit (zod schema in @leetete/shared) const PART_SIZE = 10 * 1024 * 1024;
// - validates Turnstile const KEY_PREFIX = 'uploads';
// - reads event_config (rejects if file too big / video disabled / over duration) const IP_HASH_SALT = 'wedding-uploads-v1';
// - decides single vs multipart based on sizeBytes (threshold ~50 MB)
// - creates row in `uploads` (status pending) function extFromFilename(filename: string, mimeType: string): string {
// - returns presigned PUT or multipart presigned URLs 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}`;
}
uploadsRoutes.post('/init', async (c) => { uploadsRoutes.post('/init', async (c) => {
return c.json({ error: 'not_implemented' }, 501); 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(c.env.DB);
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get();
if (!cfg) return c.json({ error: 'not_configured' }, 500);
const isVideo = input.mimeType.startsWith('video/');
if (isVideo && !cfg.allowVideo) {
return c.json({ error: 'video_not_allowed' }, 400);
}
if (input.sizeBytes > cfg.maxFileMb * 1024 * 1024) {
return c.json({ error: 'file_too_large', maxFileMb: cfg.maxFileMb }, 400);
}
if (
isVideo &&
input.durationSeconds !== undefined &&
input.durationSeconds > cfg.maxVideoSeconds
) {
return c.json({ error: 'video_too_long', maxVideoSeconds: cfg.maxVideoSeconds }, 400);
}
const id = uploadId();
const storageKey = buildKey(id, input.filename, input.mimeType);
const storage = createStorage(c.env);
const ip =
c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? 'unknown';
const ipHashValue = await hashIp(ip, IP_HASH_SALT);
const initialStatus = cfg.moderation === 'pre' ? 'pending' : 'approved';
const approvedAt = initialStatus === 'approved' ? Date.now() : null;
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: initialStatus,
source: 'guest',
ipHash: ipHashValue,
approvedAt,
});
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);
}); });
// 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) => { uploadsRoutes.post('/:id/confirm', async (c) => {
return c.json({ error: 'not_implemented' }, 501); 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(c.env.DB);
const upload = await db.select().from(uploads).where(eq(uploads.id, id)).get();
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)).get();
if (!cfg) return c.json({ error: 'not_configured' }, 500);
const storage = createStorage(c.env);
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 = cfg.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 });
}); });
// POST /api/uploads/:id/abort
// Cancels a pending multipart upload (cleanup).
uploadsRoutes.post('/:id/abort', async (c) => { uploadsRoutes.post('/:id/abort', async (c) => {
return c.json({ error: 'not_implemented' }, 501); const id = c.req.param('id');
const db = getDb(c.env.DB);
const upload = await db.select().from(uploads).where(eq(uploads.id, id)).get();
if (!upload) return c.json({ error: 'not_found' }, 404);
if (upload.providerUploadId) {
const storage = createStorage(c.env);
await storage
.abortMultipart({ key: upload.storageKey, uploadId: upload.providerUploadId })
.catch(() => {});
}
await db.delete(uploads).where(eq(uploads.id, id));
return c.json({ ok: true });
}); });

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

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

View File

@ -1,10 +1,157 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import type { GalleryItem, GalleryResponse } from '@leetete/shared';
export default function Gallery() { 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 ( return (
<main className="min-h-full flex items-center justify-center p-6"> <main className="min-h-full px-4 py-8 max-w-5xl mx-auto">
<div className="text-center text-stone-700"> <div className="flex items-center justify-between mb-6">
<h2 className="font-display text-3xl mb-3">Galeria</h2> <Link to="/" className="text-sm text-stone-500 hover:text-stone-700">
<p>Em breve.</p> 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> </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> </main>
); );
} }

View File

@ -1,10 +1,258 @@
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() { 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 ( return (
<main className="min-h-full flex items-center justify-center p-6"> <main className="min-h-full flex flex-col items-center justify-center px-6 py-12 text-center">
<div className="text-center text-stone-700"> <h2 className="font-display text-4xl text-stone-800 mb-3">Obrigado!</h2>
<h2 className="font-display text-3xl mb-3">Enviar fotos</h2> <p className="max-w-md text-stone-700 mb-8">
<p>Em breve.</p> {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> </div>
</main> </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 R2 — me avisa.';
if (msg.includes('network')) return 'Falha de rede. Tenta de novo.';
return 'Erro ao enviar. Tenta de novo em alguns segundos.';
}

View File

@ -24,7 +24,7 @@ export const uploadInitSchema = z.object({
durationSeconds: z.number().int().positive().optional(), durationSeconds: z.number().int().positive().optional(),
authorName: z.string().max(80).optional(), authorName: z.string().max(80).optional(),
message: z.string().max(2000).optional(), message: z.string().max(2000).optional(),
turnstileToken: z.string().min(1), turnstileToken: z.string().min(1).optional(),
}); });
export type UploadInit = z.infer<typeof uploadInitSchema>; export type UploadInit = z.infer<typeof uploadInitSchema>;