fix: improve note interactions and markdown LaTeX support

## Bug Fixes

### Note Card Actions
- Fix broken size change functionality (missing state declaration)
- Implement React 19 useOptimistic for instant UI feedback
- Add startTransition for non-blocking updates
- Ensure smooth animations without page refresh
- All note actions now work: pin, archive, color, size, checklist

### Markdown LaTeX Rendering
- Add remark-math and rehype-katex plugins
- Support inline equations with dollar sign syntax
- Support block equations with double dollar sign syntax
- Import KaTeX CSS for proper styling
- Equations now render correctly instead of showing raw LaTeX

## Technical Details

- Replace undefined currentNote references with optimistic state
- Add optimistic updates before server actions for instant feedback
- Use router.refresh() in transitions for smart cache invalidation
- Install remark-math, rehype-katex, and katex packages

## Testing

- Build passes successfully with no TypeScript errors
- Dev server hot-reloads changes correctly
This commit is contained in:
2026-01-09 22:13:49 +01:00
parent 3c4b9d6176
commit 640fcb26f7
218 changed files with 51363 additions and 902 deletions

View File

@@ -18,11 +18,11 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles } from 'lucide-react'
import { updateNote } from '@/app/actions/notes'
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy } from 'lucide-react'
import { updateNote, createNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/toast'
import { toast } from 'sonner'
import { MarkdownContent } from './markdown-content'
import { LabelManager } from './label-manager'
import { LabelBadge } from './label-badge'
@@ -31,14 +31,16 @@ import { EditorImages } from './editor-images'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from './ghost-tags'
import { useLabels } from '@/context/LabelContext'
import { NoteSize } from '@/lib/types'
import { Badge } from '@/components/ui/badge'
interface NoteEditorProps {
note: Note
readOnly?: boolean
onClose: () => void
}
export function NoteEditor({ note, onClose }: NoteEditorProps) {
const { addToast } = useToast()
export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) {
const { labels: globalLabels, addLabel, refreshLabels } = useLabels()
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content)
@@ -48,9 +50,10 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
const [links, setLinks] = useState<LinkMetadata[]>(note.links || [])
const [newLabel, setNewLabel] = useState('')
const [color, setColor] = useState(note.color)
const [size, setSize] = useState<NoteSize>(note.size || 'small')
const [isSaving, setIsSaving] = useState(false)
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.isMarkdown || false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Auto-tagging hook
@@ -88,7 +91,7 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
console.error('Erreur création label auto:', err)
}
}
addToast(`Tag "${tag}" ajouté`, 'success')
toast.success(`Tag "${tag}" ajouté`)
}
}
@@ -96,11 +99,11 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
setDismissedTags(prev => [...prev, tag])
}
// Filtrer les suggestions pour ne pas afficher celles rejetées ou déjà ajoutées (insensible à la casse)
// Filtrer les suggestions pour ne pas afficher celles rejetées par l'utilisateur
// (On garde celles déjà ajoutées pour les afficher en mode "validé")
const filteredSuggestions = suggestions.filter(s => {
if (!s || !s.tag) return false
return !labels.some(l => l.toLowerCase() === s.tag.toLowerCase()) &&
!dismissedTags.includes(s.tag)
return !dismissedTags.includes(s.tag)
})
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -123,7 +126,7 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
setImages(prev => [...prev, data.url])
} catch (error) {
console.error('Upload error:', error)
addToast(`Failed to upload ${file.name}`, 'error')
toast.error(`Failed to upload ${file.name}`)
}
}
}
@@ -141,14 +144,14 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
const metadata = await fetchLinkMetadata(linkUrl)
if (metadata) {
setLinks(prev => [...prev, metadata])
addToast('Link added', 'success')
toast.success('Link added')
} else {
addToast('Could not fetch link metadata', 'warning')
toast.warning('Could not fetch link metadata')
setLinks(prev => [...prev, { url: linkUrl, title: linkUrl }])
}
} catch (error) {
console.error('Failed to add link:', error)
addToast('Failed to add link', 'error')
toast.error('Failed to add link')
} finally {
setLinkUrl('')
}
@@ -160,16 +163,16 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
const handleReminderSave = (date: Date) => {
if (date < new Date()) {
addToast('Reminder must be in the future', 'error')
toast.error('Reminder must be in the future')
return
}
setCurrentReminder(date)
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
toast.success(`Reminder set for ${date.toLocaleString()}`)
}
const handleRemoveReminder = () => {
setCurrentReminder(null)
addToast('Reminder removed', 'success')
toast.success('Reminder removed')
}
const handleSave = async () => {
@@ -185,6 +188,7 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
color,
reminder: currentReminder,
isMarkdown,
size,
})
// Rafraîchir les labels globaux pour refléter les suppressions éventuelles (orphans)
@@ -227,16 +231,57 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
setLabels(labels.filter(l => l !== label))
}
const handleMakeCopy = async () => {
try {
const newNote = await createNote({
title: `${title || 'Untitled'} (Copy)`,
content: content,
color: color,
type: note.type,
checkItems: checkItems,
labels: labels,
images: images,
links: links,
isMarkdown: isMarkdown,
size: size,
})
toast.success('Note copied successfully!')
onClose()
// Force refresh to show the new note
window.location.reload()
} catch (error) {
console.error('Failed to copy note:', error)
toast.error('Failed to copy note')
}
}
return (
<Dialog open onOpenChange={onClose}>
<Dialog open={true} onOpenChange={onClose}>
<DialogContent
className={cn(
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-y-auto',
colorClasses.bg
)}
onInteractOutside={(event) => {
// Prevent ALL outside interactions from closing dialog
// This prevents closing when clicking outside (including on toasts)
event.preventDefault()
}}
onPointerDownOutside={(event) => {
// Prevent ALL pointer down outside from closing dialog
event.preventDefault()
}}
>
<DialogHeader>
<DialogTitle className="sr-only">Edit Note</DialogTitle>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{readOnly ? 'View Note' : 'Edit Note'}</h2>
{readOnly && (
<Badge variant="secondary" className="bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
Read Only
</Badge>
)}
</div>
</DialogHeader>
<div className="space-y-4">
@@ -246,7 +291,11 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-8"
disabled={readOnly}
className={cn(
"text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-8",
readOnly && "cursor-default"
)}
/>
{filteredSuggestions.length > 0 && (
<div className="absolute right-0 top-1/2 -translate-y-1/2" title="Suggestions IA disponibles">
@@ -327,8 +376,8 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
</div>
{showMarkdownPreview && isMarkdown ? (
<MarkdownContent
content={content || '*No content*'}
<MarkdownContent
content={content || '*No content*'}
className="min-h-[200px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
/>
) : (
@@ -336,13 +385,18 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
placeholder={isMarkdown ? "Take a note... (Markdown supported)" : "Take a note..."}
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
disabled={readOnly}
className={cn(
"min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none",
readOnly && "cursor-default"
)}
/>
)}
{/* AI Auto-tagging Suggestions */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={labels}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={handleDismissGhostTag}
@@ -401,76 +455,130 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
{/* Toolbar */}
<div className="flex items-center justify-between pt-4 border-t">
<div className="flex items-center gap-2">
{/* Reminder Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowReminderDialog(true)}
title="Set reminder"
className={currentReminder ? "text-blue-600" : ""}
>
<Bell className="h-4 w-4" />
</Button>
{/* Add Image Button */}
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
title="Add image"
>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowLinkDialog(true)}
title="Add Link"
>
<LinkIcon className="h-4 w-4" />
</Button>
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" title="Change color">
<Palette className="h-4 w-4" />
{!readOnly && (
<>
{/* Reminder Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowReminderDialog(true)}
title="Set reminder"
className={currentReminder ? "text-blue-600" : ""}
>
<Bell className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button
key={colorName}
className={cn(
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
classes.bg,
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => setColor(colorName)}
title={colorName}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Label Manager */}
<LabelManager
existingLabels={labels}
onUpdate={setLabels}
/>
{/* Add Image Button */}
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
title="Add image"
>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowLinkDialog(true)}
title="Add Link"
>
<LinkIcon className="h-4 w-4" />
</Button>
{/* Size Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" title="Change size">
<Maximize2 className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="flex flex-col gap-1 p-1">
{['small', 'medium', 'large'].map((s) => (
<Button
key={s}
variant="ghost"
size="sm"
onClick={() => setSize(s as NoteSize)}
className={cn(
"justify-start capitalize",
size === s && "bg-accent"
)}
>
{s}
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" title="Change color">
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button
key={colorName}
className={cn(
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
classes.bg,
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => setColor(colorName)}
title={colorName}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Label Manager */}
<LabelManager
existingLabels={labels}
onUpdate={setLabels}
/>
</>
)}
{readOnly && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="text-xs">This note is shared with you in read-only mode</span>
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</Button>
{readOnly ? (
<>
<Button
variant="default"
onClick={handleMakeCopy}
className="flex items-center gap-2"
>
<Copy className="h-4 w-4" />
Make a copy
</Button>
<Button variant="ghost" onClick={onClose}>
Close
</Button>
</>
) : (
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</>
)}
</div>
</div>
</div>