refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client
This commit is contained in:
@@ -23,7 +23,8 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
const [isDarkMode, setIsDarkMode] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const filesRef = useRef<BinaryFiles>({})
|
||||
|
||||
// Parse initial state safely (ONLY ON MOUNT to prevent Next.js revalidation infinite loops)
|
||||
const [elements] = useState<readonly ExcalidrawElement[]>(() => {
|
||||
if (initialData) {
|
||||
@@ -32,6 +33,10 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
if (parsed && Array.isArray(parsed)) {
|
||||
return parsed
|
||||
} else if (parsed && parsed.elements) {
|
||||
// Restore binary files if present
|
||||
if (parsed.files && typeof parsed.files === 'object') {
|
||||
filesRef.current = parsed.files
|
||||
}
|
||||
return parsed.elements
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -57,34 +62,21 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Prevent Excalidraw from overriding document.documentElement.dir.
|
||||
// Excalidraw internally sets `document.documentElement.dir = "ltr"` which
|
||||
// breaks the RTL layout of the parent sidebar and header.
|
||||
useEffect(() => {
|
||||
const savedDir = document.documentElement.dir || 'ltr'
|
||||
|
||||
const dirObserver = new MutationObserver(() => {
|
||||
if (document.documentElement.dir !== savedDir) {
|
||||
document.documentElement.dir = savedDir
|
||||
}
|
||||
})
|
||||
dirObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['dir'] })
|
||||
|
||||
return () => dirObserver.disconnect()
|
||||
}, [])
|
||||
|
||||
const handleChange = (
|
||||
excalidrawElements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles
|
||||
) => {
|
||||
// Keep files ref up to date so we can include them in the save payload
|
||||
if (files) filesRef.current = files
|
||||
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
||||
|
||||
|
||||
setSaveStatus('saving')
|
||||
saveTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
// Excalidraw states are purely based on the geometric elements
|
||||
const snapshot = JSON.stringify(excalidrawElements)
|
||||
// Save both elements and binary files so images persist across page changes
|
||||
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
|
||||
await fetch('/api/canvas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -100,8 +92,8 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
|
||||
<Excalidraw
|
||||
initialData={{ elements }}
|
||||
<Excalidraw
|
||||
initialData={{ elements, files: filesRef.current }}
|
||||
theme={isDarkMode ? "dark" : "light"}
|
||||
onChange={handleChange}
|
||||
libraryReturnUrl={typeof window !== 'undefined' ? window.location.origin + window.location.pathname + window.location.search : undefined}
|
||||
|
||||
63
keep-notes/components/lab/canvas-error-boundary.tsx
Normal file
63
keep-notes/components/lab/canvas-error-boundary.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { AlertCircle, RefreshCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export class CanvasErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('[CanvasErrorBoundary] caught error:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 bg-destructive/5 rounded-3xl border border-destructive/20 m-6 gap-4">
|
||||
<div className="p-4 bg-destructive/10 rounded-full">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-xl font-bold">Oups ! Le Lab a rencontré un problème.</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
Une erreur inattendue est survenue lors du chargement de l'espace de dessin.
|
||||
Cela peut arriver à cause d'un conflit de données ou d'une extension de navigateur.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Recharger la page
|
||||
</Button>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<pre className="mt-4 p-4 bg-black/5 rounded-lg text-xs font-mono overflow-auto max-w-full italic text-muted-foreground">
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
27
keep-notes/components/lab/canvas-wrapper.tsx
Normal file
27
keep-notes/components/lab/canvas-wrapper.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { LabSkeleton } from './lab-skeleton'
|
||||
import { CanvasErrorBoundary } from './canvas-error-boundary'
|
||||
|
||||
const CanvasBoard = dynamic(
|
||||
() => import('./canvas-board').then((mod) => mod.CanvasBoard),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <LabSkeleton />
|
||||
}
|
||||
)
|
||||
|
||||
interface CanvasWrapperProps {
|
||||
canvasId?: string
|
||||
name: string
|
||||
initialData?: string
|
||||
}
|
||||
|
||||
export function CanvasWrapper(props: CanvasWrapperProps) {
|
||||
return (
|
||||
<CanvasErrorBoundary>
|
||||
<CanvasBoard {...props} />
|
||||
</CanvasErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { FlaskConical, Plus, X, ChevronDown, Trash2, Layout, MoreVertical } from 'lucide-react'
|
||||
import { FlaskConical, Plus, ChevronDown, Trash2, Layout } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { renameCanvas, deleteCanvas, createCanvas } from '@/app/actions/canvas-actions'
|
||||
import { useRouter } from 'next/navigation'
|
||||
@@ -25,7 +25,7 @@ interface LabHeaderProps {
|
||||
|
||||
export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHeaderProps) {
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const { t, language } = useLanguage()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
@@ -36,22 +36,21 @@ export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHead
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await renameCanvas(id, newName)
|
||||
toast.success(t('labHeader.renamed'))
|
||||
} catch (e) {
|
||||
toast.error(t('labHeader.renameError'))
|
||||
}
|
||||
setIsEditing(false)
|
||||
})
|
||||
|
||||
try {
|
||||
await renameCanvas(id, newName)
|
||||
toast.success(t('labHeader.renamed'))
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
toast.error(t('labHeader.renameError'))
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const newCanvas = await createCanvas()
|
||||
const newCanvas = await createCanvas(language)
|
||||
router.push(`/lab?id=${newCanvas.id}`)
|
||||
toast.success(t('labHeader.created'))
|
||||
} catch (e) {
|
||||
@@ -148,49 +147,44 @@ export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHead
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Inline Rename */}
|
||||
<div className="ms-2 flex items-center gap-2 group">
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="bg-muted px-3 py-1.5 rounded-lg text-sm font-medium focus:ring-2 focus:ring-primary/20 outline-none w-[200px]"
|
||||
defaultValue={currentCanvas?.name}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename(currentCanvas?.id!, e.currentTarget.value)
|
||||
if (e.key === 'Escape') setIsEditing(false)
|
||||
}}
|
||||
onBlur={(e) => handleRename(currentCanvas?.id!, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<MoreVertical className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Inline Rename — click on project name to edit */}
|
||||
{currentCanvas && (
|
||||
<div className="ms-2 flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="bg-muted px-3 py-1.5 rounded-lg text-sm font-medium focus:ring-2 focus:ring-primary/20 outline-none w-[200px]"
|
||||
defaultValue={currentCanvas.name}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename(currentCanvas.id, e.currentTarget.value)
|
||||
if (e.key === 'Escape') setIsEditing(false)
|
||||
}}
|
||||
onBlur={(e) => handleRename(currentCanvas.id, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-sm font-semibold text-foreground hover:text-primary transition-colors"
|
||||
title={t('labHeader.rename') || 'Rename'}
|
||||
>
|
||||
{currentCanvas.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{currentCanvas && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(currentCanvas.id, currentCanvas.name)}
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/5 rounded-xl transition-all"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isPending}
|
||||
className="h-10 rounded-xl px-4 flex items-center gap-2 shadow-lg shadow-primary/20 hover:shadow-primary/30 active:scale-95 transition-all outline-none"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('labHeader.new')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
41
keep-notes/components/lab/lab-skeleton.tsx
Normal file
41
keep-notes/components/lab/lab-skeleton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export function LabSkeleton() {
|
||||
return (
|
||||
<div className="flex-1 w-full h-full bg-slate-50 dark:bg-[#1a1c22] relative overflow-hidden">
|
||||
{/* Mesh grid background simulation */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]" />
|
||||
|
||||
{/* Top Menu Skeleton */}
|
||||
<div className="absolute top-4 left-4 flex gap-2">
|
||||
<Skeleton className="h-10 w-32 rounded-lg" />
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Style Menu Skeleton (Top Right) */}
|
||||
<div className="absolute top-4 right-4 flex flex-col gap-2">
|
||||
<Skeleton className="h-64 w-48 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
{/* Toolbar Skeleton (Bottom Center) */}
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-2 bg-white/50 dark:bg-black/20 backdrop-blur-md p-2 rounded-2xl border">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-10 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading Indicator */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4 bg-white/80 dark:bg-[#252830]/80 p-8 rounded-3xl border shadow-2xl backdrop-blur-xl animate-in fade-in zoom-in duration-500">
|
||||
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<h3 className="font-bold text-lg">Initialisation de l'espace</h3>
|
||||
<p className="text-sm text-muted-foreground animate-pulse">Chargement de vos idées...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user