263 lines
9.1 KiB
TypeScript
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.';
|
||
|
|
}
|