This repository has been archived on 2026-06-09. You can view files and clone it, but cannot push or open issues or pull requests.
wedding-app/infra/wedding_photo/apps/web/src/routes/Upload.tsx
Claude 5a19753013
feat: split into 3 docker-compose stacks (main / gitea / wedding_photo)
Repo passa a viver dentro de infra/, com três stacks isoladas que
compartilham uma network Docker externa ('infra-net'):

main/        infra base: postgres + redis + minio + minio-init + pgadmin
             + caddy. Postgres roda em modo multi-banco; o init script
             cria os DBs 'wedding' e 'gitea' com roles dedicadas. MinIO
             tem um bucket inicial criado pelo minio-init com anonymous
             download + CORS. Caddy é o único container expondo 80/443
             e roteia por hostname: gitea.{DOMAIN_BASE} / wedding.* /
             pgadmin.* / minio.* / media.* (rewrite de bucket).

gitea/       gitea + act_runner. Gitea liga no postgres compartilhado e
             usa redis pra cache+sessões. O runner ganha um Dockerfile
             pequeno que adiciona docker CLI por cima do
             gitea/act_runner pra workflows poderem chamar 'docker
             build'. Bootstrap do token de runner documentado no
             .env.example.

wedding_photo/  Só a aplicação: 'wedding_app' (FastAPI + SPA) +
             postgres-backup + media-backup. Os bancos e o MinIO vêm
             da stack main/. A app usa extra_hosts: host-gateway pra
             alcançar media.{DOMAIN_BASE} via Caddy mesmo em dev local
             — assim a assinatura S3 fecha com o host que o browser
             usa pra fazer PUT.

Orquestração:
- Makefile no root: 'make up' sobe tudo na ordem (main -> gitea ->
  wedding_photo). 'make up-{main,gitea,wedding}' pra controle
  granular. 'make logs-*', 'make down', 'make status', 'make pull-*'.
- network.sh cria a 'infra-net' antes de qualquer up; idempotente.
- Cada stack tem seu próprio .env.example. As creds compartilhadas
  (DOMAIN_BASE, MINIO_ROOT_*, WEDDING_DB_*) precisam casar entre
  main/.env e o consumidor (gitea/.env ou wedding_photo/.env).
- .gitignore ignora todas as pastas data/ dos volumes.
2026-06-08 23:34:14 +00:00

263 lines
9.1 KiB
TypeScript

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.';
}