feat: Memento avec dates, Markdown, reminders et auth
Tests Playwright validés ✅:
- Création de notes: OK
- Modification titre: OK
- Modification contenu: OK
- Markdown éditable avec preview: OK
Fonctionnalités:
- date-fns: dates relatives sur cards
- react-markdown + remark-gfm
- Markdown avec toggle edit/preview
- Recherche améliorée (titre/contenu/labels/checkItems)
- Reminder recurrence/location (schema)
- NextAuth.js + User/Account/Session
- userId dans Note (optionnel)
- 4 migrations créées
Ready for production + auth integration
This commit is contained in:
@@ -15,7 +15,9 @@ import {
|
||||
Archive,
|
||||
MoreVertical,
|
||||
Undo2,
|
||||
Redo2
|
||||
Redo2,
|
||||
FileText,
|
||||
Eye
|
||||
} from 'lucide-react'
|
||||
import { createNote } from '@/app/actions/notes'
|
||||
import { CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||
@@ -34,6 +36,13 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
|
||||
interface HistoryState {
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
interface NoteState {
|
||||
title: string
|
||||
@@ -56,6 +65,86 @@ export function NoteInput() {
|
||||
const [content, setContent] = useState('')
|
||||
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [isMarkdown, setIsMarkdown] = useState(false)
|
||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
|
||||
|
||||
// Undo/Redo history (title and content only)
|
||||
const [history, setHistory] = useState<HistoryState[]>([{ title: '', content: '' }])
|
||||
const [historyIndex, setHistoryIndex] = useState(0)
|
||||
const isUndoingRef = useRef(false)
|
||||
|
||||
// Reminder dialog
|
||||
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||
const [reminderDate, setReminderDate] = useState('')
|
||||
const [reminderTime, setReminderTime] = useState('')
|
||||
const [currentReminder, setCurrentReminder] = useState<Date | null>(null)
|
||||
|
||||
// Save to history after 1 second of inactivity
|
||||
useEffect(() => {
|
||||
if (isUndoingRef.current) {
|
||||
isUndoingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const currentState = { title, content }
|
||||
const lastState = history[historyIndex]
|
||||
|
||||
if (lastState.title !== title || lastState.content !== content) {
|
||||
const newHistory = history.slice(0, historyIndex + 1)
|
||||
newHistory.push(currentState)
|
||||
|
||||
if (newHistory.length > 50) {
|
||||
newHistory.shift()
|
||||
} else {
|
||||
setHistoryIndex(historyIndex + 1)
|
||||
}
|
||||
setHistory(newHistory)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [title, content, history, historyIndex])
|
||||
|
||||
// Undo/Redo functions
|
||||
const handleUndo = () => {
|
||||
if (historyIndex > 0) {
|
||||
isUndoingRef.current = true
|
||||
const newIndex = historyIndex - 1
|
||||
setHistoryIndex(newIndex)
|
||||
setTitle(history[newIndex].title)
|
||||
setContent(history[newIndex].content)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRedo = () => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
isUndoingRef.current = true
|
||||
const newIndex = historyIndex + 1
|
||||
setHistoryIndex(newIndex)
|
||||
setTitle(history[newIndex].title)
|
||||
setContent(history[newIndex].content)
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isExpanded) return
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleUndo()
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||
e.preventDefault()
|
||||
handleRedo()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isExpanded, historyIndex, history])
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
@@ -91,27 +180,37 @@ export function NoteInput() {
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleReminder = () => {
|
||||
const reminderDate = prompt('Enter reminder date and time (e.g., 2026-01-10 14:30):')
|
||||
if (!reminderDate) return
|
||||
|
||||
try {
|
||||
const date = new Date(reminderDate)
|
||||
if (isNaN(date.getTime())) {
|
||||
addToast('Invalid date format. Use: YYYY-MM-DD HH:MM', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (date < new Date()) {
|
||||
addToast('Reminder date must be in the future', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Store reminder in database
|
||||
addToast(`Reminder set for: ${date.toLocaleString()}`, 'success')
|
||||
} catch (error) {
|
||||
addToast('Failed to set reminder', 'error')
|
||||
const handleReminderOpen = () => {
|
||||
const tomorrow = new Date(Date.now() + 86400000)
|
||||
setReminderDate(tomorrow.toISOString().split('T')[0])
|
||||
setReminderTime('09:00')
|
||||
setShowReminderDialog(true)
|
||||
}
|
||||
|
||||
const handleReminderSave = () => {
|
||||
if (!reminderDate || !reminderTime) {
|
||||
addToast('Please enter date and time', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const dateTimeString = `${reminderDate}T${reminderTime}`
|
||||
const date = new Date(dateTimeString)
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
addToast('Invalid date or time', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (date < new Date()) {
|
||||
addToast('Reminder must be in the future', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentReminder(date)
|
||||
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
|
||||
setShowReminderDialog(false)
|
||||
setReminderDate('')
|
||||
setReminderTime('')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -139,6 +238,8 @@ export function NoteInput() {
|
||||
color,
|
||||
isArchived,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
reminder: currentReminder,
|
||||
isMarkdown,
|
||||
})
|
||||
|
||||
// Reset form
|
||||
@@ -146,10 +247,15 @@ export function NoteInput() {
|
||||
setContent('')
|
||||
setCheckItems([])
|
||||
setImages([])
|
||||
setIsMarkdown(false)
|
||||
setShowMarkdownPreview(false)
|
||||
setHistory([{ title: '', content: '' }])
|
||||
setHistoryIndex(0)
|
||||
setIsExpanded(false)
|
||||
setType('text')
|
||||
setColor('default')
|
||||
setIsArchived(false)
|
||||
setCurrentReminder(null)
|
||||
|
||||
addToast('Note created successfully', 'success')
|
||||
} catch (error) {
|
||||
@@ -182,9 +288,12 @@ export function NoteInput() {
|
||||
setContent('')
|
||||
setCheckItems([])
|
||||
setImages([])
|
||||
setHistory([{ title: '', content: '' }])
|
||||
setHistoryIndex(0)
|
||||
setType('text')
|
||||
setColor('default')
|
||||
setIsArchived(false)
|
||||
setCurrentReminder(null)
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
@@ -217,6 +326,7 @@ export function NoteInput() {
|
||||
const colorClasses = NOTE_COLORS[color] || NOTE_COLORS.default
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={cn(
|
||||
"p-4 max-w-2xl mx-auto mb-8 shadow-lg border",
|
||||
colorClasses.card
|
||||
@@ -253,13 +363,46 @@ export function NoteInput() {
|
||||
)}
|
||||
|
||||
{type === 'text' ? (
|
||||
<Textarea
|
||||
placeholder="Take a note..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="border-0 focus-visible:ring-0 min-h-[100px] resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{/* Markdown toggle button */}
|
||||
{isMarkdown && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{showMarkdownPreview ? (
|
||||
<>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Edit
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMarkdownPreview && isMarkdown ? (
|
||||
<MarkdownContent
|
||||
content={content || '*No content*'}
|
||||
className="min-h-[100px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder={isMarkdown ? "Take a note... (Markdown supported)" : "Take a note..."}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="border-0 focus-visible:ring-0 min-h-[100px] resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{checkItems.map((item) => (
|
||||
@@ -301,9 +444,12 @@ export function NoteInput() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className={cn(
|
||||
"h-8 w-8",
|
||||
currentReminder && "text-blue-600"
|
||||
)}
|
||||
title="Remind me"
|
||||
onClick={handleReminder}
|
||||
onClick={handleReminderOpen}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -311,6 +457,27 @@ export function NoteInput() {
|
||||
<TooltipContent>Remind me</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8",
|
||||
isMarkdown && "text-blue-600"
|
||||
)}
|
||||
onClick={() => {
|
||||
setIsMarkdown(!isMarkdown)
|
||||
if (isMarkdown) setShowMarkdownPreview(false)
|
||||
}}
|
||||
title="Markdown"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Markdown</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -390,31 +557,54 @@ export function NoteInput() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>More</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
size="sm"
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleUndo}
|
||||
disabled={historyIndex === 0}
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Undo (Ctrl+Z)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
size="sm"
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
size="sm"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleRedo}
|
||||
disabled={historyIndex >= history.length - 1}
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Redo (Ctrl+Y)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
size="sm"
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
size="sm"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,5 +617,48 @@ export function NoteInput() {
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Reminder</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-date" className="text-sm font-medium">
|
||||
Date
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-date"
|
||||
type="date"
|
||||
value={reminderDate}
|
||||
onChange={(e) => setReminderDate(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-time" className="text-sm font-medium">
|
||||
Time
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-time"
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
onChange={(e) => setReminderTime(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleReminderSave}>
|
||||
Set Reminder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user