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:
2026-01-04 16:04:24 +01:00
parent 2de2958b7a
commit f0b41572bc
25 changed files with 4220 additions and 142 deletions

View File

@@ -58,8 +58,10 @@ export async function searchNotes(query: string) {
where: {
isArchived: false,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ content: { contains: query, mode: 'insensitive' } }
{ title: { contains: query } },
{ content: { contains: query } },
{ labels: { contains: query } },
{ checkItems: { contains: query } }
]
},
orderBy: [
@@ -68,7 +70,38 @@ export async function searchNotes(query: string) {
]
})
return notes.map(parseNote)
// Enhanced ranking: prioritize title matches
const rankedNotes = notes.map(note => {
const parsedNote = parseNote(note)
let score = 0
// Title match gets highest score
if (parsedNote.title?.toLowerCase().includes(query.toLowerCase())) {
score += 10
}
// Content match
if (parsedNote.content.toLowerCase().includes(query.toLowerCase())) {
score += 5
}
// Label match
if (parsedNote.labels?.some(label => label.toLowerCase().includes(query.toLowerCase()))) {
score += 3
}
// CheckItems match
if (parsedNote.checkItems?.some(item => item.text.toLowerCase().includes(query.toLowerCase()))) {
score += 2
}
return { note: parsedNote, score }
})
// Sort by score descending, then by existing order (pinned/updated)
return rankedNotes
.sort((a, b) => b.score - a.score)
.map(item => item.note)
} catch (error) {
console.error('Error searching notes:', error)
return []
@@ -85,6 +118,8 @@ export async function createNote(data: {
labels?: string[]
images?: string[]
isArchived?: boolean
reminder?: Date | null
isMarkdown?: boolean
}) {
try {
const note = await prisma.note.create({
@@ -97,6 +132,8 @@ export async function createNote(data: {
labels: data.labels ? JSON.stringify(data.labels) : null,
images: data.images ? JSON.stringify(data.images) : null,
isArchived: data.isArchived || false,
reminder: data.reminder || null,
isMarkdown: data.isMarkdown || false,
}
})
@@ -119,6 +156,8 @@ export async function updateNote(id: string, data: {
checkItems?: CheckItem[] | null
labels?: string[] | null
images?: string[] | null
reminder?: Date | null
isMarkdown?: boolean
}) {
try {
// Stringify JSON fields if they exist

View File

@@ -0,0 +1,19 @@
'use client'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface MarkdownContentProps {
content: string
className?: string
}
export function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
return (
<div className={`prose prose-sm dark:prose-invert max-w-none ${className}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content}
</ReactMarkdown>
</div>
)
}

View File

@@ -20,10 +20,14 @@ import {
Pin,
Tag,
Trash2,
Bell,
} from 'lucide-react'
import { useState } from 'react'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { MarkdownContent } from './markdown-content'
interface NoteCardProps {
note: Note
@@ -105,6 +109,16 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
<Pin className="absolute top-3 right-3 h-4 w-4 text-gray-600 dark:text-gray-400" fill="currentColor" />
)}
{/* Reminder Icon */}
{note.reminder && new Date(note.reminder) > new Date() && (
<Bell
className={cn(
"absolute h-4 w-4 text-blue-600 dark:text-blue-400",
note.isPinned ? "top-3 right-9" : "top-3 right-3"
)}
/>
)}
{/* Title */}
{note.title && (
<h3 className="text-base font-medium mb-2 pr-6 text-gray-900 dark:text-gray-100">
@@ -173,9 +187,15 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
{/* Content */}
{note.type === 'text' ? (
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
{note.content}
</p>
note.isMarkdown ? (
<div className="text-sm line-clamp-10">
<MarkdownContent content={note.content} />
</div>
) : (
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
{note.content}
</p>
)
) : (
<div className="space-y-1">
{note.checkItems?.map((item) => (
@@ -217,6 +237,11 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
</div>
)}
{/* Creation Date */}
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: fr })}
</div>
{/* Action Bar - Shows on Hover */}
<div
className="absolute bottom-0 left-0 right-0 p-2 flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"

View File

@@ -18,9 +18,11 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { X, Plus, Palette, Tag, Image as ImageIcon } from 'lucide-react'
import { X, Plus, Palette, Tag, Image as ImageIcon, Bell, FileText, Eye } from 'lucide-react'
import { updateNote } from '@/app/actions/notes'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/toast'
import { MarkdownContent } from './markdown-content'
interface NoteEditorProps {
note: Note
@@ -28,6 +30,7 @@ interface NoteEditorProps {
}
export function NoteEditor({ note, onClose }: NoteEditorProps) {
const { addToast } = useToast()
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content)
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
@@ -36,7 +39,15 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
const [newLabel, setNewLabel] = useState('')
const [color, setColor] = useState(note.color)
const [isSaving, setIsSaving] = useState(false)
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Reminder state
const [showReminderDialog, setShowReminderDialog] = useState(false)
const [reminderDate, setReminderDate] = useState('')
const [reminderTime, setReminderTime] = useState('')
const [currentReminder, setCurrentReminder] = useState<Date | null>(note.reminder)
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
@@ -57,6 +68,49 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
setImages(images.filter((_, i) => i !== index))
}
const handleReminderOpen = () => {
if (currentReminder) {
const date = new Date(currentReminder)
setReminderDate(date.toISOString().split('T')[0])
setReminderTime(date.toTimeString().slice(0, 5))
} else {
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)
}
const handleRemoveReminder = () => {
setCurrentReminder(null)
setShowReminderDialog(false)
addToast('Reminder removed', 'success')
}
const handleSave = async () => {
setIsSaving(true)
try {
@@ -67,6 +121,8 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
labels,
images,
color,
reminder: currentReminder,
isMarkdown,
})
onClose()
} catch (error) {
@@ -158,12 +214,58 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
{/* Content or Checklist */}
{note.type === 'text' ? (
<Textarea
placeholder="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"
/>
<div className="space-y-2">
{/* Markdown controls */}
<div className="flex items-center justify-between gap-2 pb-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsMarkdown(!isMarkdown)
if (isMarkdown) setShowMarkdownPreview(false)
}}
className={cn("h-7 text-xs", isMarkdown && "text-blue-600")}
>
<FileText className="h-3 w-3 mr-1" />
{isMarkdown ? 'Markdown ON' : 'Markdown OFF'}
</Button>
{isMarkdown && (
<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-[200px] 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="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
/>
)}
</div>
) : (
<div className="space-y-2">
{checkItems.map((item) => (
@@ -221,6 +323,17 @@ 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={handleReminderOpen}
title="Set reminder"
className={currentReminder ? "text-blue-600" : ""}
>
<Bell className="h-4 w-4" />
</Button>
{/* Add Image Button */}
<Button
variant="ghost"
@@ -303,6 +416,58 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
onChange={handleImageUpload}
/>
</DialogContent>
{/* Reminder Dialog */}
<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>
<div className="flex justify-between">
<div>
{currentReminder && (
<Button variant="outline" onClick={handleRemoveReminder}>
Remove Reminder
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>
Cancel
</Button>
<Button onClick={handleReminderSave}>
Set Reminder
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</Dialog>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -15,6 +15,11 @@ export interface Note {
checkItems: CheckItem[] | null;
labels: string[] | null;
images: string[] | null;
reminder: Date | null;
reminderRecurrence: string | null;
reminderLocation: string | null;
isMarkdown: boolean;
order: number;
createdAt: Date;
updatedAt: Date;
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,13 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@libsql/client": "^0.15.15",
"@prisma/adapter-better-sqlite3": "^7.2.0",
"@prisma/adapter-libsql": "^7.2.0",
@@ -22,15 +26,20 @@
"better-sqlite3": "^12.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"next-auth": "^5.0.0-beta.30",
"prisma": "5.22.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",

View File

@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});

Binary file not shown.

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Note" ADD COLUMN "reminder" DATETIME;
-- CreateIndex
CREATE INDEX "Note_reminder_idx" ON "Note"("reminder");

View File

@@ -0,0 +1,29 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Note" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT,
"content" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT 'default',
"isPinned" BOOLEAN NOT NULL DEFAULT false,
"isArchived" BOOLEAN NOT NULL DEFAULT false,
"type" TEXT NOT NULL DEFAULT 'text',
"checkItems" TEXT,
"labels" TEXT,
"images" TEXT,
"reminder" DATETIME,
"isMarkdown" BOOLEAN NOT NULL DEFAULT false,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Note" ("checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isPinned", "labels", "order", "reminder", "title", "type", "updatedAt") SELECT "checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isPinned", "labels", "order", "reminder", "title", "type", "updatedAt" FROM "Note";
DROP TABLE "Note";
ALTER TABLE "new_Note" RENAME TO "Note";
CREATE INDEX "Note_isPinned_idx" ON "Note"("isPinned");
CREATE INDEX "Note_isArchived_idx" ON "Note"("isArchived");
CREATE INDEX "Note_order_idx" ON "Note"("order");
CREATE INDEX "Note_reminder_idx" ON "Note"("reminder");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Note" ADD COLUMN "reminderLocation" TEXT;
ALTER TABLE "Note" ADD COLUMN "reminderRecurrence" TEXT;

View File

@@ -0,0 +1,90 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT,
"email" TEXT NOT NULL,
"emailVerified" DATETIME,
"image" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Account" (
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
PRIMARY KEY ("provider", "providerAccountId"),
CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Session" (
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" DATETIME NOT NULL,
PRIMARY KEY ("identifier", "token")
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Note" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT,
"content" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT 'default',
"isPinned" BOOLEAN NOT NULL DEFAULT false,
"isArchived" BOOLEAN NOT NULL DEFAULT false,
"type" TEXT NOT NULL DEFAULT 'text',
"checkItems" TEXT,
"labels" TEXT,
"images" TEXT,
"reminder" DATETIME,
"reminderRecurrence" TEXT,
"reminderLocation" TEXT,
"isMarkdown" BOOLEAN NOT NULL DEFAULT false,
"userId" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Note" ("checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isMarkdown", "isPinned", "labels", "order", "reminder", "reminderLocation", "reminderRecurrence", "title", "type", "updatedAt") SELECT "checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isMarkdown", "isPinned", "labels", "order", "reminder", "reminderLocation", "reminderRecurrence", "title", "type", "updatedAt" FROM "Note";
DROP TABLE "Note";
ALTER TABLE "new_Note" RENAME TO "Note";
CREATE INDEX "Note_isPinned_idx" ON "Note"("isPinned");
CREATE INDEX "Note_isArchived_idx" ON "Note"("isArchived");
CREATE INDEX "Note_order_idx" ON "Note"("order");
CREATE INDEX "Note_reminder_idx" ON "Note"("reminder");
CREATE INDEX "Note_userId_idx" ON "Note"("userId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");

View File

@@ -10,22 +10,82 @@ datasource db {
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
notes Note[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([provider, providerAccountId])
}
model Session {
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VerificationToken {
identifier String
token String
expires DateTime
@@id([identifier, token])
}
model Note {
id String @id @default(cuid())
title String?
content String
color String @default("default")
isPinned Boolean @default(false)
isArchived Boolean @default(false)
type String @default("text") // "text" or "checklist"
checkItems String? // For checklist items stored as JSON string
labels String? // Array of label names stored as JSON string
images String? // Array of image URLs stored as JSON string
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
title String?
content String
color String @default("default")
isPinned Boolean @default(false)
isArchived Boolean @default(false)
type String @default("text") // "text" or "checklist"
checkItems String? // For checklist items stored as JSON string
labels String? // Array of label names stored as JSON string
images String? // Array of image URLs stored as JSON string
reminder DateTime? // Reminder date and time
reminderRecurrence String? // "none", "daily", "weekly", "monthly", "custom"
reminderLocation String? // Location for location-based reminders
isMarkdown Boolean @default(false) // Whether content uses Markdown
userId String? // Owner of the note (optional for now, will be required after auth)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isPinned])
@@index([isArchived])
@@index([order])
@@index([reminder])
@@index([userId])
}

View File

@@ -0,0 +1,181 @@
import { test, expect } from '@playwright/test';
test.describe('Note Grid - Drag and Drop', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Create multiple notes for testing drag and drop
for (let i = 1; i <= 4; i++) {
await page.click('input[placeholder="Take a note..."]');
await page.fill('input[placeholder="Title"]', `Note ${i}`);
await page.fill('textarea[placeholder="Take a note..."]', `Content ${i}`);
await page.click('button:has-text("Add")');
await page.waitForTimeout(500);
}
});
test('should have draggable notes', async ({ page }) => {
// Wait for notes to appear
await page.waitForSelector('text=Note 1');
// Check that notes have draggable attribute
const noteCards = page.locator('[draggable="true"]');
const count = await noteCards.count();
expect(count).toBeGreaterThanOrEqual(4);
});
test('should show cursor-move on note cards', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Check CSS class for cursor-move
const firstNote = page.locator('[draggable="true"]').first();
const className = await firstNote.getAttribute('class');
expect(className).toContain('cursor-move');
});
test('should change opacity when dragging', async ({ page }) => {
await page.waitForSelector('text=Note 1');
const firstNote = page.locator('[draggable="true"]').first();
// Start drag
const box = await firstNote.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
// Check if opacity changed (isDragging class)
await page.waitForTimeout(100);
const className = await firstNote.getAttribute('class');
// The dragged note should have opacity-30 class
// Note: This is tricky with Playwright, might need visual regression testing
await page.mouse.up();
}
});
test('should reorder notes when dropped on another note', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Get initial order
const notes = page.locator('[draggable="true"]');
const firstNoteText = await notes.first().textContent();
const secondNoteText = await notes.nth(1).textContent();
expect(firstNoteText).toContain('Note');
expect(secondNoteText).toContain('Note');
// Drag first note to second position
const firstNote = notes.first();
const secondNote = notes.nth(1);
const firstBox = await firstNote.boundingBox();
const secondBox = await secondNote.boundingBox();
if (firstBox && secondBox) {
await page.mouse.move(firstBox.x + firstBox.width / 2, firstBox.y + firstBox.height / 2);
await page.mouse.down();
await page.mouse.move(secondBox.x + secondBox.width / 2, secondBox.y + secondBox.height / 2);
await page.mouse.up();
// Wait for reorder to complete
await page.waitForTimeout(1000);
// Check that order changed
// Note: This depends on the order persisting in the database
await page.reload();
await page.waitForSelector('text=Note');
// Verify the order changed (implementation dependent)
}
});
test('should work with pinned and unpinned notes separately', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Pin first note
const firstNote = page.locator('text=Note 1').first();
await firstNote.hover();
await page.click('button[title*="Pin"]:visible').first();
await page.waitForTimeout(500);
// Check that "Pinned" section appears
await expect(page.locator('text=Pinned')).toBeVisible();
// Verify note is in pinned section
const pinnedSection = page.locator('h2:has-text("Pinned")').locator('..').locator('..');
await expect(pinnedSection.locator('text=Note 1')).toBeVisible();
});
test('should not mix pinned and unpinned notes when dragging', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Pin first note
const firstNote = page.locator('text=Note 1').first();
await firstNote.hover();
await page.click('button[title*="Pin"]:visible').first();
await page.waitForTimeout(500);
// Should have both Pinned and Others sections
await expect(page.locator('text=Pinned')).toBeVisible();
await expect(page.locator('text=Others')).toBeVisible();
// Count notes in each section
const pinnedNotes = page.locator('h2:has-text("Pinned") ~ div [draggable="true"]');
const unpinnedNotes = page.locator('h2:has-text("Others") ~ div [draggable="true"]');
expect(await pinnedNotes.count()).toBeGreaterThanOrEqual(1);
expect(await unpinnedNotes.count()).toBeGreaterThanOrEqual(3);
});
test('should persist note order after page reload', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Get initial order
const notes = page.locator('[draggable="true"]');
const initialOrder: string[] = [];
const count = await notes.count();
for (let i = 0; i < Math.min(count, 4); i++) {
const text = await notes.nth(i).textContent();
if (text) initialOrder.push(text);
}
// Reload page
await page.reload();
await page.waitForSelector('text=Note');
// Get order after reload
const notesAfterReload = page.locator('[draggable="true"]');
const reloadedOrder: string[] = [];
const countAfterReload = await notesAfterReload.count();
for (let i = 0; i < Math.min(countAfterReload, 4); i++) {
const text = await notesAfterReload.nth(i).textContent();
if (text) reloadedOrder.push(text);
}
// Order should be the same
expect(reloadedOrder.length).toBe(initialOrder.length);
});
test.afterEach(async ({ page }) => {
// Clean up created notes
const notes = page.locator('[draggable="true"]');
const count = await notes.count();
for (let i = 0; i < count; i++) {
const note = notes.first();
await note.hover();
await page.click('button:has(svg.lucide-more-vertical)').first();
await page.click('text=Delete').first();
// Confirm delete
page.once('dialog', dialog => dialog.accept());
await page.waitForTimeout(300);
}
});
});

View File

@@ -0,0 +1,423 @@
import { test, expect } from '@playwright/test';
test.describe('Note Input - Reminder Dialog', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Expand the note input
await page.click('input[placeholder="Take a note..."]');
await expect(page.locator('input[placeholder="Title"]')).toBeVisible();
});
test('should open dialog when clicking Bell icon (not prompt)', async ({ page }) => {
// Set up listener for prompt dialogs - should NOT appear
let promptAppeared = false;
page.on('dialog', () => {
promptAppeared = true;
});
// Click the Bell button
const bellButton = page.locator('button:has(svg.lucide-bell)');
await bellButton.click();
// Verify dialog opened (NOT a browser prompt)
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible();
// Verify dialog title
await expect(page.locator('h2:has-text("Set Reminder")')).toBeVisible();
// Verify no prompt appeared
expect(promptAppeared).toBe(false);
// Verify date and time inputs exist
await expect(page.locator('input[type="date"]')).toBeVisible();
await expect(page.locator('input[type="time"]')).toBeVisible();
// Verify buttons
await expect(page.locator('button:has-text("Cancel")')).toBeVisible();
await expect(page.locator('button:has-text("Set Reminder")')).toBeVisible();
});
test('should have default values (tomorrow 9am)', async ({ page }) => {
// Click Bell
await page.click('button:has(svg.lucide-bell)');
// Get tomorrow's date
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const expectedDate = tomorrow.toISOString().split('T')[0];
// Check date input
const dateInput = page.locator('input[type="date"]');
await expect(dateInput).toHaveValue(expectedDate);
// Check time input
const timeInput = page.locator('input[type="time"]');
await expect(timeInput).toHaveValue('09:00');
});
test('should close dialog on Cancel', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
await expect(page.locator('[role="dialog"]')).toBeVisible();
// Click Cancel
await page.click('button:has-text("Cancel")');
// Dialog should close
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
test('should close dialog on X button', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
await expect(page.locator('[role="dialog"]')).toBeVisible();
// Click X button (close button in dialog)
const closeButton = page.locator('[role="dialog"] button[data-slot="dialog-close"]');
await closeButton.click();
// Dialog should close
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
test('should validate empty date/time', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Clear date
const dateInput = page.locator('input[type="date"]');
await dateInput.fill('');
// Click Set Reminder
await page.click('button:has-text("Set Reminder")');
// Should show warning toast (not close dialog)
// We look for the toast notification
await expect(page.locator('text=Please enter date and time')).toBeVisible();
// Dialog should still be open
await expect(page.locator('[role="dialog"]')).toBeVisible();
});
test('should validate past date', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Set date to yesterday
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const pastDate = yesterday.toISOString().split('T')[0];
const dateInput = page.locator('input[type="date"]');
await dateInput.fill(pastDate);
// Click Set Reminder
await page.click('button:has-text("Set Reminder")');
// Should show error toast
await expect(page.locator('text=Reminder must be in the future')).toBeVisible();
// Dialog should still be open
await expect(page.locator('[role="dialog"]')).toBeVisible();
});
test('should set reminder successfully with valid date', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Set future date (tomorrow already default)
const timeInput = page.locator('input[type="time"]');
await timeInput.fill('14:30');
// Click Set Reminder
await page.click('button:has-text("Set Reminder")');
// Should show success toast
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
// Dialog should close
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
test('should clear fields after successful reminder', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Set and confirm
await page.click('button:has-text("Set Reminder")');
// Wait for dialog to close
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
// Open again
await page.click('button:has(svg.lucide-bell)');
// Should have default values again (tomorrow 9am)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const expectedDate = tomorrow.toISOString().split('T')[0];
await expect(page.locator('input[type="date"]')).toHaveValue(expectedDate);
await expect(page.locator('input[type="time"]')).toHaveValue('09:00');
});
test('should allow custom date and time selection', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Set custom date (next week)
const nextWeek = new Date();
nextWeek.setDate(nextWeek.getDate() + 7);
const customDate = nextWeek.toISOString().split('T')[0];
const dateInput = page.locator('input[type="date"]');
await dateInput.fill(customDate);
// Set custom time
const timeInput = page.locator('input[type="time"]');
await timeInput.fill('15:45');
// Submit
await page.click('button:has-text("Set Reminder")');
// Should show success with the date/time
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
});
test.describe('Note Editor - Reminder Dialog', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Create a test note
await page.click('input[placeholder="Take a note..."]');
await page.fill('input[placeholder="Title"]', 'Test Note for Reminder');
await page.fill('textarea[placeholder="Take a note..."]', 'This note will have a reminder');
await page.click('button:has-text("Add")');
await page.waitForTimeout(500);
// Open the note for editing
await page.click('text=Test Note for Reminder');
await page.waitForTimeout(300);
});
test('should open reminder dialog in note editor', async ({ page }) => {
// Click the Bell button in note editor
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
await bellButton.click();
// Should open a second dialog for reminder
await page.waitForTimeout(300);
// Verify reminder dialog opened
await expect(page.locator('h2:has-text("Set Reminder")')).toBeVisible();
// Verify date and time inputs exist
await expect(page.locator('input[type="date"]')).toBeVisible();
await expect(page.locator('input[type="time"]')).toBeVisible();
});
test('should set reminder on existing note', async ({ page }) => {
// Click Bell button
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
// Set date and time
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '14:30');
// Click Set Reminder
await page.click('button:has-text("Set Reminder")');
// Should show success toast
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
// Reminder dialog should close
await page.waitForTimeout(300);
await expect(page.locator('h2:has-text("Set Reminder")')).not.toBeVisible();
});
test('should show bell button as active when reminder is set', async ({ page }) => {
// Set reminder
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '10:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Bell button should have active styling (text-blue-600)
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
const className = await bellButton.getAttribute('class');
expect(className).toContain('text-blue-600');
});
test('should allow editing existing reminder', async ({ page }) => {
// Set initial reminder
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '10:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Open reminder dialog again
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
// Should show previous values
const dateInput = page.locator('input[type="date"]');
const timeInput = page.locator('input[type="time"]');
await expect(dateInput).toHaveValue(dateString);
await expect(timeInput).toHaveValue('10:00');
// Change time
await timeInput.fill('15:00');
await page.click('button:has-text("Set Reminder")');
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
});
test('should allow removing reminder', async ({ page }) => {
// Set reminder first
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '10:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Open reminder dialog again
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
// Should see "Remove Reminder" button
await expect(page.locator('button:has-text("Remove Reminder")')).toBeVisible();
// Click Remove Reminder
await page.click('button:has-text("Remove Reminder")');
// Should show success toast
await expect(page.locator('text=Reminder removed')).toBeVisible();
// Bell button should not be active anymore
await page.waitForTimeout(300);
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
const className = await bellButton.getAttribute('class');
expect(className).not.toContain('text-blue-600');
});
test('should persist reminder after saving note', async ({ page }) => {
// Set reminder
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '14:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Save the note
await page.click('button:has-text("Save")');
await page.waitForTimeout(500);
// Reopen the note
await page.click('text=Test Note for Reminder');
await page.waitForTimeout(300);
// Bell button should still be active
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
const className = await bellButton.getAttribute('class');
expect(className).toContain('text-blue-600');
// Open reminder dialog to verify values
await bellButton.click();
await page.waitForTimeout(200);
const dateInput = page.locator('input[type="date"]');
const timeInput = page.locator('input[type="time"]');
await expect(dateInput).toHaveValue(dateString);
await expect(timeInput).toHaveValue('14:00');
});
test('should show bell icon on note card when reminder is set', async ({ page }) => {
// Set reminder
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '10:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Save and close
await page.click('button:has-text("Save")');
await page.waitForTimeout(500);
// Check that note card has bell icon
const noteCard = page.locator('text=Test Note for Reminder').locator('..');
await expect(noteCard.locator('svg.lucide-bell')).toBeVisible();
});
test.afterEach(async ({ page }) => {
// Close any open dialogs
const dialogs = page.locator('[role="dialog"]');
const count = await dialogs.count();
for (let i = 0; i < count; i++) {
const cancelButton = page.locator('button:has-text("Cancel")').first();
if (await cancelButton.isVisible()) {
await cancelButton.click();
await page.waitForTimeout(200);
}
}
// Delete test note
try {
const testNote = page.locator('text=Test Note for Reminder').first();
if (await testNote.isVisible()) {
await testNote.hover();
await page.click('button:has(svg.lucide-more-vertical)').first();
await page.click('text=Delete').first();
// Confirm delete
page.once('dialog', dialog => dialog.accept());
await page.waitForTimeout(300);
}
} catch (e) {
// Note might already be deleted
}
});
});

View File

@@ -0,0 +1,167 @@
import { test, expect } from '@playwright/test';
test.describe('Note Input - Undo/Redo', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Expand the note input
await page.click('input[placeholder="Take a note..."]');
await expect(page.locator('input[placeholder="Title"]')).toBeVisible();
});
test('should save history after 1 second of inactivity', async ({ page }) => {
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
// Type "Hello"
await contentArea.fill('Hello');
// Wait for debounce to save
await page.waitForTimeout(1100);
// Type " World"
await contentArea.fill('Hello World');
// Wait for debounce
await page.waitForTimeout(1100);
// Click Undo button
const undoButton = page.locator('button:has(svg.lucide-undo-2)');
await expect(undoButton).toBeEnabled();
await undoButton.click();
// Should show "Hello" only
await expect(contentArea).toHaveValue('Hello');
// Undo should now be disabled (back to initial state)
// Actually not disabled, there's the initial empty state
await undoButton.click();
await expect(contentArea).toHaveValue('');
// Undo should be disabled now
await expect(undoButton).toBeDisabled();
// Click Redo
const redoButton = page.locator('button:has(svg.lucide-redo-2)');
await expect(redoButton).toBeEnabled();
await redoButton.click();
// Should show "Hello"
await expect(contentArea).toHaveValue('Hello');
// Redo again
await redoButton.click();
await expect(contentArea).toHaveValue('Hello World');
// Redo should be disabled now
await expect(redoButton).toBeDisabled();
});
test('should undo/redo with keyboard shortcuts', async ({ page }) => {
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
// Type and wait
await contentArea.fill('First');
await page.waitForTimeout(1100);
await contentArea.fill('Second');
await page.waitForTimeout(1100);
// Ctrl+Z to undo
await page.keyboard.press('Control+z');
await expect(contentArea).toHaveValue('First');
// Ctrl+Z again
await page.keyboard.press('Control+z');
await expect(contentArea).toHaveValue('');
// Ctrl+Y to redo
await page.keyboard.press('Control+y');
await expect(contentArea).toHaveValue('First');
// Ctrl+Shift+Z also works for redo
await page.keyboard.press('Control+Shift+z');
await expect(contentArea).toHaveValue('Second');
});
test('should work with title and content', async ({ page }) => {
const titleInput = page.locator('input[placeholder="Title"]');
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
// Type title
await titleInput.fill('My Title');
await page.waitForTimeout(1100);
// Type content
await contentArea.fill('My Content');
await page.waitForTimeout(1100);
// Undo
await page.keyboard.press('Control+z');
await expect(titleInput).toHaveValue('My Title');
await expect(contentArea).toHaveValue('');
// Undo again
await page.keyboard.press('Control+z');
await expect(titleInput).toHaveValue('');
await expect(contentArea).toHaveValue('');
});
test('should reset history after creating note', async ({ page }) => {
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
const undoButton = page.locator('button:has(svg.lucide-undo-2)');
// Type something
await contentArea.fill('Test note');
await page.waitForTimeout(1100);
// Undo should be enabled
await expect(undoButton).toBeEnabled();
// Submit note
await page.click('button:has-text("Add")');
// Wait for note to be created and form to reset
await page.waitForTimeout(500);
// Expand again
await page.click('input[placeholder="Take a note..."]');
// Undo should be disabled (fresh start)
await expect(undoButton).toBeDisabled();
});
test('should not create history during undo/redo', async ({ page }) => {
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
// Type "A"
await contentArea.fill('A');
await page.waitForTimeout(1100);
// Type "B"
await contentArea.fill('B');
await page.waitForTimeout(1100);
// Type "C"
await contentArea.fill('C');
await page.waitForTimeout(1100);
// Undo to B
await page.keyboard.press('Control+z');
await expect(contentArea).toHaveValue('B');
// Undo to A
await page.keyboard.press('Control+z');
await expect(contentArea).toHaveValue('A');
// Redo to B
await page.keyboard.press('Control+y');
await expect(contentArea).toHaveValue('B');
// Redo to C
await page.keyboard.press('Control+y');
await expect(contentArea).toHaveValue('C');
// Should not be able to redo further
const redoButton = page.locator('button:has(svg.lucide-redo-2)');
await expect(redoButton).toBeDisabled();
});
});