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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user