From c476d303035b7b59dea26a7ef2f0c5bd3c1a8e0f Mon Sep 17 00:00:00 2001 From: Anton Date: Mon, 23 Feb 2026 04:52:42 +0000 Subject: [PATCH] feat: enhance feedback form with image upload, user/project context, and scene graph (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enhance feedback form with image upload, user/project context, and scene graph - Add invisible drag-and-drop zone that reveals on hover+drag with dashed border overlay - Multi-image upload (max 5, max 5MB each) to Supabase Storage feedback-images bucket - Subtle attach button + thumbnail previews with remove on hover - Auto-capture authenticated user email/name from Better Auth session - Pass projectId from editor context - Snapshot scene graph (nodes + rootNodeIds) on submit - DB migration adds user_email, user_name, project_id, images (jsonb), scene_graph (jsonb) columns - Storage bucket + RLS policies for public read / service role write * refactor: use existing user_id FK instead of denormalized email/name columns Removed user_email and user_name — the user_id already links to the users table. Simpler schema, no data duplication. * fix: guard against undefined in removeImage * refactor: direct Supabase Storage upload via signed URLs Bypass Vercel's 4.5MB serverless body-size limit by uploading images directly from the client to Supabase Storage. - New createImageUploadUrls server action generates signed upload URLs - Client PUTs files directly to Supabase (no bytes through Vercel) - submitFeedback now receives only image paths, not FormData with files - No migration changes needed (existing RLS policies support signed URLs) * fix: remove relative class that broke dialog centering twMerge was replacing the Dialog's fixed positioning with relative, pushing the dialog to the bottom of the viewport. --------- Co-authored-by: Anton Pascal --- apps/editor/components/editor/index.tsx | 2 +- apps/editor/components/feedback-dialog.tsx | 248 ++++++++++++++++-- .../community/lib/feedback/actions.ts | 71 ++++- packages/db/src/schema/feedback/feedback.ts | 5 +- ...0223020000_feedback_images_and_context.sql | 20 ++ 5 files changed, 317 insertions(+), 29 deletions(-) create mode 100644 supabase/migrations/20260223020000_feedback_images_and_context.sql diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 61759c9e..08b5e0dc 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -62,7 +62,7 @@ export default function Editor({ projectId }: EditorProps) {
- +
diff --git a/apps/editor/components/feedback-dialog.tsx b/apps/editor/components/feedback-dialog.tsx index 4b659bb4..d4386f1d 100644 --- a/apps/editor/components/feedback-dialog.tsx +++ b/apps/editor/components/feedback-dialog.tsx @@ -1,8 +1,13 @@ 'use client' -import { MessageSquare } from 'lucide-react' -import { useState } from 'react' -import { submitFeedback } from '@/features/community/lib/feedback/actions' +import { ImageIcon, MessageSquare, X } from 'lucide-react' +import { useCallback, useRef, useState } from 'react' +import { useParams } from 'next/navigation' +import { useScene } from '@pascal-app/core' +import { + createImageUploadUrls, + submitFeedback, +} from '@/features/community/lib/feedback/actions' import { Dialog, DialogContent, @@ -12,36 +17,172 @@ import { } from '@/components/ui/primitives/dialog' import { Button } from '@/components/ui/primitives/button' -export function FeedbackDialog() { +const MAX_IMAGES = 5 +const MAX_IMAGE_SIZE = 5 * 1024 * 1024 + +type ImagePreview = { file: File; url: string } + +export function FeedbackDialog({ projectId: projectIdProp }: { projectId?: string }) { + const params = useParams() + const projectId = projectIdProp ?? (params?.projectId as string | undefined) + const [open, setOpen] = useState(false) const [message, setMessage] = useState('') + const [images, setImages] = useState([]) + const [isDragging, setIsDragging] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState(null) const [sent, setSent] = useState(false) + const fileInputRef = useRef(null) + const dragCounter = useRef(0) const handleOpen = () => { setOpen(true) setSent(false) setError(null) setMessage('') + setImages([]) + setIsDragging(false) + dragCounter.current = 0 } const handleClose = () => { if (isSubmitting) return setOpen(false) + images.forEach((img) => URL.revokeObjectURL(img.url)) + } + + const addFiles = useCallback( + (files: FileList | File[]) => { + const incoming = Array.from(files).filter( + (f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE, + ) + setImages((prev) => { + const remaining = MAX_IMAGES - prev.length + const added = incoming.slice(0, remaining).map((file) => ({ + file, + url: URL.createObjectURL(file), + })) + return [...prev, ...added] + }) + }, + [], + ) + + const removeImage = (index: number) => { + setImages((prev) => { + const img = prev[index] + if (img) URL.revokeObjectURL(img.url) + return prev.filter((_, i) => i !== index) + }) + } + + // ── Drag handlers (on the entire dialog content) ── + const onDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCounter.current++ + if (e.dataTransfer.types.includes('Files')) { + setIsDragging(true) + } + } + + const onDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCounter.current-- + if (dragCounter.current === 0) { + setIsDragging(false) + } + } + + const onDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + } + + const onDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCounter.current = 0 + setIsDragging(false) + if (e.dataTransfer.files.length > 0) { + addFiles(e.dataTransfer.files) + } } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError(null) setIsSubmitting(true) - const result = await submitFeedback(message) - setIsSubmitting(false) - if (result.success) { - setSent(true) - setTimeout(() => setOpen(false), 1500) - } else { - setError(result.error) + + try { + // Capture scene graph snapshot + let sceneGraph: unknown = null + try { + const { nodes, rootNodeIds } = useScene.getState() + sceneGraph = { nodes, rootNodeIds } + } catch { + // Scene store may not be available (e.g. on non-editor pages) + } + + // Upload images directly to Supabase Storage via signed URLs + let imagePaths: string[] = [] + + if (images.length > 0) { + const urlResult = await createImageUploadUrls( + images.map((img) => ({ name: img.file.name, type: img.file.type })), + ) + + if (!urlResult.success) { + setError(urlResult.error) + return + } + + // Upload each file directly to Supabase (bypasses Vercel size limit) + const uploadResults = await Promise.allSettled( + urlResult.uploads.map(async ({ path, signedUrl }, i) => { + const file = images[i]?.file + if (!file) return null + + const res = await fetch(signedUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type }, + body: file, + }) + + if (!res.ok) { + console.error(`Upload failed for ${file.name}: ${res.status}`) + return null + } + + return path + }), + ) + + imagePaths = uploadResults + .filter( + (r): r is PromiseFulfilledResult => + r.status === 'fulfilled' && r.value !== null, + ) + .map((r) => r.value) + } + + const result = await submitFeedback({ + message, + projectId, + sceneGraph, + imagePaths, + }) + + if (result.success) { + setSent(true) + setTimeout(() => setOpen(false), 1500) + } else { + setError(result.error) + } + } finally { + setIsSubmitting(false) } } @@ -56,7 +197,23 @@ export function FeedbackDialog() { - + + {/* Drag overlay — only visible when dragging files over the dialog */} + {isDragging && ( +
+
+ +

Drop images here

+
+
+ )} + Send Feedback We'd love to hear your thoughts @@ -84,17 +241,66 @@ export function FeedbackDialog() { /> - {error && ( -

{error}

+ {/* Image thumbnails */} + {images.length > 0 && ( +
+ {images.map((img, i) => ( +
+ + +
+ ))} +
)} -
- - + {error &&

{error}

} + +
+ {/* Subtle attach button */} + + { + if (e.target.files) addFiles(e.target.files) + e.target.value = '' + }} + /> + +
+ + +
)} diff --git a/apps/editor/features/community/lib/feedback/actions.ts b/apps/editor/features/community/lib/feedback/actions.ts index b297e390..8dabe9f4 100644 --- a/apps/editor/features/community/lib/feedback/actions.ts +++ b/apps/editor/features/community/lib/feedback/actions.ts @@ -4,12 +4,68 @@ import { createId } from '@pascal-app/db' import { createServerSupabaseClient } from '../database/server' import { getSession } from '../auth/server' -export async function submitFeedback( - message: string, -): Promise<{ success: true } | { success: false; error: string }> { +const MAX_IMAGES = 5 + +/** + * Create signed upload URLs so the client can upload images directly to + * Supabase Storage — bypasses Vercel's 4.5 MB serverless body-size limit. + */ +export async function createImageUploadUrls( + files: { name: string; type: string }[], +): Promise< + | { success: true; uploads: { path: string; signedUrl: string }[] } + | { success: false; error: string } +> { try { - const trimmed = message.trim() - if (!trimmed) return { success: false, error: 'Message cannot be empty' } + if (files.length > MAX_IMAGES) { + return { success: false, error: `Maximum ${MAX_IMAGES} images allowed` } + } + + const supabase = await createServerSupabaseClient() + const uploads: { path: string; signedUrl: string }[] = [] + + for (const file of files) { + if (!file.type.startsWith('image/')) continue + + const ext = file.name.split('.').pop() || 'jpg' + const path = `${createId('img')}.${ext}` + + const { data, error } = await ( + supabase as ReturnType + ).storage + .from('feedback-images') + .createSignedUploadUrl(path) + + if (error || !data) { + console.error(`Failed to create signed URL for ${file.name}:`, error) + continue + } + + uploads.push({ path, signedUrl: data.signedUrl }) + } + + return { success: true, uploads } + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Failed to create upload URLs', + } + } +} + +/** + * Submit feedback with pre-uploaded image paths. + * Images are already in Supabase Storage — this just records the metadata. + */ +export async function submitFeedback(data: { + message: string + projectId?: string | null + sceneGraph?: unknown + imagePaths?: string[] +}): Promise<{ success: true } | { success: false; error: string }> { + try { + const { message, projectId, sceneGraph, imagePaths } = data + if (!message?.trim()) return { success: false, error: 'Message cannot be empty' } const session = await getSession() const supabase = await createServerSupabaseClient() @@ -18,7 +74,10 @@ export async function submitFeedback( const { error } = await (supabase as any).from('feedback').insert({ id: createId('feedback'), user_id: session?.user?.id ?? null, - message: trimmed, + project_id: projectId ?? null, + message: message.trim(), + images: imagePaths && imagePaths.length > 0 ? imagePaths : null, + scene_graph: sceneGraph ?? null, }) if (error) return { success: false, error: error.message } diff --git a/packages/db/src/schema/feedback/feedback.ts b/packages/db/src/schema/feedback/feedback.ts index f49931e0..5dea8607 100644 --- a/packages/db/src/schema/feedback/feedback.ts +++ b/packages/db/src/schema/feedback/feedback.ts @@ -4,8 +4,11 @@ import { id, createdAt } from '../../helpers' export const feedback = pgTable('feedback', (t) => ({ id: id('feedback'), - userId: t.text('user_id'), // nullable — stores Better Auth user ID or null for anonymous + userId: t.text('user_id'), + projectId: t.text('project_id'), message: t.text('message').notNull(), + images: t.jsonb('images').$type(), + sceneGraph: t.jsonb('scene_graph'), createdAt, })).enableRLS() diff --git a/supabase/migrations/20260223020000_feedback_images_and_context.sql b/supabase/migrations/20260223020000_feedback_images_and_context.sql new file mode 100644 index 00000000..84422634 --- /dev/null +++ b/supabase/migrations/20260223020000_feedback_images_and_context.sql @@ -0,0 +1,20 @@ +-- Add image upload, project context, and scene graph to feedback +ALTER TABLE feedback + ADD COLUMN IF NOT EXISTS project_id text, + ADD COLUMN IF NOT EXISTS images jsonb, + ADD COLUMN IF NOT EXISTS scene_graph jsonb; + +-- Create feedback-images storage bucket (public read, service-role write) +INSERT INTO storage.buckets (id, name, public) +VALUES ('feedback-images', 'feedback-images', true) +ON CONFLICT (id) DO NOTHING; + +-- Allow public read on feedback-images bucket +CREATE POLICY "Public read feedback images" + ON storage.objects FOR SELECT + USING (bucket_id = 'feedback-images'); + +-- Allow service role (and authenticated users) to upload +CREATE POLICY "Service role upload feedback images" + ON storage.objects FOR INSERT + WITH CHECK (bucket_id = 'feedback-images');