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:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user