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:
parent
4f85dd8721
commit
dbfd917e50
@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
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 };
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.';
|
||||||
|
}
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user