feat: enhance feedback form with image upload, user/project context, and scene graph (#113)

* 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 <anton-pascal@users.noreply.github.com>
This commit is contained in:
Anton
2026-02-22 23:52:42 -05:00
committed by GitHub
co-authored by Anton Pascal
parent c0a7fd8067
commit c476d30303
5 changed files with 317 additions and 29 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ export default function Editor({ projectId }: EditorProps) {
<PascalRadio /> <PascalRadio />
</div> </div>
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<FeedbackDialog /> <FeedbackDialog projectId={projectId} />
</div> </div>
</div> </div>
+227 -21
View File
@@ -1,8 +1,13 @@
'use client' 'use client'
import { MessageSquare } from 'lucide-react' import { ImageIcon, MessageSquare, X } from 'lucide-react'
import { useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { submitFeedback } from '@/features/community/lib/feedback/actions' import { useParams } from 'next/navigation'
import { useScene } from '@pascal-app/core'
import {
createImageUploadUrls,
submitFeedback,
} from '@/features/community/lib/feedback/actions'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -12,36 +17,172 @@ import {
} from '@/components/ui/primitives/dialog' } from '@/components/ui/primitives/dialog'
import { Button } from '@/components/ui/primitives/button' 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 [open, setOpen] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [images, setImages] = useState<ImagePreview[]>([])
const [isDragging, setIsDragging] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false) const [sent, setSent] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragCounter = useRef(0)
const handleOpen = () => { const handleOpen = () => {
setOpen(true) setOpen(true)
setSent(false) setSent(false)
setError(null) setError(null)
setMessage('') setMessage('')
setImages([])
setIsDragging(false)
dragCounter.current = 0
} }
const handleClose = () => { const handleClose = () => {
if (isSubmitting) return if (isSubmitting) return
setOpen(false) 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError(null) setError(null)
setIsSubmitting(true) setIsSubmitting(true)
const result = await submitFeedback(message)
setIsSubmitting(false) try {
if (result.success) { // Capture scene graph snapshot
setSent(true) let sceneGraph: unknown = null
setTimeout(() => setOpen(false), 1500) try {
} else { const { nodes, rootNodeIds } = useScene.getState()
setError(result.error) 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<string> =>
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() {
</button> </button>
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[460px]"> <DialogContent
className="sm:max-w-[460px]"
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{/* Drag overlay — only visible when dragging files over the dialog */}
{isDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-dashed border-primary/50 bg-primary/5 backdrop-blur-sm transition-all">
<div className="flex flex-col items-center gap-2 text-primary/70">
<ImageIcon className="h-8 w-8" />
<p className="text-sm font-medium">Drop images here</p>
</div>
</div>
)}
<DialogHeader> <DialogHeader>
<DialogTitle>Send Feedback</DialogTitle> <DialogTitle>Send Feedback</DialogTitle>
<DialogDescription>We&apos;d love to hear your thoughts</DialogDescription> <DialogDescription>We&apos;d love to hear your thoughts</DialogDescription>
@@ -84,17 +241,66 @@ export function FeedbackDialog() {
/> />
</div> </div>
{error && ( {/* Image thumbnails */}
<p className="text-sm text-destructive">{error}</p> {images.length > 0 && (
<div className="flex flex-wrap gap-2">
{images.map((img, i) => (
<div
key={img.url}
className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
>
<img
src={img.url}
alt=""
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={() => removeImage(i)}
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
>
<X className="h-4 w-4 text-white" />
</button>
</div>
))}
</div>
)} )}
<div className="flex justify-end gap-2"> {error && <p className="text-sm text-destructive">{error}</p>}
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
Cancel <div className="flex items-center justify-between">
</Button> {/* Subtle attach button */}
<Button type="submit" disabled={isSubmitting || !message.trim()}> <button
{isSubmitting ? 'Sending...' : 'Send Feedback'} type="button"
</Button> onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting || images.length >= MAX_IMAGES}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40"
>
<ImageIcon className="h-3.5 w-3.5" />
{images.length > 0
? `${images.length}/${MAX_IMAGES}`
: 'Attach'}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files)
e.target.value = ''
}}
/>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !message.trim()}>
{isSubmitting ? 'Sending...' : 'Send Feedback'}
</Button>
</div>
</div> </div>
</form> </form>
)} )}
@@ -4,12 +4,68 @@ import { createId } from '@pascal-app/db'
import { createServerSupabaseClient } from '../database/server' import { createServerSupabaseClient } from '../database/server'
import { getSession } from '../auth/server' import { getSession } from '../auth/server'
export async function submitFeedback( const MAX_IMAGES = 5
message: string,
): Promise<{ success: true } | { success: false; error: string }> { /**
* 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 { try {
const trimmed = message.trim() if (files.length > MAX_IMAGES) {
if (!trimmed) return { success: false, error: 'Message cannot be empty' } 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<typeof import('@supabase/supabase-js').createClient>
).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 session = await getSession()
const supabase = await createServerSupabaseClient() const supabase = await createServerSupabaseClient()
@@ -18,7 +74,10 @@ export async function submitFeedback(
const { error } = await (supabase as any).from('feedback').insert({ const { error } = await (supabase as any).from('feedback').insert({
id: createId('feedback'), id: createId('feedback'),
user_id: session?.user?.id ?? null, 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 } if (error) return { success: false, error: error.message }
+4 -1
View File
@@ -4,8 +4,11 @@ import { id, createdAt } from '../../helpers'
export const feedback = pgTable('feedback', (t) => ({ export const feedback = pgTable('feedback', (t) => ({
id: id('feedback'), 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(), message: t.text('message').notNull(),
images: t.jsonb('images').$type<string[]>(),
sceneGraph: t.jsonb('scene_graph'),
createdAt, createdAt,
})).enableRLS() })).enableRLS()
@@ -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');