* 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>
93 lines
2.8 KiB
TypeScript
93 lines
2.8 KiB
TypeScript
'use server'
|
|
|
|
import { createId } from '@pascal-app/db'
|
|
import { createServerSupabaseClient } from '../database/server'
|
|
import { getSession } from '../auth/server'
|
|
|
|
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 {
|
|
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()
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const { error } = await (supabase as any).from('feedback').insert({
|
|
id: createId('feedback'),
|
|
user_id: session?.user?.id ?? null,
|
|
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 }
|
|
|
|
return { success: true }
|
|
} catch (err) {
|
|
return {
|
|
success: false,
|
|
error: err instanceof Error ? err.message : 'Failed to submit feedback',
|
|
}
|
|
}
|
|
}
|