Fix tests and add changelog
This commit is contained in:
25
keep-notes/components/header-wrapper.tsx
Normal file
25
keep-notes/components/header-wrapper.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { Header } from './header'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
|
||||
export function HeaderWrapper() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
|
||||
const handleLabelFilterChange = (labels: string[]) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
|
||||
if (labels.length > 0) {
|
||||
params.set('labels', labels.join(','))
|
||||
} else {
|
||||
params.delete('labels')
|
||||
}
|
||||
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
return <Header selectedLabels={selectedLabels} onLabelFilterChange={handleLabelFilterChange} />
|
||||
}
|
||||
@@ -15,8 +15,14 @@ import { usePathname } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { searchNotes } from '@/app/actions/notes'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { LabelFilter } from './label-filter'
|
||||
|
||||
export function Header() {
|
||||
interface HeaderProps {
|
||||
selectedLabels?: string[]
|
||||
onLabelFilterChange?: (labels: string[]) => void
|
||||
}
|
||||
|
||||
export function Header({ selectedLabels = [], onLabelFilterChange }: HeaderProps = {}) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('light')
|
||||
@@ -86,8 +92,8 @@ export function Header() {
|
||||
</Link>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="flex-1 max-w-2xl">
|
||||
<div className="relative">
|
||||
<div className="flex-1 max-w-2xl flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search notes..."
|
||||
@@ -96,6 +102,12 @@ export function Header() {
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{onLabelFilterChange && (
|
||||
<LabelFilter
|
||||
selectedLabels={selectedLabels}
|
||||
onFilterChange={onLabelFilterChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
|
||||
130
keep-notes/components/label-filter.tsx
Normal file
130
keep-notes/components/label-filter.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Filter, X } from 'lucide-react'
|
||||
import { getAllLabelColors, getLabelColor } from '@/lib/label-storage'
|
||||
import { LABEL_COLORS } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LabelFilterProps {
|
||||
selectedLabels: string[]
|
||||
onFilterChange: (labels: string[]) => void
|
||||
}
|
||||
|
||||
export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps) {
|
||||
const [allLabels, setAllLabels] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
// Load all labels from localStorage
|
||||
const labelColors = getAllLabelColors()
|
||||
setAllLabels(Object.keys(labelColors).sort())
|
||||
}, [])
|
||||
|
||||
const handleToggleLabel = (label: string) => {
|
||||
if (selectedLabels.includes(label)) {
|
||||
onFilterChange(selectedLabels.filter(l => l !== label))
|
||||
} else {
|
||||
onFilterChange([...selectedLabels, label])
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
onFilterChange([])
|
||||
}
|
||||
|
||||
if (allLabels.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-9">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filter by Label
|
||||
{selectedLabels.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 h-5 min-w-5 px-1.5">
|
||||
{selectedLabels.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
Filter by Labels
|
||||
{selectedLabels.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{allLabels.map((label) => {
|
||||
const colorName = getLabelColor(label)
|
||||
const colorClasses = LABEL_COLORS[colorName]
|
||||
const isSelected = selectedLabels.includes(label)
|
||||
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={label}
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleLabel(label)}
|
||||
>
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-xs border mr-2',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Active filters display */}
|
||||
{selectedLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedLabels.map((label) => {
|
||||
const colorName = getLabelColor(label)
|
||||
const colorClasses = LABEL_COLORS[colorName]
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={label}
|
||||
className={cn(
|
||||
'text-xs border cursor-pointer pr-1',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border
|
||||
)}
|
||||
onClick={() => handleToggleLabel(label)}
|
||||
>
|
||||
{label}
|
||||
<X className="h-3 w-3 ml-1" />
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
229
keep-notes/components/label-manager.tsx
Normal file
229
keep-notes/components/label-manager.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './ui/dialog'
|
||||
import { Badge } from './ui/badge'
|
||||
import { Tag, X, Plus, Palette } from 'lucide-react'
|
||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||
import { getLabelColor, setLabelColor, deleteLabelColor, getAllLabelColors } from '@/lib/label-storage'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LabelManagerProps {
|
||||
existingLabels: string[]
|
||||
onUpdate: (labels: string[]) => void
|
||||
}
|
||||
|
||||
export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [newLabel, setNewLabel] = useState('')
|
||||
const [selectedLabels, setSelectedLabels] = useState<string[]>(existingLabels)
|
||||
const [allLabelsInStorage, setAllLabelsInStorage] = useState<string[]>([])
|
||||
const [editingColor, setEditingColor] = useState<string | null>(null)
|
||||
|
||||
// Load all labels from localStorage
|
||||
useEffect(() => {
|
||||
const allColors = getAllLabelColors()
|
||||
setAllLabelsInStorage(Object.keys(allColors))
|
||||
}, [open])
|
||||
|
||||
const handleAddLabel = () => {
|
||||
const trimmed = newLabel.trim()
|
||||
if (trimmed && !selectedLabels.includes(trimmed)) {
|
||||
const updated = [...selectedLabels, trimmed]
|
||||
setSelectedLabels(updated)
|
||||
setNewLabel('')
|
||||
|
||||
// Set default color if doesn't exist
|
||||
if (getLabelColor(trimmed) === 'gray') {
|
||||
const colors = Object.keys(LABEL_COLORS) as LabelColorName[]
|
||||
const randomColor = colors[Math.floor(Math.random() * colors.length)]
|
||||
setLabelColor(trimmed, randomColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveLabel = (label: string) => {
|
||||
setSelectedLabels(selectedLabels.filter(l => l !== label))
|
||||
}
|
||||
|
||||
const handleSelectExisting = (label: string) => {
|
||||
if (!selectedLabels.includes(label)) {
|
||||
setSelectedLabels([...selectedLabels, label])
|
||||
} else {
|
||||
setSelectedLabels(selectedLabels.filter(l => l !== label))
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangeColor = (label: string, color: LabelColorName) => {
|
||||
setLabelColor(label, color)
|
||||
setEditingColor(null)
|
||||
// Force re-render
|
||||
const allColors = getAllLabelColors()
|
||||
setAllLabelsInStorage(Object.keys(allColors))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(selectedLabels)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectedLabels(existingLabels)
|
||||
setEditingColor(null)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
handleCancel()
|
||||
} else {
|
||||
setOpen(true)
|
||||
}
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Tag className="h-4 w-4 mr-2" />
|
||||
Labels
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Labels</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add or remove labels for this note. Click on a label to change its color.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Add new label */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="New label name"
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddLabel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={handleAddLabel} size="sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Selected labels */}
|
||||
{selectedLabels.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Selected Labels</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedLabels.map((label) => {
|
||||
const colorName = getLabelColor(label)
|
||||
const colorClasses = LABEL_COLORS[colorName]
|
||||
const isEditing = editingColor === label
|
||||
|
||||
return (
|
||||
<div key={label} className="relative">
|
||||
{isEditing ? (
|
||||
<div className="absolute z-10 top-8 left-0 bg-white dark:bg-zinc-900 border rounded-lg shadow-lg p-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
|
||||
const classes = LABEL_COLORS[color]
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
||||
classes.bg,
|
||||
colorName === color ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-600'
|
||||
)}
|
||||
onClick={() => handleChangeColor(label, color)}
|
||||
title={color}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-xs border cursor-pointer pr-1 flex items-center gap-1',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border
|
||||
)}
|
||||
onClick={() => setEditingColor(isEditing ? null : label)}
|
||||
>
|
||||
<Palette className="h-3 w-3" />
|
||||
{label}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveLabel(label)
|
||||
}}
|
||||
className="ml-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available labels from storage */}
|
||||
{allLabelsInStorage.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">All Labels</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allLabelsInStorage
|
||||
.filter(label => !selectedLabels.includes(label))
|
||||
.map((label) => {
|
||||
const colorName = getLabelColor(label)
|
||||
const colorClasses = LABEL_COLORS[colorName]
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={label}
|
||||
className={cn(
|
||||
'text-xs border cursor-pointer',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border,
|
||||
'hover:opacity-80'
|
||||
)}
|
||||
onClick={() => handleSelectExisting(label)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||
import { Note, NOTE_COLORS, NoteColor, LABEL_COLORS } from '@/lib/types'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
@@ -22,26 +22,37 @@ import {
|
||||
Trash2,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } 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'
|
||||
import { getLabelColor } from '@/lib/label-storage'
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note
|
||||
onEdit?: (note: Note) => void
|
||||
onDragStart?: (note: Note) => void
|
||||
onDragEnd?: () => void
|
||||
onDragOver?: (note: Note) => void
|
||||
isDragging?: boolean
|
||||
isDragOver?: boolean
|
||||
}
|
||||
|
||||
export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isDragging }: NoteCardProps) {
|
||||
export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [labelColors, setLabelColors] = useState<Record<string, string>>({})
|
||||
const colorClasses = NOTE_COLORS[note.color as NoteColor] || NOTE_COLORS.default
|
||||
|
||||
// Load label colors from localStorage
|
||||
useEffect(() => {
|
||||
if (note.labels) {
|
||||
const colors: Record<string, string> = {}
|
||||
note.labels.forEach(label => {
|
||||
colors[label] = getLabelColor(label)
|
||||
})
|
||||
setLabelColors(colors)
|
||||
}
|
||||
}, [note.labels])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirm('Are you sure you want to delete this note?')) {
|
||||
setIsDeleting(true)
|
||||
@@ -79,22 +90,14 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
|
||||
|
||||
return (
|
||||
<Card
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation()
|
||||
onDragStart?.(note)
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDragOver?.(note)
|
||||
}}
|
||||
className={cn(
|
||||
'group relative p-4 transition-all duration-200 border',
|
||||
'cursor-move hover:shadow-md',
|
||||
'note-card-main group relative p-4 transition-all duration-200 border cursor-move',
|
||||
'hover:shadow-md',
|
||||
colorClasses.bg,
|
||||
colorClasses.card,
|
||||
isDragging && 'opacity-30 scale-95'
|
||||
colorClasses.hover,
|
||||
isDragging && 'opacity-30',
|
||||
isDragOver && 'ring-2 ring-blue-500'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
// Only trigger edit if not clicking on buttons
|
||||
@@ -229,11 +232,23 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
|
||||
{/* Labels */}
|
||||
{note.labels && note.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{note.labels.map((label) => (
|
||||
<Badge key={label} variant="secondary" className="text-xs">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
{note.labels.map((label) => {
|
||||
const colorName = labelColors[label] || 'gray'
|
||||
const colorClasses = LABEL_COLORS[colorName as keyof typeof LABEL_COLORS] || LABEL_COLORS.gray
|
||||
return (
|
||||
<Badge
|
||||
key={label}
|
||||
className={cn(
|
||||
'text-xs border',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Note, CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||
import { Note, CheckItem, NOTE_COLORS, NoteColor, LABEL_COLORS } from '@/lib/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -23,6 +23,8 @@ import { updateNote } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
import { LabelManager } from './label-manager'
|
||||
import { getLabelColor } from '@/lib/label-storage'
|
||||
|
||||
interface NoteEditorProps {
|
||||
note: Note
|
||||
@@ -306,17 +308,29 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{labels.map((label) => (
|
||||
<Badge key={label} variant="secondary" className="gap-1">
|
||||
{label}
|
||||
<button
|
||||
onClick={() => handleRemoveLabel(label)}
|
||||
className="hover:text-red-600"
|
||||
{labels.map((label) => {
|
||||
const colorName = getLabelColor(label)
|
||||
const colorClasses = LABEL_COLORS[colorName]
|
||||
return (
|
||||
<Badge
|
||||
key={label}
|
||||
className={cn(
|
||||
'gap-1 border',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border
|
||||
)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{label}
|
||||
<button
|
||||
onClick={() => handleRemoveLabel(label)}
|
||||
className="hover:text-red-600"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -370,31 +384,10 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Label Manager */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" title="Add label">
|
||||
<Tag className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64">
|
||||
<div className="p-2 space-y-2">
|
||||
<Input
|
||||
placeholder="Enter label name"
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddLabel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" onClick={handleAddLabel} className="w-full">
|
||||
Add Label
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<LabelManager
|
||||
existingLabels={labels}
|
||||
onUpdate={setLabels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -2,93 +2,257 @@
|
||||
|
||||
import { Note } from '@/lib/types'
|
||||
import { NoteCard } from './note-card'
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { NoteEditor } from './note-editor'
|
||||
import { reorderNotes } from '@/app/actions/notes'
|
||||
import { reorderNotes, getNotes } from '@/app/actions/notes'
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
rectSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface NoteGridProps {
|
||||
notes: Note[]
|
||||
}
|
||||
|
||||
function SortableNote({ note, onEdit }: { note: Note; onEdit: (note: Note) => void }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: note.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 1000 : 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
data-note-id={note.id}
|
||||
data-draggable="true"
|
||||
>
|
||||
<NoteCard note={note} onEdit={onEdit} isDragging={isDragging} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteGrid({ notes }: NoteGridProps) {
|
||||
const router = useRouter()
|
||||
const [editingNote, setEditingNote] = useState<Note | null>(null)
|
||||
const [draggedNote, setDraggedNote] = useState<Note | null>(null)
|
||||
const [dragOverNote, setDragOverNote] = useState<Note | null>(null)
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const [localPinnedNotes, setLocalPinnedNotes] = useState<Note[]>([])
|
||||
const [localUnpinnedNotes, setLocalUnpinnedNotes] = useState<Note[]>([])
|
||||
|
||||
const pinnedNotes = notes.filter(note => note.isPinned).sort((a, b) => a.order - b.order)
|
||||
const unpinnedNotes = notes.filter(note => !note.isPinned).sort((a, b) => a.order - b.order)
|
||||
// Sync local state with props
|
||||
useEffect(() => {
|
||||
setLocalPinnedNotes(notes.filter(note => note.isPinned).sort((a, b) => a.order - b.order))
|
||||
setLocalUnpinnedNotes(notes.filter(note => !note.isPinned).sort((a, b) => a.order - b.order))
|
||||
}, [notes])
|
||||
|
||||
const handleDragStart = (note: Note) => {
|
||||
setDraggedNote(note)
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 200,
|
||||
tolerance: 6,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
console.log('[DND-DEBUG] Drag started:', {
|
||||
activeId: event.active.id,
|
||||
activeData: event.active.data.current
|
||||
})
|
||||
setActiveId(event.active.id as string)
|
||||
}
|
||||
|
||||
const handleDragEnd = async () => {
|
||||
if (draggedNote && dragOverNote && draggedNote.id !== dragOverNote.id) {
|
||||
// Reorder notes
|
||||
const sourceIndex = notes.findIndex(n => n.id === draggedNote.id)
|
||||
const targetIndex = notes.findIndex(n => n.id === dragOverNote.id)
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
console.log('[DND-DEBUG] Drag ended:', {
|
||||
activeId: active.id,
|
||||
overId: over?.id,
|
||||
hasOver: !!over
|
||||
})
|
||||
setActiveId(null)
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
console.log('[DND-DEBUG] Drag cancelled: no valid drop target or same element')
|
||||
return
|
||||
}
|
||||
|
||||
const activeIdStr = active.id as string
|
||||
const overIdStr = over.id as string
|
||||
|
||||
// Determine which section the dragged note belongs to
|
||||
const isInPinned = localPinnedNotes.some(n => n.id === activeIdStr)
|
||||
const targetIsInPinned = localPinnedNotes.some(n => n.id === overIdStr)
|
||||
|
||||
console.log('[DND-DEBUG] Section check:', {
|
||||
activeIdStr,
|
||||
overIdStr,
|
||||
isInPinned,
|
||||
targetIsInPinned,
|
||||
pinnedNotesCount: localPinnedNotes.length,
|
||||
unpinnedNotesCount: localUnpinnedNotes.length
|
||||
})
|
||||
|
||||
// Only allow reordering within the same section
|
||||
if (isInPinned !== targetIsInPinned) {
|
||||
console.log('[DND-DEBUG] Drag cancelled: crossing sections (pinned/unpinned)')
|
||||
return
|
||||
}
|
||||
|
||||
if (isInPinned) {
|
||||
// Reorder pinned notes
|
||||
const oldIndex = localPinnedNotes.findIndex(n => n.id === activeIdStr)
|
||||
const newIndex = localPinnedNotes.findIndex(n => n.id === overIdStr)
|
||||
|
||||
await reorderNotes(draggedNote.id, dragOverNote.id)
|
||||
console.log('[DND-DEBUG] Pinned reorder:', { oldIndex, newIndex })
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
const newOrder = arrayMove(localPinnedNotes, oldIndex, newIndex)
|
||||
setLocalPinnedNotes(newOrder)
|
||||
console.log('[DND-DEBUG] Calling reorderNotes for pinned notes')
|
||||
await reorderNotes(activeIdStr, overIdStr)
|
||||
|
||||
// Refresh notes from server to sync state
|
||||
console.log('[DND-DEBUG] Refreshing notes from server after reorder')
|
||||
await refreshNotesFromServer()
|
||||
} else {
|
||||
console.log('[DND-DEBUG] Invalid indices for pinned reorder')
|
||||
}
|
||||
} else {
|
||||
// Reorder unpinned notes
|
||||
const oldIndex = localUnpinnedNotes.findIndex(n => n.id === activeIdStr)
|
||||
const newIndex = localUnpinnedNotes.findIndex(n => n.id === overIdStr)
|
||||
|
||||
console.log('[DND-DEBUG] Unpinned reorder:', { oldIndex, newIndex })
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
const newOrder = arrayMove(localUnpinnedNotes, oldIndex, newIndex)
|
||||
setLocalUnpinnedNotes(newOrder)
|
||||
console.log('[DND-DEBUG] Calling reorderNotes for unpinned notes')
|
||||
await reorderNotes(activeIdStr, overIdStr)
|
||||
|
||||
// Refresh notes from server to sync state
|
||||
console.log('[DND-DEBUG] Refreshing notes from server after reorder')
|
||||
await refreshNotesFromServer()
|
||||
} else {
|
||||
console.log('[DND-DEBUG] Invalid indices for unpinned reorder')
|
||||
}
|
||||
}
|
||||
setDraggedNote(null)
|
||||
setDragOverNote(null)
|
||||
}
|
||||
|
||||
const handleDragOver = (note: Note) => {
|
||||
if (draggedNote && draggedNote.id !== note.id) {
|
||||
setDragOverNote(note)
|
||||
}
|
||||
// Function to refresh notes from server without full page reload
|
||||
const refreshNotesFromServer = async () => {
|
||||
console.log('[DND-DEBUG] Fetching fresh notes from server...')
|
||||
const freshNotes = await getNotes()
|
||||
console.log('[DND-DEBUG] Received fresh notes:', freshNotes.length)
|
||||
|
||||
// Update local state with fresh data
|
||||
const pinned = freshNotes.filter(note => note.isPinned).sort((a, b) => a.order - b.order)
|
||||
const unpinned = freshNotes.filter(note => !note.isPinned).sort((a, b) => a.order - b.order)
|
||||
|
||||
setLocalPinnedNotes(pinned)
|
||||
setLocalUnpinnedNotes(unpinned)
|
||||
|
||||
console.log('[DND-DEBUG] Local state updated with fresh server data')
|
||||
}
|
||||
|
||||
// Find active note from either section
|
||||
const activeNote = activeId
|
||||
? localPinnedNotes.find(n => n.id === activeId) || localUnpinnedNotes.find(n => n.id === activeId)
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-8">
|
||||
{pinnedNotes.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3 px-2">
|
||||
Pinned
|
||||
</h2>
|
||||
<div className="columns-1 sm:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5 gap-4 space-y-4">
|
||||
{pinnedNotes.map(note => (
|
||||
<div key={note.id} className="break-inside-avoid mb-4">
|
||||
<NoteCard
|
||||
note={note}
|
||||
onEdit={setEditingNote}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
isDragging={draggedNote?.id === note.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unpinnedNotes.length > 0 && (
|
||||
<div>
|
||||
{pinnedNotes.length > 0 && (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{localPinnedNotes.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3 px-2">
|
||||
Others
|
||||
Pinned
|
||||
</h2>
|
||||
)}
|
||||
<div className="columns-1 sm:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5 gap-4 space-y-4">
|
||||
{unpinnedNotes.map(note => (
|
||||
<div key={note.id} className="break-inside-avoid mb-4">
|
||||
<NoteCard
|
||||
note={note}
|
||||
onEdit={setEditingNote}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
isDragging={draggedNote?.id === note.id}
|
||||
/>
|
||||
<SortableContext items={localPinnedNotes.map(n => n.id)} strategy={rectSortingStrategy}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 auto-rows-max">
|
||||
{localPinnedNotes.map((note) => (
|
||||
<SortableNote key={note.id} note={note} onEdit={setEditingNote} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{localUnpinnedNotes.length > 0 && (
|
||||
<div>
|
||||
{localPinnedNotes.length > 0 && (
|
||||
<h2 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3 px-2">
|
||||
Others
|
||||
</h2>
|
||||
)}
|
||||
<SortableContext items={localUnpinnedNotes.map(n => n.id)} strategy={rectSortingStrategy}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 auto-rows-max">
|
||||
{localUnpinnedNotes.map((note) => (
|
||||
<SortableNote key={note.id} note={note} onEdit={setEditingNote} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DragOverlay>
|
||||
{activeNote ? (
|
||||
<div className="opacity-90 rotate-2 scale-105 shadow-2xl">
|
||||
<NoteCard
|
||||
note={activeNote}
|
||||
onEdit={() => {}}
|
||||
isDragging={true}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{notes.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">No notes yet</p>
|
||||
@@ -98,10 +262,7 @@ export function NoteGrid({ notes }: NoteGridProps) {
|
||||
</div>
|
||||
|
||||
{editingNote && (
|
||||
<NoteEditor
|
||||
note={editingNote}
|
||||
onClose={() => setEditingNote(null)}
|
||||
/>
|
||||
<NoteEditor note={editingNote} onClose={() => setEditingNote(null)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user