chore: clean up repo for public release

- Remove BMAD framework, IDE configs, dev screenshots, test files,
  internal docs, and backup files
- Rename keep-notes/ to memento-note/
- Update all references from keep-notes to memento-note
- Add Apache 2.0 license with Commons Clause (non-commercial restriction)
- Add clean .gitignore and .env.docker.example
This commit is contained in:
Sepehr Ramezani
2026-04-20 22:48:06 +02:00
parent 402e88b788
commit e4d4e23dc7
3981 changed files with 407 additions and 530622 deletions

View File

@@ -0,0 +1,110 @@
'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)
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) {
try {
const parsed = JSON.parse(initialData)
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) {
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()
}, [])
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 {
// 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' },
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, files: filesRef.current }}
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>
)
}

View 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
}
}

View 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>
)
}

View File

@@ -0,0 +1,191 @@
'use client'
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'
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, language } = 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
}
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(language)
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 — 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"
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>
)}
</div>
</header>
)
}

View 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>
)
}