fix(rtl): remplacer les propriétés physiques par logiques pour le support RTL
La sidebar et le lab header utilisaient border-r, pr-4, ml-2, ml-auto au lieu des propriétés logiques CSS (border-e, pe-4, ms-2, ms-auto). En mode RTL (persan/arabe), ces propriétés physiques ne s'inversent pas, ce qui causait la sidebar à basculer du mauvais côté lors de la navigation vers Lab/Excalidraw. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,20 +3,24 @@ import { Sidebar } from "@/components/sidebar";
|
||||
import { ProvidersWrapper } from "@/components/providers-wrapper";
|
||||
import { auth } from "@/auth";
|
||||
import { detectUserLanguage } from "@/lib/i18n/detect-user-language";
|
||||
import { loadTranslations } from "@/lib/i18n/load-translations";
|
||||
|
||||
export default async function MainLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// Run auth + language detection in parallel
|
||||
// Run auth + language detection + translation loading in parallel
|
||||
const [session, initialLanguage] = await Promise.all([
|
||||
auth(),
|
||||
detectUserLanguage(),
|
||||
]);
|
||||
|
||||
// Load initial translations server-side to prevent hydration mismatch
|
||||
const initialTranslations = await loadTranslations(initialLanguage);
|
||||
|
||||
return (
|
||||
<ProvidersWrapper initialLanguage={initialLanguage}>
|
||||
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
||||
<div className="bg-background-light dark:bg-background-dark font-display text-slate-900 dark:text-white overflow-hidden h-screen flex flex-col">
|
||||
{/* Top Navigation - Style Keep */}
|
||||
<HeaderWrapper user={session?.user} />
|
||||
@@ -24,7 +28,7 @@ export default async function MainLayout({
|
||||
{/* Main Layout */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar Navigation - Style Keep */}
|
||||
<Sidebar className="w-64 flex-none flex-col bg-white dark:bg-[#1e2128] border-r border-slate-200 dark:border-slate-800 overflow-y-auto hidden md:flex" user={session?.user} />
|
||||
<Sidebar className="w-64 flex-none flex-col bg-white dark:bg-[#1e2128] border-e border-slate-200 dark:border-slate-800 overflow-y-auto hidden md:flex" user={session?.user} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-background-light dark:bg-background-dark p-4 scroll-smooth">
|
||||
|
||||
118
keep-notes/components/lab/canvas-board.tsx
Normal file
118
keep-notes/components/lab/canvas-board.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'
|
||||
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types/types'
|
||||
import '@excalidraw/excalidraw/index.css'
|
||||
|
||||
// Dynamic import with SSR disabled is REQUIRED for Excalidraw due to window dependencies
|
||||
const Excalidraw = dynamic(
|
||||
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
interface CanvasBoardProps {
|
||||
initialData?: string
|
||||
canvasId?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Parse initial state safely (ONLY ON MOUNT to prevent Next.js revalidation infinite loops)
|
||||
const [elements] = useState<readonly ExcalidrawElement[]>(() => {
|
||||
if (initialData) {
|
||||
try {
|
||||
const parsed = JSON.parse(initialData)
|
||||
if (parsed && Array.isArray(parsed)) {
|
||||
return parsed
|
||||
} else if (parsed && parsed.elements) {
|
||||
return parsed.elements
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[CanvasBoard] Failed to parse initial Excalidraw data:", e)
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Detect dark mode from html class
|
||||
useEffect(() => {
|
||||
const checkDarkMode = () => {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
setIsDarkMode(isDark)
|
||||
}
|
||||
|
||||
checkDarkMode()
|
||||
|
||||
// Observer for theme changes
|
||||
const observer = new MutationObserver(checkDarkMode)
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
|
||||
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
|
||||
) => {
|
||||
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)
|
||||
await fetch('/api/canvas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: canvasId || null, name, data: snapshot })
|
||||
})
|
||||
setSaveStatus('saved')
|
||||
} catch (e) {
|
||||
console.error("[CanvasBoard] Save failure:", e)
|
||||
setSaveStatus('error')
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
|
||||
<Excalidraw
|
||||
initialData={{ elements }}
|
||||
theme={isDarkMode ? "dark" : "light"}
|
||||
onChange={handleChange}
|
||||
libraryReturnUrl={typeof window !== 'undefined' ? window.location.origin + window.location.pathname + window.location.search : undefined}
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
loadScene: false,
|
||||
saveToActiveFile: false,
|
||||
clearCanvas: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
197
keep-notes/components/lab/lab-header.tsx
Normal file
197
keep-notes/components/lab/lab-header.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import { FlaskConical, Plus, X, ChevronDown, Trash2, Layout, MoreVertical } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { renameCanvas, deleteCanvas, createCanvas } from '@/app/actions/canvas-actions'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, useTransition } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface LabHeaderProps {
|
||||
canvases: any[]
|
||||
currentCanvasId: string | null
|
||||
onCreateCanvas: () => Promise<void>
|
||||
}
|
||||
|
||||
export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHeaderProps) {
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
const currentCanvas = canvases.find(c => c.id === currentCanvasId)
|
||||
|
||||
const handleRename = async (id: string, newName: string) => {
|
||||
if (!newName || newName === currentCanvas?.name) {
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await renameCanvas(id, newName)
|
||||
toast.success(t('labHeader.renamed'))
|
||||
} catch (e) {
|
||||
toast.error(t('labHeader.renameError'))
|
||||
}
|
||||
setIsEditing(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const newCanvas = await createCanvas()
|
||||
router.push(`/lab?id=${newCanvas.id}`)
|
||||
toast.success(t('labHeader.created'))
|
||||
} catch (e) {
|
||||
toast.error(t('labHeader.createFailed'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (window.confirm(`${t('labHeader.deleteSpace')} "${name}" ?`)) {
|
||||
try {
|
||||
await deleteCanvas(id)
|
||||
toast.success(t('labHeader.deleted'))
|
||||
router.push('/lab')
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
toast.error(t('labHeader.deleteError'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-16 border-b bg-white/80 dark:bg-[#1a1c22]/80 backdrop-blur-md flex items-center justify-between px-6 z-20 shrink-0 sticky top-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 pe-4 border-e">
|
||||
<div className="p-2 bg-primary/10 rounded-xl">
|
||||
<FlaskConical className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-bold tracking-tight">{t('labHeader.title')}</h1>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">{t('labHeader.live')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Switcher */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-10 flex items-center gap-2 hover:bg-muted/50 rounded-xl px-3 transition-all active:scale-95">
|
||||
<Layout className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold leading-none">{t('labHeader.currentProject')}</span>
|
||||
<span className="text-sm font-semibold truncate max-w-[150px]">{currentCanvas?.name || t('labHeader.choose')}</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground ms-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[280px] p-2 rounded-2xl shadow-xl border-muted/20">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground px-2 py-1.5 flex justify-between items-center">
|
||||
{t('labHeader.yourSpaces')}
|
||||
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded-full font-mono">{canvases.length}</span>
|
||||
</DropdownMenuLabel>
|
||||
<div className="space-y-1 mt-1">
|
||||
{canvases.map(c => (
|
||||
<div key={c.id} className="group/item flex items-center gap-1">
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"flex-1 flex flex-col items-start gap-0.5 rounded-xl cursor-pointer p-3 transition-all",
|
||||
c.id === currentCanvasId ? "bg-primary/5 text-primary border border-primary/20" : "hover:bg-muted"
|
||||
)}
|
||||
onClick={() => router.push(`/lab?id=${c.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<span className="font-semibold text-sm">{c.name}</span>
|
||||
{c.id === currentCanvasId && <span className="w-2 h-2 rounded-full bg-primary" />}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{t('labHeader.updated')} {new Date(c.updatedAt).toLocaleDateString()}</span>
|
||||
</DropdownMenuItem>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover/item:opacity-100 transition-opacity hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(c.id, c.name)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DropdownMenuSeparator className="my-2" />
|
||||
<DropdownMenuItem
|
||||
onClick={handleCreate}
|
||||
disabled={isPending}
|
||||
className="flex items-center gap-2 text-primary font-medium p-3 rounded-xl cursor-pointer hover:bg-primary/5"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('labHeader.newSpace')}
|
||||
</DropdownMenuItem>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{currentCanvas && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
||||
>
|
||||
<Plus className="h-5 w-5 shrink-0" />
|
||||
<span className="truncate">{t('sidebar.newNoteTabs')}</span>
|
||||
<Sparkles className="ml-auto h-4 w-4 shrink-0 text-primary" aria-hidden />
|
||||
<Sparkles className="ms-auto h-4 w-4 shrink-0 text-primary" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-[240px]">
|
||||
|
||||
Reference in New Issue
Block a user