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

@@ -36,14 +36,16 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/toast'
import { toast } from 'sonner'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { MarkdownContent } from './markdown-content'
import { LabelSelector } from './label-selector'
import { LabelBadge } from './label-badge'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from './ghost-tags'
import { CollaboratorDialog } from './collaborator-dialog'
import { useLabels } from '@/context/LabelContext'
import { useSession } from 'next-auth/react'
interface HistoryState {
title: string
@@ -58,23 +60,36 @@ interface NoteState {
}
export function NoteInput() {
const { addToast } = useToast()
const { labels: globalLabels, addLabel } = useLabels()
const { data: session } = useSession()
const [isExpanded, setIsExpanded] = useState(false)
const [type, setType] = useState<'text' | 'checklist'>('text')
const [isSubmitting, setIsSubmitting] = useState(false)
const [color, setColor] = useState<NoteColor>('default')
const [isArchived, setIsArchived] = useState(false)
const [selectedLabels, setSelectedLabels] = useState<string[]>([])
const [collaborators, setCollaborators] = useState<string[]>([])
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Simple state without complex undo/redo - like Google Keep
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
const [images, setImages] = useState<string[]>([])
const [links, setLinks] = useState<LinkMetadata[]>([])
const [isMarkdown, setIsMarkdown] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
// Combine text content and link metadata for AI analysis
const fullContentForAI = [
content,
...links.map(l => `${l.title || ''} ${l.description || ''}`)
].join(' ').trim();
// Auto-tagging hook
const { suggestions, isAnalyzing } = useAutoTagging({
content: type === 'text' ? content : '',
content: type === 'text' ? fullContentForAI : '',
enabled: type === 'text' && isExpanded
})
@@ -96,7 +111,7 @@ export function NoteInput() {
}
}
addToast(`Tag "${tag}" ajouté`, 'success')
toast.success(`Tag "${tag}" ajouté`)
}
}
@@ -109,13 +124,6 @@ export function NoteInput() {
return !selectedLabels.some(l => l.toLowerCase() === s.tag.toLowerCase()) &&
!dismissedTags.includes(s.tag)
})
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
const [images, setImages] = useState<string[]>([])
const [links, setLinks] = useState<LinkMetadata[]>([])
const [isMarkdown, setIsMarkdown] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
// Undo/Redo history (title and content only)
const [history, setHistory] = useState<HistoryState[]>([{ title: '', content: '' }])
@@ -208,12 +216,12 @@ export function NoteInput() {
for (const file of Array.from(files)) {
// Validation
if (!validTypes.includes(file.type)) {
addToast(`Invalid file type: ${file.name}. Only JPEG, PNG, GIF, and WebP allowed.`, 'error')
toast.error(`Invalid file type: ${file.name}. Only JPEG, PNG, GIF, and WebP allowed.`)
continue
}
if (file.size > maxSize) {
addToast(`File too large: ${file.name}. Maximum size is 5MB.`, 'error')
toast.error(`File too large: ${file.name}. Maximum size is 5MB.`)
continue
}
@@ -233,7 +241,7 @@ export function NoteInput() {
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}`)
}
}
@@ -251,15 +259,15 @@ export function NoteInput() {
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')
// Fallback: just add the url as title
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('')
}
@@ -278,7 +286,7 @@ export function NoteInput() {
const handleReminderSave = () => {
if (!reminderDate || !reminderTime) {
addToast('Please enter date and time', 'warning')
toast.warning('Please enter date and time')
return
}
@@ -286,34 +294,34 @@ export function NoteInput() {
const date = new Date(dateTimeString)
if (isNaN(date.getTime())) {
addToast('Invalid date or time', 'error')
toast.error('Invalid date or time')
return
}
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()}`)
setShowReminderDialog(false)
setReminderDate('')
setReminderTime('')
}
const handleSubmit = async () => {
// Validation
if (type === 'text' && !content.trim()) {
addToast('Please enter some content', 'warning')
// Validation: Allow submit if content OR images OR links exist
const hasContent = content.trim().length > 0;
const hasMedia = images.length > 0 || links.length > 0;
const hasCheckItems = checkItems.some(i => i.text.trim().length > 0);
if (type === 'text' && !hasContent && !hasMedia) {
toast.warning('Please enter some content or add a link/image')
return
}
if (type === 'checklist' && checkItems.length === 0) {
addToast('Please add at least one item', 'warning')
return
}
if (type === 'checklist' && checkItems.every(item => !item.text.trim())) {
addToast('Checklist items cannot be empty', 'warning')
if (type === 'checklist' && !hasCheckItems && !hasMedia) {
toast.warning('Please add at least one item or media')
return
}
@@ -331,6 +339,7 @@ export function NoteInput() {
reminder: currentReminder,
isMarkdown,
labels: selectedLabels.length > 0 ? selectedLabels : undefined,
sharedWith: collaborators.length > 0 ? collaborators : undefined,
})
// Reset form
@@ -349,11 +358,12 @@ export function NoteInput() {
setIsArchived(false)
setCurrentReminder(null)
setSelectedLabels([])
setCollaborators([])
addToast('Note created successfully', 'success')
toast.success('Note created successfully')
} catch (error) {
console.error('Failed to create note:', error)
addToast('Failed to create note', 'error')
toast.error('Failed to create note')
} finally {
setIsSubmitting(false)
}
@@ -391,6 +401,7 @@ export function NoteInput() {
setIsArchived(false)
setCurrentReminder(null)
setSelectedLabels([])
setCollaborators([])
}
if (!isExpanded) {
@@ -462,11 +473,32 @@ export function NoteInput() {
{/* Link Previews */}
{links.length > 0 && (
<div className="flex flex-col gap-2 mt-2">
{/* ... */}
{links.map((link, idx) => (
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-white/50 dark:bg-black/20 flex">
{link.imageUrl && (
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-blue-500 truncate hover:underline block mt-1">
{new URL(link.url).hostname}
</a>
</div>
<Button
variant="ghost"
size="sm"
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* Selected Labels Display (Moved here to be visible for both text and checklist) */}
{/* Selected Labels Display */}
{selectedLabels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{selectedLabels.map(label => (
@@ -523,6 +555,7 @@ export function NoteInput() {
{/* AI Auto-tagging Suggestions */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={selectedLabels}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={handleDismissGhostTag}
@@ -620,11 +653,17 @@ export function NoteInput() {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Collaborator">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Add collaborators"
onClick={() => setShowCollaboratorDialog(true)}
>
<UserPlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Collaborator</TooltipContent>
<TooltipContent>Add collaborators</TooltipContent>
</Tooltip>
<Tooltip>
@@ -766,7 +805,31 @@ export function NoteInput() {
</Card>
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
<DialogContent>
<DialogContent
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Set Reminder</DialogTitle>
</DialogHeader>
@@ -808,7 +871,31 @@ export function NoteInput() {
</Dialog>
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
<DialogContent>
<DialogContent
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Add Link</DialogTitle>
</DialogHeader>
@@ -836,6 +923,16 @@ export function NoteInput() {
</DialogFooter>
</DialogContent>
</Dialog>
<CollaboratorDialog
open={showCollaboratorDialog}
onOpenChange={setShowCollaboratorDialog}
noteId=""
noteOwnerId={session?.user?.id || ""}
currentUserId={session?.user?.id || ""}
onCollaboratorsChange={setCollaborators}
initialCollaborators={collaborators}
/>
</>
)
}
}