From dbfd917e5098922bb73046840e6fef81076b142b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 02:08:00 +0000 Subject: [PATCH] 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 --- apps/api/src/routes/public.ts | 64 +++++++- apps/api/src/routes/uploads.ts | 217 ++++++++++++++++++++++++--- apps/web/src/lib/upload.ts | 143 ++++++++++++++++++ apps/web/src/routes/Gallery.tsx | 155 ++++++++++++++++++- apps/web/src/routes/Upload.tsx | 258 +++++++++++++++++++++++++++++++- packages/shared/src/schemas.ts | 2 +- 6 files changed, 808 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/lib/upload.ts diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts index 06afbdb..f34d0c1 100644 --- a/apps/api/src/routes/public.ts +++ b/apps/api/src/routes/public.ts @@ -1,11 +1,15 @@ -import { eq } from 'drizzle-orm'; +import { and, desc, eq, lt } from 'drizzle-orm'; import { Hono } from 'hono'; +import type { GalleryResponse } from '@leetete/shared'; 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 { createStorage } from '../lib/storage-factory.js'; export const publicRoutes = new Hono(); +const GALLERY_PAGE_SIZE = 24; + publicRoutes.get('/event', async (c) => { const db = getDb(c.env.DB); 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) => { - // TODO: paginated list of approved uploads with public URLs - return c.json({ items: [], nextCursor: null }); + const db = getDb(c.env.DB); + 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 }); }); diff --git a/apps/api/src/routes/uploads.ts b/apps/api/src/routes/uploads.ts index a346d25..dabc557 100644 --- a/apps/api/src/routes/uploads.ts +++ b/apps/api/src/routes/uploads.ts @@ -1,30 +1,213 @@ +import { eq } from 'drizzle-orm'; 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 { hashIp, uploadId } from '../lib/ids.js'; +import { createStorage } from '../lib/storage-factory.js'; export const uploadsRoutes = new Hono(); -// 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 +const SINGLE_PUT_MAX = 50 * 1024 * 1024; +const PART_SIZE = 10 * 1024 * 1024; +const KEY_PREFIX = 'uploads'; +const IP_HASH_SALT = 'wedding-uploads-v1'; + +function extFromFilename(filename: string, mimeType: string): string { + 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 = { + '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) => { - 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) => { - 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) => { - 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 }); }); diff --git a/apps/web/src/lib/upload.ts b/apps/web/src/lib/upload.ts new file mode 100644 index 0000000..72f74fc --- /dev/null +++ b/apps/web/src/lib/upload.ts @@ -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 { + if (!file.type.startsWith('video/')) return undefined; + return await new Promise((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, + 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 { + 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 }; +} diff --git a/apps/web/src/routes/Gallery.tsx b/apps/web/src/routes/Gallery.tsx index 96e4417..a45d9f3 100644 --- a/apps/web/src/routes/Gallery.tsx +++ b/apps/web/src/routes/Gallery.tsx @@ -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() { + const [items, setItems] = useState([]); + const [cursor, setCursor] = useState(null); + const [loading, setLoading] = useState(false); + const [done, setDone] = useState(false); + const [error, setError] = useState(null); + const [active, setActive] = useState(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 ( -
-
-

Galeria

-

Em breve.

+
+
+ + ← Voltar + + + + Enviar +
+

Galeria

+ + {items.length === 0 && !loading && !error && ( +
+ Ainda sem fotos. Seja a primeira pessoa a compartilhar! +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ {items.map((item) => ( + + ))} +
+ + {!done && ( +
+ +
+ )} + + {active && ( +
setActive(null)} + > + +
e.stopPropagation()} + > + {active.isVideo ? ( +
+ {(active.authorName || active.message) && ( +
e.stopPropagation()} + > + {active.authorName && ( +

{active.authorName}

+ )} + {active.message &&

{active.message}

} +
+ )} +
+ )}
); } diff --git a/apps/web/src/routes/Upload.tsx b/apps/web/src/routes/Upload.tsx index 89fee8b..b4cbc45 100644 --- a/apps/web/src/routes/Upload.tsx +++ b/apps/web/src/routes/Upload.tsx @@ -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() { + const [event, setEvent] = useState(null); + const [files, setFiles] = useState([]); + const [authorName, setAuthorName] = useState(''); + const [message, setMessage] = useState(''); + const [status, setStatus] = useState('idle'); + const [progress, setProgress] = useState({ index: 0, loaded: 0, total: 0 }); + const [error, setError] = useState(null); + const [successCount, setSuccessCount] = useState(0); + const abortRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + fetch('/api/event') + .then((r) => (r.ok ? (r.json() as Promise) : 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 ( +
+

Obrigado!

+

+ {successCount === 1 + ? 'Sua foto/vídeo foi enviado com sucesso.' + : `${successCount} arquivos enviados com sucesso.`} + {message ? ' Sua mensagem aos noivos foi guardada.' : ''} +

+
+ + + Ver galeria + +
+
+ ); + } + return ( -
-
-

Enviar fotos

-

Em breve.

-
+
+ + ← Voltar + +

Enviar fotos

+

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

+ +
+
+ + 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 && ( +

+ {files.length} arquivo{files.length > 1 ? 's' : ''} ({formatBytes(totalBytes)}) +

+ )} + {overLimit && ( +

+ Algum arquivo excede o limite ({event?.maxFileMb} MB) ou é um vídeo (não permitido). +

+ )} +
+ +
+ + 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" + /> +
+ +
+ +