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:
@@ -62,7 +62,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||
<PascalRadio />
|
||||
</div>
|
||||
<div className="pointer-events-auto">
|
||||
<FeedbackDialog />
|
||||
<FeedbackDialog projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,37 +17,173 @@ 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<ImagePreview[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [sent, setSent] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(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)
|
||||
|
||||
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<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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -56,7 +197,23 @@ export function FeedbackDialog() {
|
||||
</button>
|
||||
|
||||
<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>
|
||||
<DialogTitle>Send Feedback</DialogTitle>
|
||||
<DialogDescription>We'd love to hear your thoughts</DialogDescription>
|
||||
@@ -84,11 +241,59 @@ export function FeedbackDialog() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
{/* Image thumbnails */}
|
||||
{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>}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Subtle attach button */}
|
||||
<button
|
||||
type="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>
|
||||
@@ -96,6 +301,7 @@ export function FeedbackDialog() {
|
||||
{isSubmitting ? 'Sending...' : 'Send Feedback'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -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<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 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 }
|
||||
|
||||
@@ -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<string[]>(),
|
||||
sceneGraph: t.jsonb('scene_graph'),
|
||||
createdAt,
|
||||
})).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');
|
||||
Reference in New Issue
Block a user