266 lines
8.4 KiB
TypeScript
266 lines
8.4 KiB
TypeScript
'use client'
|
|
|
|
import { useScene } from '@pascal-app/core'
|
|
import { ImageIcon, MessageSquare, X } from 'lucide-react'
|
|
import { useCallback, useRef, useState } from 'react'
|
|
import { Button } from './ui/primitives/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from './ui/primitives/dialog'
|
|
|
|
const MAX_IMAGES = 5
|
|
const MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
|
|
|
type ImagePreview = { file: File; url: string }
|
|
|
|
export function FeedbackDialog({
|
|
projectId: projectIdProp,
|
|
onSubmit,
|
|
}: {
|
|
projectId?: string
|
|
onSubmit?: (data: {
|
|
message: string
|
|
projectId?: string
|
|
sceneGraph: unknown
|
|
images: File[]
|
|
}) => Promise<{ success: boolean; error?: string }>
|
|
}) {
|
|
const projectId = projectIdProp
|
|
|
|
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)
|
|
|
|
try {
|
|
if (!onSubmit) return
|
|
const { nodes, rootNodeIds } = useScene.getState()
|
|
const sceneGraph = { nodes, rootNodeIds }
|
|
const result = await onSubmit({
|
|
message,
|
|
projectId,
|
|
sceneGraph,
|
|
images: images.map((img) => img.file),
|
|
})
|
|
if (result.success) {
|
|
setSent(true)
|
|
setTimeout(() => setOpen(false), 1500)
|
|
} else {
|
|
setError(result.error ?? 'Something went wrong')
|
|
}
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
|
|
onClick={handleOpen}
|
|
>
|
|
<MessageSquare className="h-4 w-4" />
|
|
Feedback
|
|
</button>
|
|
|
|
<Dialog onOpenChange={handleClose} open={open}>
|
|
<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-primary/50 border-dashed 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="font-medium text-sm">Drop images here</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<DialogHeader>
|
|
<DialogTitle>Send Feedback</DialogTitle>
|
|
<DialogDescription>We'd love to hear your thoughts</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{sent ? (
|
|
<p className="py-4 text-center text-muted-foreground text-sm">
|
|
Thanks for your feedback!
|
|
</p>
|
|
) : (
|
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
|
<div>
|
|
<label className="font-medium text-sm" htmlFor="feedback-message">
|
|
Your feedback
|
|
</label>
|
|
<textarea
|
|
autoFocus
|
|
className="mt-1 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
|
disabled={isSubmitting}
|
|
id="feedback-message"
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
placeholder="Share your thoughts, suggestions, feature requests, or report issues..."
|
|
rows={5}
|
|
value={message}
|
|
/>
|
|
</div>
|
|
|
|
{/* Image thumbnails */}
|
|
{images.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{images.map((img, i) => (
|
|
<div
|
|
className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
|
|
key={img.url}
|
|
>
|
|
<img alt="" className="h-full w-full object-cover" src={img.url} />
|
|
<button
|
|
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
|
|
onClick={() => removeImage(i)}
|
|
type="button"
|
|
>
|
|
<X className="h-4 w-4 text-white" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
|
|
<div className="flex items-center justify-between">
|
|
{/* Subtle attach button */}
|
|
<button
|
|
className="flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground disabled:opacity-40"
|
|
disabled={isSubmitting || images.length >= MAX_IMAGES}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
type="button"
|
|
>
|
|
<ImageIcon className="h-3.5 w-3.5" />
|
|
{images.length > 0 ? `${images.length}/${MAX_IMAGES}` : 'Attach'}
|
|
</button>
|
|
<input
|
|
accept="image/*"
|
|
className="hidden"
|
|
multiple
|
|
onChange={(e) => {
|
|
if (e.target.files) addFiles(e.target.files)
|
|
e.target.value = ''
|
|
}}
|
|
ref={fileInputRef}
|
|
type="file"
|
|
/>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
disabled={isSubmitting}
|
|
onClick={handleClose}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button disabled={isSubmitting || !message.trim() || !onSubmit} type="submit">
|
|
{isSubmitting ? 'Sending...' : 'Send Feedback'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|