feat: [US-039] - Loading and error states

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-22 23:06:43 -03:00
parent f1e92ba1a0
commit b9d778b379
5 changed files with 127 additions and 31 deletions

View File

@ -413,7 +413,7 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null) const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
const [conditionEditor, setConditionEditor] = useState<ConditionEditorState>(null) const [conditionEditor, setConditionEditor] = useState<ConditionEditorState>(null)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null) const [toast, setToast] = useState<{ message: string; type: 'success' | 'error'; action?: { label: string; onClick: () => void } } | null>(null)
const [importConfirmDialog, setImportConfirmDialog] = useState<{ const [importConfirmDialog, setImportConfirmDialog] = useState<{
pendingData: FlowchartData pendingData: FlowchartData
} | null>(null) } | null>(null)
@ -425,6 +425,9 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
// Ref for hidden file input // Ref for hidden file input
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
// Ref for save function to enable retry without circular dependency
const handleSaveRef = useRef<() => void>(() => {})
// Check for saved draft on initial render (lazy initialization) // Check for saved draft on initial render (lazy initialization)
const [draftState, setDraftState] = useState<{ const [draftState, setDraftState] = useState<{
showPrompt: boolean showPrompt: boolean
@ -628,12 +631,19 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
setToast({ message: 'Project saved successfully', type: 'success' }) setToast({ message: 'Project saved successfully', type: 'success' })
} catch (error) { } catch (error) {
console.error('Failed to save project:', error) console.error('Failed to save project:', error)
setToast({ message: 'Failed to save project. Please try again.', type: 'error' }) setToast({
message: 'Failed to save project.',
type: 'error',
action: { label: 'Retry', onClick: () => { setToast(null); handleSaveRef.current() } },
})
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }
}, [isSaving, nodes, edges, projectId]) }, [isSaving, nodes, edges, projectId])
// Keep ref updated with latest handleSave
handleSaveRef.current = handleSave
const handleExport = useCallback(() => { const handleExport = useCallback(() => {
// Convert React Flow state to FlowchartData // Convert React Flow state to FlowchartData
const flowchartData: FlowchartData = { const flowchartData: FlowchartData = {
@ -1184,6 +1194,7 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
message={toast.message} message={toast.message}
type={toast.type} type={toast.type}
onClose={() => setToast(null)} onClose={() => setToast(null)}
action={toast.action}
/> />
)} )}
</div> </div>

View File

@ -1,3 +1,5 @@
import LoadingSpinner from '@/components/LoadingSpinner'
export default function EditorLoading() { export default function EditorLoading() {
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
@ -9,31 +11,7 @@ export default function EditorLoading() {
</header> </header>
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800"> <div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
<div className="flex flex-col items-center gap-3"> <LoadingSpinner size="lg" message="Loading editor..." />
<svg
className="h-8 w-8 animate-spin text-blue-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Loading editor...
</p>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,5 +1,5 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation' import Link from 'next/link'
import FlowchartEditor from './FlowchartEditor' import FlowchartEditor from './FlowchartEditor'
import type { FlowchartData } from '@/types/flowchart' import type { FlowchartData } from '@/types/flowchart'
@ -27,7 +27,59 @@ export default async function EditorPage({ params }: PageProps) {
.single() .single()
if (error || !project) { if (error || !project) {
notFound() return (
<div className="flex h-screen flex-col">
<header className="flex items-center border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
<Link
href="/dashboard"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
aria-label="Back to dashboard"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
</Link>
</header>
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
<div className="flex flex-col items-center gap-4 text-center">
<svg
className="h-12 w-12 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Project Not Found
</h2>
<p className="max-w-sm text-sm text-zinc-600 dark:text-zinc-400">
The project you&apos;re looking for doesn&apos;t exist or you don&apos;t have access to it.
</p>
<Link
href="/dashboard"
className="mt-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600"
>
Back to Dashboard
</Link>
</div>
</div>
</div>
)
} }
const flowchartData = (project.flowchart_data || { const flowchartData = (project.flowchart_data || {

View File

@ -0,0 +1,40 @@
type LoadingSpinnerProps = {
size?: 'sm' | 'md' | 'lg'
message?: string
}
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12',
}
export default function LoadingSpinner({ size = 'md', message }: LoadingSpinnerProps) {
return (
<div className="flex flex-col items-center gap-3">
<svg
className={`animate-spin text-blue-500 ${sizeClasses[size]}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{message && (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{message}</p>
)}
</div>
)
}

View File

@ -6,16 +6,23 @@ interface ToastProps {
message: string message: string
type: 'success' | 'error' type: 'success' | 'error'
onClose: () => void onClose: () => void
action?: {
label: string
onClick: () => void
}
} }
export default function Toast({ message, type, onClose }: ToastProps) { export default function Toast({ message, type, onClose, action }: ToastProps) {
useEffect(() => { useEffect(() => {
// Don't auto-dismiss if there's an action button
if (action) return
const timer = setTimeout(() => { const timer = setTimeout(() => {
onClose() onClose()
}, 3000) }, 3000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [onClose]) }, [onClose, action])
const bgColor = const bgColor =
type === 'success' type === 'success'
@ -60,6 +67,14 @@ export default function Toast({ message, type, onClose }: ToastProps) {
> >
{icon} {icon}
<span>{message}</span> <span>{message}</span>
{action && (
<button
onClick={action.onClick}
className="ml-2 rounded bg-white/20 px-2 py-0.5 text-xs font-semibold hover:bg-white/30"
>
{action.label}
</button>
)}
<button <button
onClick={onClose} onClick={onClose}
className="ml-2 rounded p-0.5 hover:bg-white/20" className="ml-2 rounded p-0.5 hover:bg-white/20"