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 />
|
<PascalRadio />
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<FeedbackDialog />
|
<FeedbackDialog projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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'd love to hear your thoughts</DialogDescription>
|
<DialogDescription>We'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,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');
|
||||||
Reference in New Issue
Block a user